CLose story 1.2

This commit is contained in:
2026-05-21 23:08:34 +02:00
commit 110b295a7b
75 changed files with 16065 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
# Decision Log — Video View Manager Product Brief
## Session: 2026-05-19
### Decisions Captured
| # | Decision | Rationale | Source |
|---|---|---|---|
| 1 | Module is free / open source | User confirmed | User input |
| 2 | Target platform: FoundryVTT v14 | User confirmed | User input |
| 3 | North star feature: GM one-click cam toggle | Confirmed and anchored by user at end of brainstorming | Brainstorming session |
| 4 | Progressive Enhancement as architecture (L1→L2→L3) | Emerged from SCAMPER-Eliminate; user confirmed | Brainstorming session |
| 5 | Dedicated popout for seating chart (not sidebar) | User explicitly chose popout — "we will not do this frequently" | User input |
| 6 | Reaction cam is opt-in at character setup | Resolved Marcus/Sofia privacy tension | User confirmed during Role Playing |
| 7 | v1.0 scope = right-click toggle only | User reanchored to core feature; all else is additive | User input |
| 8 | Brief purpose: FoundryVTT community pitch | User selected option [2] | User input |
| 9 | Display name assumed as "Video View Manager" | User did not specify; derived from package ID | [ASSUMPTION] — confirm or override |
| 10 | No known competitor modules | User described current state as "complex and not flexible" with no named alternatives | User input — [ASSUMPTION] worth validating |
### Open Questions
- [ ] Confirm display/community name for the module
- [ ] Validate: any existing FoundryVTT v14 modules that offer per-player cam visibility control?
- [ ] Technical constraint: does FoundryVTT v14 WebRTC API support real track disabling, or is it CSS-only? This affects architecture of the core toggle.
| 11 | Module display name: **Scrying Pool** | User selected from proposed names; thematic, memorable, GM-perspective | User input |
| 12 | Assumption #9 resolved: display name confirmed as "Scrying Pool" | See decision #11 | User input |
| 13 | Assumption #10 resolved: no competitor modules confirmed | Exhaustive research of FoundryVTT registry + GitHub found zero per-player GM webcam visibility modules | Research agent |
| 14 | Key adjacent modules noted: LiveKit (local-only hide), Camera Dock (layout only), OBS Utils (map viewport), Better Cams (CSS only) | Differentiation is confirmed genuine | Research agent |
| 15 | Technical note: FoundryVTT native AVMaster exposes `canUserShareVideo()` and per-user `hidden` flag — but self-controlled only. GM writing to another user's setting is unexploited by any existing module. Core toggle is technically feasible. | Confirms architectural approach | Research agent |
## Finalized: 2026-05-19
Doc standards applied (structural + prose). Status set to final.
@@ -0,0 +1,87 @@
---
title: "Product Brief: Scrying Pool"
status: final
created: 2026-05-19
updated: 2026-05-19
---
# Product Brief: Scrying Pool
## Executive Summary
Scrying Pool is a free, open-source FoundryVTT v14 module that gives the GM direct control over player webcam visibility. Instead of Foundry's all-or-nothing AV behavior, the GM can show or hide individual camera feeds in one click without interrupting play.
The core promise is simple: **the GM can control any player's visible webcam feed at any time with minimal friction.** Everything else builds from that. At the lowest level, the module works from the existing camera tiles through a right-click menu. Over time, it grows into scene presets, spotlighting, and broader table-direction tools.
## The Problem
FoundryVTT's built-in AV support is too coarse for most online tables. Cameras are effectively either visible or not, with no practical way for the GM to manage visibility per player, per moment, or per scene.
That leaves GMs with bad options: tolerate a cluttered camera strip, run a second video tool beside Foundry, or stop using webcams altogether. The result is less table presence and less control over how the session feels.
The missing piece is not video support. It is video management.
## The Solution
Scrying Pool adds a GM-controlled visibility layer on top of FoundryVTT's existing AV system. The basic interaction is straightforward: right-click a player's camera tile and choose Show or Hide. The change applies immediately for all connected clients and persists across reconnects.
The module follows a progressive enhancement model:
- **Level 1 — Right-click controls.** No new interface, no setup barrier, immediate utility.
- **Level 2 — Director's Board.** A compact control strip for quick live adjustments, plus scene-based presets.
- **Level 3 — Table Manager.** A dedicated popout for bulk control, status visibility, and advanced preset handling.
That approach keeps v1 useful on its own while leaving room for deeper tooling later.
## What Makes This Different
- **It solves the exact missing control.** The GM manages individual webcam visibility directly from the interface they already use.
- **It treats visibility as part of session direction.** Webcam layout becomes something the GM can prepare and apply by scene, not improvise mid-session.
- **It respects player agency.** Privacy-sensitive features are opt-in by design, not patched in later.
- **It fills a confirmed gap.** An exhaustive search of the FoundryVTT registry and GitHub found no module that provides GM-controlled per-player webcam visibility. Comparable modules address AV backends (LiveKit), layout changes (Camera Dock), map viewport streaming (OBS Utils), or cosmetics (Better Cams) — not visibility management.
## Who This Serves
**Primary — GMs running regular online campaigns.** They want fast control during play and predictable behavior that does not demand constant attention.
**Secondary — Privacy-conscious players.** They want clear expectations and protection against unwanted camera exposure.
**Tertiary — Actual-play streamers.** A curated broadcast view and advanced controls are on the roadmap, but not the first target.
## Success Criteria
- A GM can hide or show any player's webcam in one click without disrupting the session.
- Visibility state survives player disconnects and reconnects.
- The v1 interaction is discoverable enough that most GMs can use it without documentation.
- The module works reliably on FoundryVTT v14 and is maintainable enough for registry publication.
## Scope
**In for v1.0**
- Per-player GM webcam toggle from the existing AV tile context menu
- World-level persistence of visibility state
- Socket-based updates so all clients stay in sync
- Clear visual indicator for hidden tiles
- Portrait fallback when no camera is available
- Contextual toast notifications
- FoundryVTT v14 compatibility
**Out for v1.0**
- Director's Board
- Table Manager popout
- Scene-aware presets
- Combat spotlighting
- Player privacy preferences UI
- Streaming or OBS-specific tooling
- Token-anchored or stylized camera effects
- NPC or reaction-driven camera systems
**Deferred pending validation**
- True WebRTC track disabling rather than cosmetic hiding
- Multi-GM support
## Vision
If v1.0 proves the core interaction is reliable, the next step is scene-aware presets and a lightweight Director's Board so GMs can set webcam visibility during prep and trust it during play. After that, privacy controls, spotlighting, and streaming support extend Scrying Pool into a broader table-direction tool.
The long-term goal is modest but useful: make webcam feeds feel like part of session design rather than a side effect of turning AV on.
+894
View File
@@ -0,0 +1,894 @@
---
stepsCompleted: [1, 2, 3, 4]
inputDocuments:
- _bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/prd.md
- _bmad-output/planning-artifacts/architecture.md
- _bmad-output/planning-artifacts/ux-design-specification.md
---
# video-view-manager - Epic Breakdown
## Overview
This document provides the complete epic and story breakdown for video-view-manager, decomposing the requirements from the PRD, UX Design, and Architecture into implementable stories.
## Requirements Inventory
### Functional Requirements
FR-1: GM toggles Participant visibility via right-click context menu ("Hide from table" / "Show to table") on any AV Tile; sets target Participant's Visibility State to `hidden` or `active` for all Viewers; AV Tile indicator updates on all connected clients within 500 ms.
FR-2: All Visibility Matrix changes are broadcast to all connected clients in real time via FoundryVTT native socket API; new clients joining mid-session receive the current Visibility Matrix; state-change latency ≤500 ms on local network.
FR-3: Visibility Matrix state persists in world-level settings across page refreshes and full server restarts; a Participant who disconnects/reconnects returns to their previously set state; new Participants default to `active` on first connection.
FR-4: AV Tile visual indicator distinguishes all Participant States using plain-language labels and icons: `hidden` = grey overlay + lock icon + "Camera hidden by GM" tooltip; `self-muted` = camera-off icon; `offline` = disconnection icon; `cam-lost` = camera-error icon; `reconnecting` = spinner icon; all icons from FoundryVTT library, no external dependency.
FR-5: All eight Participant States (`active`, `hidden`, `self-muted`, `offline`, `cam-lost`, `reconnecting`, `never-connected`, `ghost`) render appropriate visual feedback without AV Tile reflow, layout shift, or disruption to other Participants' tiles.
FR-6: GM always sees all activated Participant feeds regardless of Visibility State (hidden tiles at reduced opacity + lock overlay); GM hears audio from all Participants; GM self-view in own interface configurable via module setting "Show my own feed to myself" (default: ON).
FR-7: WebRTC track disabling is the preferred implementation when the FoundryVTT v14 API allows programmatic track access (no inbound video bandwidth consumed for hidden feeds); CSS/DOM cosmetic hiding is the fallback; a world setting reports the active mode.
FR-8: Portrait Fallback displayed when Participant has no camera (`never-connected`) or enters `cam-lost` state; default is FoundryVTT user avatar falling back to system placeholder; renders at same dimensions as a live camera-feed tile; Participants can set custom Portrait Fallback via Player Privacy Panel (FR-26).
FR-9: GM opens Director's Board via a dedicated sidebar button and keyboard shortcut (default: `Ctrl+Shift+V`); Director's Board opens as a resizable, draggable `ApplicationV2` window; shortcut is configurable in module settings; opening does not change existing AV Tile strip.
FR-10: Director's Board displays full Visibility Matrix in a seating-chart layout; every Participant card shows name, portrait, current Participant State, and current Visibility State; Visibility State updates appear within 500 ms.
FR-11: Per-Participant visibility toggle from Director's Board with a single click on a Participant card; behavior and persistence match FR-1.
FR-12: Bulk actions "Show All" and "Hide All" in Director's Board; one-step Undo restores the Visibility Matrix immediately before the bulk action; Participants in `ghost` state excluded from bulk actions.
FR-13: Spotlight action shows exactly one Participant's feed and hides all others in a single action; stores current Visibility Matrix as pre-spotlight snapshot; single "Restore" action reverts to snapshot; distinct from manual Hide All + Show One.
FR-14: All primary Director's Board actions keyboard-accessible without a mouse: `Space`/`Enter` toggles focused Participant; arrow keys move focus between cards; `Ctrl+Shift+S` = Show All; `Ctrl+Shift+H` = Hide All; `Ctrl+Shift+P` = Spotlight focused Participant; `?` opens shortcut reference panel; all shortcuts configurable and documented.
FR-15: GM saves a named Scene Preset from the current Visibility Matrix (single action from Director's Board or module settings); preset captures full current matrix; names are editable and unique per world; up to 50 presets per world.
FR-16: GM loads a Scene Preset at any time, overriding the current Visibility Matrix; all clients receive state within 500 ms; loading generates notification "GM applied preset: [Preset Name]"; offline Participants receive stored state on reconnection.
FR-17: Scene Preset auto-applies on FoundryVTT Scene activation via `updateScene` hook; Scene-to-Preset association configured in Scene or module settings; configurable 05000 ms pre-delay; all clients receive "Scene changed: camera layout updated" notification.
FR-18: Scene Preset auto-apply can be disabled per-scene (without removing association) or globally via module settings; Director's Board always provides a manual override regardless of automation state.
FR-19: Preset import/export as JSON; export downloads all presets as one human-readable JSON file; import reads JSON and merges or replaces (user's choice); invalid JSON shows error; README documents exported format.
FR-20: Toast notification to all Participants when GM changes any Participant's Visibility State; message uses Participant display name ("GM hid [Name]'s camera" / "GM showed [Name]'s camera"); uses FoundryVTT native notification UI; affected Participant receives distinct personal notification.
FR-21: Notification verbosity configurable per user: `All` (default), `GM Only`, `Silent`; configuration stored in user-level client settings; `Silent` mode still shows personal notification to affected Participant — GM cannot suppress personal message.
FR-22: Persistent feed status badge on own AV Tile visible only to the owning Participant; shows "Live," "Hidden by GM," "Muted," or "No Camera"; updates within 500 ms when state changes.
FR-23: Player Privacy Panel accessible from module settings; lists every automation effect that can touch the owning user with current opt-in status; owning user can edit; GM can view but not edit another Participant's settings; settings persist in world-level user flags.
FR-24: Reaction Cam automation requires explicit Participant opt-in (default: off); Reaction Cam remains disabled until Participant enables it in Player Privacy Panel; Director's Board shows "Reaction Cam: Enabled" badge on opted-in cards; opt-in flag persists across sessions; all Reaction Cam triggers (Combat Cinematics Mode etc.) respect and skip opted-out Participants silently.
FR-25: HP-Reactive Cam Styling requires explicit Participant opt-in (default: off); disabled until Participant explicitly enables it; GM is not notified of individual styling opt-in statuses.
FR-26: Custom Portrait Fallback settable via file picker in Player Privacy Panel; accepted formats: PNG, JPG, WEBP, static GIF; falls back to FoundryVTT user avatar, then to system placeholder if no avatar exists.
### NonFunctional Requirements
NFR-1 (Compatibility): Module must not conflict with other popular FoundryVTT modules by patching shared DOM selectors or overriding core hooks without proper chaining; all hooks use `Hooks.on()` registration pattern, never `Hooks.once()` for persistent behavior.
NFR-2 (Performance): No Visibility Matrix operation blocks the FoundryVTT main render loop (state changes apply asynchronously); Director's Board renders and becomes interactive within 1 second with 12 Participants; socket message payload for a Visibility Matrix update ≤4 KB.
NFR-3 (Reliability): Socket broadcast retries up to 3 times on network interruption before surfacing an error notification; module fails gracefully when `game.webrtc` is unavailable (AV disabled) — all UI elements hidden or disabled, no uncaught errors thrown.
NFR-4 (Privacy): Module transmits no data outside the FoundryVTT world; no analytics, telemetry, or third-party calls; Participant names and portraits used only within the FoundryVTT session.
NFR-5 (Accessibility): All interactive elements in the Director's Board have ARIA labels and are keyboard navigable; state-indicator icons include tooltip text for screen-reader compatibility; WCAG AA contrast required for all state tokens against both Foundry dark and light themes; every state has a second signal beyond colour (icon, shape, or motion).
NFR-6 (Language / Voice): Default UI labels use plain language: "Show," "Hide," "Spotlight," "Hidden by GM," "No Camera"; technical or cinematic vocabulary reserved for documentation, tooltips in advanced mode, and developer-facing strings only; two-tier vocabulary applies to all present and future features.
NFR-7 (GM Override Guarantee): Every automation feature must expose an obvious one-click GM override accessible without opening configuration UI; Director's Board "Hide All" is the module's emergency path; no automation may be implemented if it cannot be interrupted or overridden immediately by the GM.
### Additional Requirements
- **Custom minimal scaffold (no external bundler/framework):** Vanilla JavaScript ES2022+ with native ESM; LESS 4.6.4 → CSS via chokidar watch; Handlebars `.hbs` templates (ApplicationV2 PARTS); no external UI libraries; no socketlib; Font Awesome 6 and Foundry CSS custom properties only.
- **Story 0 scaffold deliverables (all are AC blockers):** `module.json` (v14 manifest), `tsconfig.json` (`checkJs+strict+noEmit`), `.eslintrc.js` (`jsdoc/require-jsdoc` on exported symbols; `import/no-restricted-paths` boundary enforcement), `vitest.config.js` (happy-dom environment), `scripts/package.mjs` (produces `module.zip`; single version source of truth), `tests/fixtures/socket-payloads.js` (socket payload contract tests), `.gitignore`, `styles/scrying-pool.less` (LESS entry point), `lang/en.json` (i18n skeleton).
- **Dependency injection hard rule:** `StateStore`, `SocketHandler`, `VisibilityManager`, `RoleRenderer` MUST have zero direct `game.*` access; all Foundry API dependencies constructor-injected via `FoundryAdapter` interface (required for Vitest testability).
- **FoundryAdapter surface contract:** `settings`, `socket`, `users`, `scenes`, `notifications`, `webrtc` (feature-detected; `null` if OQ-1 unresolvable), `hooks` — surfaces defined and injected at init time.
- **Initialisation order:** `Hooks.once('init')` → register world settings → construct `FoundryAdapter``StateStore``SocketHandler` (queue+drain); `Hooks.once('ready')``VisibilityManager``SocketHandler.setReady()``NotificationBus``RoleRenderer``RosterStrip`/`ScryingPoolStrip``DirectorsBoard` (lazy, GM only).
- **Data persistence:** Visibility Matrix → world setting `scrying-pool.visibilityMatrix` with `{ _version: 1, matrix: {...} }` wrapper; Scene Presets → Scene document flag `{ _version: 1, presets: {...} }`; notification verbosity → user-level client setting; `firstBadgeEncounter``game.user.setFlag('video-view-manager', 'firstBadgeEncounter')`.
- **Socket reconciliation:** GM intent → `socket.emit(intent)` → all clients receive authoritative echo → GM clears `PendingOp`; 3s fallback timeout; retry-once before revert; stale ACK guard by `opId`/revision; `PendingOp` contract: `{ opId, userId, targetState, previousState, issuedAt, timeoutId }`.
- **Contract files:** `src/contracts/visibility-matrix.js`, `src/contracts/socket-message.js`, `src/contracts/pending-op.js`, `src/contracts/scene-preset.js` — validated at send and receive.
- **CI/CD:** Gitea workflows (`.gitea/workflows/`); lint + typecheck + test on every push; release via `scripts/package.mjs``module.zip`.
- **OQ-1 architectural risk:** WebRTC track disabling API availability on v14 must be spiked before Level 1 is finalized; CSS fallback is the safe default until confirmed.
- **World-vs-client persistence boundary:** Visibility Matrix and Presets → world settings; notification verbosity → client settings; `firstBadgeEncounter` → user flag; `StateStore` is the sole writer for in-memory state and sole caller of `adapter.settings.set()`.
- **Naming/prefix conventions:** All world settings prefixed `scrying-pool.`; all socket events prefixed `scrying-pool.`; all CSS classes prefixed `.sp-` or scoped under `.scrying-pool`; public API "not found" returns `null`, never `undefined`.
### UX Design Requirements
UX-DR1: Implement 3-tier design token architecture — Layer 1: SP semantic alias tokens (`--sp-surface`, `--sp-border`, `--sp-text-primary`, `--sp-text-secondary`, `--sp-accent`, `--sp-focus`) mapping to Foundry tokens with hardcoded fallbacks; Layer 2: SP Participant State tokens for all 8 states; Layer 3: SP Urgency Tier tokens (`--sp-urgency-director` cool/deliberate, `--sp-urgency-awareness` neutral, background operations = no toast ever); Layer 4: SP Motion tokens (`--sp-fade-hide`, `--sp-pulse-reconnecting`, `--sp-shimmer-degraded`, `--sp-toast-delay`).
UX-DR2: All module CSS scoped under `.scrying-pool` namespace; no bare `button`, `a`, `input` selectors; forbidden to use Foundry `--color-*`/`--font-*`/`--border-*` tokens directly inside module CSS — always aliased through `--sp-*` layer; linting convention enforces this as the sole enforcement point for the semantic layer.
UX-DR3: Implement `StateRing` CSS primitive as the single ring implementation (no ad-hoc ring CSS anywhere else): `.sp-state-ring--solid`, `--dashed`, `--pending` (animated pulse in `no-preference`), `--revert` variants; all animations gated under `@media (prefers-reduced-motion: no-preference)`.
UX-DR4: Implement `ParticipantAvatar` component (44×44px container; 32px rounded avatar + `StateRing` + 12px corner badge bottom-right) with correct visual rendering for all 8 states + pending/revert; `role="button"`, `aria-label="[Name] — [state label]"`, `aria-pressed` when popover open; click → `StripOverlayLayer.openPopover()`; right-click → `ContextMenu`.
UX-DR5: Implement `ScryingPoolStrip` as floating `ApplicationV2` window: collapsed (avatar-only, 44px) ↔ expanded (rich rows, 240px) toggle via `.is-expanded` class + `max-width` transition (never `width` animation); `firstStripOpen` one-time onboarding tip; position/state persisted to GM User flag `{ left, top, open, expanded }`; `role="complementary"`, `aria-label="Scrying Pool"`.
UX-DR6: Implement `StripOverlayLayer` as single overlay container for all positioned overlays (`position: absolute; inset: 0; pointer-events: none; overflow: visible`); children restore `pointer-events: auto`; `ActionPopover` anchors via `getBoundingClientRect()` relative to strip; one-at-a-time popover enforcement via supersede pattern.
UX-DR7: Implement `ActionPopover` as native `<dialog>` anchored via `StripOverlayLayer`; primary CTA "Hide from table" / "Show to table"; primary CTA `disabled` + `aria-disabled="true"` during `pending`; Esc / click-outside dismiss; `aria-labelledby` → participant name h3; focus → primary CTA on open.
UX-DR8: Implement `AVTileAdapter` for all Foundry AV tile DOM interaction: idempotent `mount(userId, badgeElement)`, `unmount(userId)`, `onTileRerender(userId, callback)` with scoped `MutationObserver`; no-op + `console.warn` if tile not found (fail-open); `disconnect()` on module teardown.
UX-DR9: Implement `VisibilityBadge` injection into AV tile DOM via `AVTileAdapter`; `role="status"`, `aria-live="polite"`, `aria-label="Camera visibility: [state label]"`; tokens on `:root` (badge is mounted outside `.scrying-pool` root); `firstBadgeEncounter` stored as user flag; check for existing badge before injecting — update in place; remove-and-reinsert only if structure requires full rebuild.
UX-DR10: Implement `FirstEncounterPanel` with 10s auto-collapse timer, `mouseenter`/`:focus-within` timer pause, "Got it" dismiss (sets `firstBadgeEncounter`), `max-height` fold 300ms ease-out collapse animation, panel→chip transition post-collapse; `aria-modal="false"`, `role="dialog"`, not a focus trap; `clearTimeout` on "Got it", click, and `_onClose()` teardown.
UX-DR11: Implement `VisibilityDetailsPanel` with actor ("Hidden by: [GM name]" / "Connection issue" / "Scene preset: [name]"), action, audience (list suppressed when hidden — show reassurance copy instead), note, reassurance sections; focus trap; `aria-modal="true"`, `<dialog>`; Esc + click-outside + "Close" dismiss; stale-data indicator when controller unavailable.
UX-DR12: Implement `ConfirmationBar` in `StripOverlayLayer` (`position: absolute; bottom: 0`): "Preset applied — N hidden, N visible" with amber variant for partial fail; "Undo" primary affordance; 8s auto-dismiss (4s if ≥2 presets applied within 60s); `opacity` transition only — never `height`/`max-height`; instant-replace rule (zero crossfade); `clearTimeout` on click, strip close, and `_onClose()` teardown.
UX-DR13: Implement `ParticipantCard` (80×100px; 48px avatar + `StateRing` + name 12px 2-line truncate + hover toggle-icon overlay) for Director's Board grid; states: `default` | `hover` | `pending` | `revert`; `role="listitem"`, `aria-label="[Name] — [state label]"`; hover toggle icon is keyboard-focusable `role="button"`.
UX-DR14: Implement `DirectorBoard` as `ApplicationV2`: CSS grid `auto-fill, minmax(80px, 1fr)` of `ParticipantCard` components; footer with "Save Preset…" / "Load Preset…"; dumb view subscribing to `ScryingPoolController` events — no local state cache; shortcut `Ctrl+Shift+V` to open/close.
UX-DR15: Implement `NotificationBus` coalescing layer above `ui.notifications`: `Map<participantId, {timer, lastState, changeCount}>`; 3s quiet period before coalesced notification fires; suppresses notification entirely if net state = original state; reports final state + change count in message.
UX-DR16: Implement `ScryingPoolController` as module-singleton (constructed at `Hooks.once("ready")`): owns `visibilityMatrix: Map<participantId, Map<viewerId, VisibilityState>>` and `pendingOps: Map<participantId, {opId, targetState, baseRevision}>`; `action(source, participantId, targetState, opId, baseRevision)` method; latest-revision-wins guard; per-participant last-intent guard; emits change events for Strip and Board to consume; manages retry policy and revert notifications.
UX-DR17: Implement player-facing vocabulary partition — player labels use human-readable copy; GM UI uses technical state names; `VisibilityBadge` and `VisibilityDetailsPanel` MUST use the player label table: `hidden`→"Hidden from table", `self-muted`→"Camera paused", `offline`→"Not connected", `cam-lost`→"Camera unavailable", `reconnecting`→"Rejoining view", `never-connected`→"Not yet connected", `ghost`→"Leaving", `active`→no label shown.
UX-DR18: Implement `EmptyStatePanel` with slow breathing/pulse animation on eye icon (reduced motion: static); "No participants yet" header with anticipatory copy; optional "Learn how visibility works" link; low-noise centred layout — not styled as an error or broken state.
UX-DR19: Implement 4-tier feedback pattern strictly: (1) ambient ring/badge = stable state; (2) optimistic pending ring pulse = op in flight; (3) `ConfirmationBar` = preset/bulk-op strip-local feedback; (4) `ui.notifications` = failure/revert only. No `ui.notifications` toast on success. Revert sequence: amber ring flash (200ms) → `ui.notifications.warn()` — ring fires first. SP overlays suspended when `ui.activeWindow` is a Foundry `Dialog`; queue ops and show confirmation immediately on dialog close.
UX-DR20: WCAG AA colour contrast required for all 8 participant state colour tokens against both Foundry dark and light themes; every state has a second signal beyond colour (icon, shape, or motion); `hidden`, `offline`, `never-connected`, `ghost` tokens MUST NOT appear as text or small-pill foreground only.
UX-DR21: Canonical action label rule — "Hide from table" and "Show to table" verbatim on every interaction surface (strip hover rail, `ActionPopover`, Director's Board card, right-click context menu); same string constant, never synonyms; first-hover tooltip variant ("Hide this participant from other players.") sets `firstHideTooltip` flag and reverts to canonical label on subsequent hovers.
### FR Coverage Map
FR-1: Epic 1 — GM right-click toggle on AV Tile
FR-2: Epic 1 — Real-time socket broadcast of Visibility Matrix changes
FR-3: Epic 1 — Visibility Matrix persistence across refreshes and reconnects
FR-4: Epic 1 — AV Tile visual state indicators for all Participant States
FR-5: Epic 1 — Eight Participant States rendered without layout disruption
FR-6: Epic 1 — GM always-visible view with configurable self-feed setting
FR-7: Epic 1 — WebRTC track disabling with CSS fallback
FR-8: Epic 1 — Portrait Fallback for no-camera participants
FR-9: Epic 2 — Director's Board open via sidebar button + keyboard shortcut
FR-10: Epic 2 — Director's Board full Visibility Matrix seating-chart layout
FR-11: Epic 2 — Per-participant toggle from Director's Board
FR-12: Epic 2 — Bulk Show All / Hide All with one-step Undo
FR-13: Epic 2 — Spotlight action with pre-spotlight snapshot and Restore
FR-14: Epic 2 — Full keyboard shortcuts for Director's Board actions
FR-15: Epic 3 — Save named Scene Preset from current Visibility Matrix
FR-16: Epic 3 — Load Scene Preset at any time
FR-17: Epic 3 — Scene Preset auto-applies on Scene activation
FR-18: Epic 3 — Disable auto-apply per-scene or globally
FR-19: Epic 3 — Preset import/export as JSON
FR-20: Epic 2 — Toast notification to all participants on GM visibility change
FR-21: Epic 2 — Notification verbosity configuration per user
FR-22: Epic 1 — Persistent self-status badge on own AV Tile
FR-23: Epic 4 — Player Privacy Panel accessible from module settings
FR-24: Epic 4 — Opt-in to Reaction Cam automation
FR-25: Epic 4 — Opt-in to HP-Reactive Cam Styling
FR-26: Epic 4 — Custom Portrait Fallback via file picker
## Epic List
### Epic 1: Core Camera Visibility Control
The GM can install the module, hide or show any participant's camera in one click, and every connected viewer updates in real time. Players always see the current state of their own feed via a persistent badge — with a plain-language explanation on first encounter and on demand. This epic delivers the entire Level 1 experience: module scaffold, the WebRTC spike to validate the technical approach, the tested data layer, and all core UI components including the player-facing badge.
**FRs covered:** FR-1, FR-2, FR-3, FR-4, FR-5, FR-6, FR-7, FR-8, FR-22
**Architecture requirements:** All scaffold Story 0 deliverables · WebRTC spike (OQ-1) · FoundryAdapter · StateStore · SocketHandler · contract files · CI/CD pipeline · design token system · ScryingPoolController singleton
**UX components:** ScryingPoolStrip · ActionPopover · AVTileAdapter · StateRing · ParticipantAvatar · StripOverlayLayer · VisibilityBadge · FirstEncounterPanel · VisibilityDetailsPanel · EmptyStatePanel
**Story sequence:** Story 0 (Scaffold + CI) → Story 1 (WebRTC Spike) → Story 2 (FoundryAdapter + StateStore + SocketHandler) → Story 3 (VisibilityManager + ScryingPoolStrip + ActionPopover) → Story 4 (VisibilityBadge + FirstEncounterPanel + VisibilityDetailsPanel + FR-22)
---
### Epic 2: Player Notifications & Director's Board
Players receive plain-language notifications whenever camera states change — no action is ever silent. The GM gets a dedicated floating board showing all participants in a seating-chart layout, with bulk Show All / Hide All, Spotlight, and full keyboard accessibility. The NotificationBus that powers toast delivery is built here and reused by all subsequent automation.
**FRs covered:** FR-9, FR-10, FR-11, FR-12, FR-13, FR-14, FR-20, FR-21
**UX components:** NotificationBus · ParticipantCard · DirectorBoard
**Note:** FR-20/21 (toast notifications + verbosity) grouped here because NotificationBus is the shared infrastructure for Director's Board actions and all future automation notifications, matching the architecture's impl-seq Story 3.
---
### Epic 3: Scene-Aware Camera Automation (Scene Presets)
The GM can save named camera configurations as Scene Presets and apply them — manually or automatically on Scene activation. Preset state is shared and persisted; presets can be exported and re-used across campaigns. The ConfirmationBar gives the GM immediate strip-local feedback with a single-click Undo after every preset apply.
**FRs covered:** FR-15, FR-16, FR-17, FR-18, FR-19
**UX components:** ScenePresetManager · ConfirmationBar (full use)
---
### Epic 4: Player Privacy Panel
Players can opt in or out of any automation effect that touches their on-screen presence — Reaction Cam and HP-Reactive Cam Styling — without waiting for the GM. Players can also set a custom Portrait Fallback image. All consent flags persist across sessions. This epic scaffolds the per-player consent layer that all future automation features gate against.
**FRs covered:** FR-23, FR-24, FR-25, FR-26
---
<!-- Stories populated in Step 3 -->
## Epic 1: Core Camera Visibility Control
### Story 1.1: Module Scaffold, CI/CD Pipeline & Design Token System *(Technical Foundation)*
As a **developer**,
I want a fully configured module scaffold with the complete `--sp-*` design token system, contract files, tooling, and CI,
So that every subsequent story builds on enforced boundaries, verified tooling, and a stable design language.
**Acceptance Criteria:**
**Given** the repository is checked out fresh
**When** `npm install && npm run lint && npm run typecheck && npm run test` are executed
**Then** all commands exit 0
**And** `npm run build` produces `module.zip` containing `module.json`, `scripts/`, `styles/`, `templates/`, `lang/`
**Given** the module is installed in FoundryVTT v14
**When** FoundryVTT loads
**Then** the module activates with no console errors and `game.modules.get('video-view-manager').active === true`
**Given** a developer writes an exported function without a JSDoc comment
**When** `npm run lint` runs
**Then** `eslint` reports a `jsdoc/require-jsdoc` violation
**Given** a source file imports from a restricted layer
**When** `npm run lint` runs
**Then** `import/no-restricted-paths` reports a boundary violation
**Given** a Gitea push is made
**When** the CI workflow runs
**Then** lint, typecheck, and test all run; a failing test fails the workflow
**Given** a developer writes module CSS using a Foundry `--color-*`/`--font-*`/`--border-*` token directly inside `.scrying-pool` CSS
**When** the linting convention is enforced
**Then** a violation is reported — all Foundry tokens must be aliased through `--sp-*`
**Given** a developer renders any participant state
**When** they look up the token system in `styles/scrying-pool.less`
**Then** all three token layers are defined:
- Layer 1: SP semantic aliases (`--sp-surface`, `--sp-border`, `--sp-text-primary`, `--sp-text-secondary`, `--sp-accent`, `--sp-focus`) mapping to Foundry tokens with hardcoded fallbacks
- Layer 2: SP Participant State tokens for all 8 states (`active`, `hidden`, `self-muted`, `offline`, `cam-lost`, `reconnecting`, `never-connected`, `ghost`)
- Layer 3: SP Urgency + Motion tokens (`--sp-urgency-director`, `--sp-urgency-awareness`, `--sp-fade-hide`, `--sp-pulse-reconnecting`, `--sp-shimmer-degraded`, `--sp-toast-delay`)
**And** the `VisibilityBadge` `:root` exception is documented: badge tokens are declared on `:root` because the badge is mounted outside the `.scrying-pool` root
**And** all animated token usages are gated under `@media (prefers-reduced-motion: no-preference)`
**Given** the 4 contract files exist
**When** a story imports `src/contracts/visibility-matrix.js`
**Then** it exports a canonical shape constant, a factory function (`createVisibilityMatrix()`), and a guard/validator function (`isValidVisibilityMatrix(data)`)
**And** the same factory + validator pattern applies to `socket-message.js`, `pending-op.js`, `scene-preset.js`
**Deliverables:** `module.json` (v14 manifest), `tsconfig.json` (checkJs+strict+noEmit), `.eslintrc.js`, `vitest.config.js` (happy-dom), `scripts/package.mjs`, `tests/fixtures/socket-payloads.js` (stub only at this stage), `.gitignore`, `styles/scrying-pool.less` (full 3-layer token system), `lang/en.json` (i18n skeleton), `src/contracts/visibility-matrix.js`, `src/contracts/socket-message.js`, `src/contracts/pending-op.js`, `src/contracts/scene-preset.js`, `.gitea/workflows/ci.yml`
---
### Story 1.2: WebRTC Spike — Track Disabling API Validation *(Spike)*
As a **developer**,
I want to determine the FoundryVTT v14 WebRTC API capability and freeze the `FoundryAdapter.webrtc` interface contract,
So that Story 1.3 can implement against a stable interface without ambiguity.
**Acceptance Criteria:**
**Given** FoundryVTT v14 running with AV enabled
**When** the spike code executes
**Then** the result is exactly one of three documented outcomes:
- `"track-disable"``track.enabled = false` confirmed on a remote inbound stream
- `"css-fallback"` — programmatic track access unavailable; CSS/DOM hiding is sufficient
- `"unsupported"` — neither WebRTC track API nor reliable CSS targeting is available
**And** the decision is recorded as a code comment in `src/adapters/foundry-adapter.js` with the FoundryVTT version tested against
**Given** the spike outcome is determined
**When** `src/adapters/foundry-adapter.js` is examined
**Then** the `FoundryAdapter.webrtc` interface contract is frozen: either `{ disableTrack(userId): void, enableTrack(userId): void }` or `null`, with capability-probe documentation
**And** `scrying-pool.webrtcMode` world setting is registered with an enum of `["track-disable", "css-fallback", "unsupported"]` and records the active outcome
**Given** outcome is `"track-disable"`
**When** a participant is hidden
**Then** the inbound track is disabled (no inbound video bandwidth consumed)
**Given** outcome is `"css-fallback"` or `"unsupported"`
**When** a participant is hidden
**Then** CSS/DOM hiding is applied (cosmetic only)
**Hard exit rule:** Story 1.3 must not start until this story has a merged result and `FoundryAdapter.webrtc` interface is frozen.
---
### Story 1.3: Data Layer — FoundryAdapter, StateStore & Socket Infrastructure
As a **GM**,
I want camera visibility changes to persist and broadcast to all connected clients reliably,
So that every participant's Foundry client always shows the correct camera state, even after page refreshes or mid-session joins.
**Acceptance Criteria:**
**Given** the module initialises
**When** `Hooks.once('init')` fires
**Then** world settings (`scrying-pool.visibilityMatrix`, `scrying-pool.webrtcMode`, `scrying-pool.showGMSelfFeed`) are registered
**And** `FoundryAdapter` is constructed (implementing the frozen interface from Story 1.2) and injected into `StateStore` and `SocketHandler`
**Given** `StateStore.setVisibility(participantId, targetState)` is called
**When** the call completes
**Then** the in-memory `visibilityMatrix` is updated
**And** `adapter.settings.set('scrying-pool.visibilityMatrix', { _version: 1, matrix: {...} })` is called
**Given** `SocketHandler.emit(intent)` is called
**When** the message is sent
**Then** all connected clients receive the authoritative echo within 500ms on a local network
**Given** a client receives a socket message
**When** the `opId` matches a known `PendingOp`
**Then** the `PendingOp` is cleared and state is confirmed
**And** if the message arrives after the 3s timeout it is discarded as stale
**Given** a socket broadcast is unacknowledged for 3 seconds
**When** the timeout fires
**Then** the module retries exactly once; if still unacknowledged: logs a warning and reverts pending state to `previousState`
**Given** a new client joins mid-session
**When** `Hooks.once('ready')` fires for them
**Then** `StateStore` is hydrated from world setting `scrying-pool.visibilityMatrix`
**Given** the page refreshes
**When** the module re-initialises
**Then** all participant states are restored from the persisted world setting
**Given** `game.webrtc` is null (AV disabled)
**When** the module initialises
**Then** `FoundryAdapter.webrtc` returns `null`, no errors are thrown, no code attempts webrtc access
**Unit test coverage — `tests/fixtures/socket-payloads.js` must define canonical fixtures for:**
- Valid intent payload (`scrying-pool.*` prefix, `{ opId, userId, targetState, baseRevision }`)
- Valid authoritative echo/ack payload
- Stale ACK (opId not in pendingOps; revision mismatch)
- Timeout + retry + revert sequence
- Hydrated setting payload (`{ _version: 1, matrix: {...} }`)
- Invalid/malformed payload (fails validator)
**And** `StateStore`, `SocketHandler`, `FoundryAdapter` are all testable via Vitest with injected mocks (zero `game.*` access in any of these classes)
---
### Story 1.4: Core Logic — ScryingPoolController & VisibilityManager *(Headless)*
As a **developer**,
I want the module's core orchestration logic to be independently tested without any UI,
So that the GM control UI (Story 1.5) can be built against a stable, verified interface.
**Acceptance Criteria:**
**Given** `Hooks.once('ready')` fires
**When** `ScryingPoolController` is constructed as the module singleton
**Then** it owns `visibilityMatrix: Map<participantId, Map<viewerId, VisibilityState>>` and `pendingOps: Map<participantId, PendingOp>`
**And** it subscribes to `SocketHandler` for authoritative echoes
**Given** `ScryingPoolController.action(source, participantId, targetState, opId, baseRevision)` is called
**When** the call is processed
**Then** a `PendingOp` is created in `pendingOps`
**And** `StateStore.setVisibility()` is called
**And** a change event is emitted for subscribers (Strip and Board)
**And** the latest-revision-wins guard prevents stale updates
**And** the per-participant last-intent guard ignores duplicate intent for the same state
**Given** `action()` is called by a non-GM user
**When** the call is processed
**Then** `game.user.isGM` is checked; non-GM callers receive a `console.warn` and the call is silently dropped
**Given** `VisibilityManager` is constructed after `ScryingPoolController`
**When** a participant is hidden
**Then** it applies the strategy from `scrying-pool.webrtcMode`: calls `FoundryAdapter.webrtc.disableTrack()` if `"track-disable"`, applies CSS hiding if `"css-fallback"` or `"unsupported"`
**And** `SocketHandler.setReady()` is called after `VisibilityManager` initialises (respecting init order: `ready → VisibilityManager → SocketHandler.setReady()`)
**Given** `ScryingPoolController` emits a revert event (after retry exhaustion)
**When** `VisibilityManager` receives it
**Then** the participant's state reverts to `previousState`
**And** `ui.notifications.warn()` fires with a human-readable message (revert = failure → tier-4 feedback only)
**And** NO success notification fires for normal state changes
**Unit test coverage:** `ScryingPoolController` and `VisibilityManager` tested via Vitest with injected mocks; test cases cover: normal toggle, latest-revision-wins, last-intent guard, retry-then-revert, non-GM authorization rejection, `webrtcMode` strategy switching
---
### Story 1.5: GM Control UI — ScryingPoolStrip, ActionPopover & AV Tile Integration
As a **GM**,
I want to right-click any participant's AV tile to show or hide their camera feed, and see all feed states at a glance in the ScryingPoolStrip,
So that I can control what the table sees in a single interaction without disrupting the session.
**Acceptance Criteria:**
**Given** the module is active and the user is GM
**When** FoundryVTT's `ready` hook completes
**Then** `ScryingPoolStrip` appears as a floating `ApplicationV2` window showing all connected participants
**And** its position (`left`, `top`), open state, and expanded state persist to the GM's user flag `{ left, top, open, expanded }`
**Given** the ScryingPoolStrip is in collapsed state
**When** the GM clicks the expand toggle
**Then** the strip transitions via `max-width` CSS transition (never `width` animation): collapsed = 44px avatar-only rail; expanded = 240px rich rows
**Given** the strip renders participants
**When** it displays each `ParticipantAvatar`
**Then** each avatar is a 44×44px container with a 32px rounded avatar + `StateRing` + 12px corner badge at bottom-right
**And** `StateRing` uses the correct variant per state: `--solid` (active/hidden/offline), `--dashed` (self-muted/cam-lost), `--pending` (animated pulse), `--revert` (amber flash 200ms on revert)
**And** all `StateRing` animations are gated under `@media (prefers-reduced-motion: no-preference)`
**Given** a PendingOp is in-flight for a participant
**When** the strip renders
**Then** that participant's `StateRing` shows the `--pending` animated pulse
**And** NO `ui.notifications` toast fires on successful state change (success uses ambient ring only — tier-1/2 feedback)
**Given** a GM right-clicks a participant's AV tile
**When** the context menu appears
**Then** the option reads exactly **"Hide from table"** (never a synonym)
**And** selecting it calls `ScryingPoolController.action()` and transitions state to `hidden`
**Given** a GM clicks a participant in the ScryingPoolStrip
**When** the `ActionPopover` opens
**Then** it is a native `<dialog>` anchored via `StripOverlayLayer.getBoundingClientRect()` relative to the strip
**And** the primary CTA reads exactly **"Hide from table"** or **"Show to table"**
**And** the primary CTA is `disabled` + `aria-disabled="true"` while a `PendingOp` is in-flight
**And** Esc / click-outside dismiss the popover and return focus to the triggering avatar
**And** only one `ActionPopover` is open at a time (supersede pattern)
**Given** `StripOverlayLayer` is the parent for all positioned overlays
**When** any overlay is positioned
**Then** it is a child of the single `StripOverlayLayer` (`position: absolute; inset: 0; pointer-events: none; overflow: visible`); children restore `pointer-events: auto`
**Given** a visibility change is dispatched
**When** the socket broadcast completes
**Then** all clients' AV tiles update state indicators within 500ms
**And** no AV tile layout shift or reflow occurs for any of the 8 participant states
**And** `AVTileAdapter.mount(userId, element)` is idempotent — calling it twice does not duplicate elements
**Given** a participant is `hidden`
**When** the GM views their AV tile
**Then** it renders at reduced opacity with a lock overlay and "Camera hidden by GM" tooltip
**And** the GM still hears that participant's audio
**Given** a participant has no camera (`never-connected` or `cam-lost`)
**When** their tile renders
**Then** Portrait Fallback (FoundryVTT user avatar → system placeholder) displays at AV tile dimensions with no layout shift
**Given** no participants are connected
**When** the ScryingPoolStrip renders
**Then** `EmptyStatePanel` shows "No participants yet" with a slow breathing-pulse eye icon (static under `prefers-reduced-motion`)
**And** the panel is NOT styled as an error state
**Given** the GM opens module settings
**When** they locate "Show my own feed to myself" (default ON)
**Then** toggling it hides/shows the GM's self-view immediately without errors
**Given** `game.webrtc` is null (AV disabled)
**When** the module loads
**Then** `ScryingPoolStrip` is not rendered and no console errors appear
**Accessibility:**
**Given** a screen reader user navigates to a `ParticipantAvatar`
**When** focus lands
**Then** `role="button"`, `aria-label="[Name] — [state label]"` is announced
**And** `aria-pressed` reflects popover-open state
**Given** a keyboard user opens an `ActionPopover`
**When** it opens
**Then** focus moves to the primary CTA
**And** Tab/Shift+Tab cycles through popover controls only
**And** Esc closes it and returns focus to the triggering avatar
**Given** `prefers-reduced-motion: reduce` is active
**When** any animated state occurs
**Then** all `StateRing` animations are fully suppressed; static icons provide state information
**Given** any participant state is rendered
**When** it is visually displayed
**Then** colour is never the only signal: each state also has a distinct icon, shape, or motion indicator
**And** all state colour tokens meet WCAG AA contrast against both Foundry dark and light themes
**Given** a canonical action label appears on any surface
**When** it is displayed
**Then** it reads exactly "Hide from table" or "Show to table" (never synonyms)
**And** on first hover a tooltip variant sets `firstHideTooltip` flag; subsequent hovers show only the canonical label
---
### Story 1.6: Player Camera Status Badge
As a **player**,
I want to always see whether my own camera feed is visible to the table, and understand what it means on first encounter,
So that I'm never confused or surveilled without knowing it.
**Acceptance Criteria:**
**Given** a player is connected with AV enabled
**When** the module is active
**Then** a persistent `VisibilityBadge` appears on their own AV tile
**And** the badge is visible only to the owning player (not to other players or the GM)
**And** `role="status"`, `aria-live="polite"`, `aria-label="Camera visibility: [state label]"` are set
**And** badge tokens are declared on `:root` (badge mounted outside `.scrying-pool` root, using `AVTileAdapter` from Story 1.5)
**Given** a player's state is anything other than `active`
**When** the badge renders
**Then** it shows the correct vocabulary-partition label: `hidden` → "Hidden from table", `self-muted` → "Camera paused", `offline` → "Not connected", `cam-lost` → "Camera unavailable", `reconnecting` → "Rejoining view", `never-connected` → "Not yet connected", `ghost` → "Leaving"; `active` → no label shown
**Given** the GM changes a player's visibility state
**When** the socket broadcast completes
**Then** the player's `VisibilityBadge` updates within 500ms
**Given** `firstBadgeEncounter` user flag is not set and a state change occurs
**When** the badge updates
**Then** `FirstEncounterPanel` appears with a plain-language explanation
**And** a 10s auto-collapse timer starts
**And** `mouseenter` or `:focus-within` on the panel pauses the timer (resumes on leave/blur)
**And** "Got it" sets `firstBadgeEncounter` and immediately closes the panel
**And** the panel is `aria-modal="false"`, `role="dialog"`, and is NOT a focus trap
**Given** the 10s timer expires without interaction
**When** auto-collapse fires
**Then** the panel collapses via `max-height` fold animation (300ms ease-out) into a persistent chip
**And** the chip is focusable and keyboard-activatable, re-opening `VisibilityDetailsPanel` on activation
**And** if focus is inside the panel when collapse fires, focus is moved to the chip
**And** subsequent state changes do NOT re-show the panel (flag is permanently set)
**And** `clearTimeout` is called on "Got it" click and on `_onClose()` teardown to prevent ghost timers
**Given** a player clicks their `VisibilityBadge` or the collapsed chip
**When** `VisibilityDetailsPanel` opens
**Then** it shows: who changed the state ("Hidden by: [GM name]" / "Connection issue" / "Scene preset: [name]"), what the state means in plain language, and a reassurance note
**And** when state is `hidden`, the audience list is suppressed and replaced with reassurance copy: "Other players cannot see your feed"
**And** a stale-data indicator appears when `ScryingPoolController` is unavailable
**And** the panel is a focus-trapped `<dialog>` with `aria-modal="true"`
**And** Esc, click-outside, or "Close" button dismisses it and returns focus to the triggering element
**Given** `AVTileAdapter.mount(userId, badgeElement)` is called and the AV tile DOM node is not found
**When** the call executes
**Then** the adapter no-ops and logs `console.warn` without throwing (fail-open)
**Given** Foundry re-renders the AV tile (detected via `MutationObserver`)
**When** the re-render is detected
**Then** the badge is updated in-place if possible; remove-and-reinsert only if structure requires full rebuild
**And** `AVTileAdapter.disconnect()` is called on module teardown
## Epic 2: Player Notifications & Director's Board
### Story 2.1: NotificationBus & Notification Verbosity
As a **player**,
I want to receive a plain-language notification whenever the GM changes my camera's visibility, and control how many notifications I see,
So that I'm never left wondering what happened to my feed without being overwhelmed by alerts.
**Acceptance Criteria:**
**Given** the GM changes a participant's visibility state
**When** the socket broadcast is received by all clients
**Then** a toast notification fires via `ui.notifications` reading "GM hid [Name]'s camera" or "GM showed [Name]'s camera"
**Given** the affected participant's own client
**When** any visibility change is received
**Then** they receive a distinct personal notification regardless of their verbosity setting
**And** this personal message cannot be suppressed by the GM
**Given** the GM changes the same participant's state multiple times within 3 seconds
**When** the `NotificationBus` coalescing timer fires
**Then** a single coalesced notification fires reporting the final state and change count
**And** if the net state equals the original state, no notification fires at all
**Given** a user's verbosity setting is `GM Only`
**When** another participant's camera is changed
**Then** only the GM and the affected participant receive a notification (other players see nothing)
**Given** a user's verbosity setting is `Silent`
**When** any participant's camera is changed
**Then** that user receives no notification unless they are the affected participant
**Given** a user changes their verbosity setting in module settings
**When** the change is saved
**Then** it persists to their client-level user setting and takes effect immediately
**Given** `Hooks.once('ready')` fires
**When** `NotificationBus` is constructed
**Then** it subscribes to `ScryingPoolController` change events and holds `Map<participantId, {timer, lastState, changeCount}>`
---
### Story 2.2: Director's Board — Core Layout & Participant Toggle
As a **GM**,
I want a dedicated floating board showing all participants in a seating-chart layout with per-participant visibility toggle,
So that I can manage all camera states at a glance without right-clicking individual AV tiles.
**Acceptance Criteria:**
**Given** the module is active and the user is GM
**When** the GM presses `Ctrl+Shift+V` or clicks the dedicated sidebar button
**Then** the Director's Board opens as a resizable, draggable `ApplicationV2` window
**Given** the Director's Board is open
**When** it renders
**Then** every connected participant has a `ParticipantCard` (80×100px: 48px avatar + `StateRing` + name 12px 2-line truncate + hover toggle-icon overlay)
**And** cards are laid out in a CSS grid: `auto-fill, minmax(80px, 1fr)`
**Given** a participant's state changes
**When** the socket broadcast completes
**Then** the Director's Board updates that participant's card within 500ms
**And** the board is a dumb view — it subscribes to `ScryingPoolController` events with no local state cache
**Given** the GM clicks a participant card
**When** the click is processed
**Then** the participant's visibility toggles between `active` and `hidden`
**And** the behaviour and persistence match FR-1 (same as AV tile right-click)
**Given** the GM uses keyboard navigation
**When** arrow keys are pressed in the board
**Then** focus moves between participant cards
**And** `Space` or `Enter` toggles the focused participant's visibility
**Given** `Ctrl+Shift+V` is pressed while the board is open
**When** the event fires
**Then** the board closes
**Given** the user is not GM
**When** they attempt to open the Director's Board
**Then** the sidebar button is not shown and the keyboard shortcut has no effect
**Accessibility:**
**Given** a screen reader user navigates to a `ParticipantCard`
**When** focus lands
**Then** `role="listitem"`, `aria-label="[Name] — [state label]"` is announced
**And** the hover toggle icon is independently focusable with `role="button"` and a descriptive `aria-label`
---
### Story 2.3: Director's Board — Bulk Actions, Spotlight & Keyboard Shortcuts
As a **GM**,
I want to show or hide all participants at once, spotlight a single feed, and undo these bulk actions instantly,
So that I can execute camera arrangements in a single action without toggling participants one by one.
**Acceptance Criteria:**
**Given** the Director's Board is open
**When** the GM clicks "Show All"
**Then** all participants' states are set to `active` (excluding `ghost`-state participants)
**And** the action is broadcast to all clients
**Given** the Director's Board is open
**When** the GM clicks "Hide All"
**Then** all participants' states are set to `hidden` (excluding `ghost`-state participants)
**And** the action is broadcast to all clients
**Given** the GM has just executed "Show All" or "Hide All"
**When** the GM clicks "Undo"
**Then** the Visibility Matrix is immediately restored to the state before the bulk action
**And** no second undo is available (single-step undo only)
**Given** a participant card is focused
**When** the GM presses `Ctrl+Shift+P`
**Then** that participant's feed is shown and all others are hidden in a single action
**And** the pre-spotlight Visibility Matrix is stored as a snapshot
**Given** Spotlight is active
**When** the GM clicks "Restore"
**Then** the Visibility Matrix reverts to the pre-spotlight snapshot
**And** "Restore" is distinct from the bulk action Undo affordance
**Given** `Ctrl+Shift+S` or `Ctrl+Shift+H` is pressed
**When** the event fires
**Then** "Show All" or "Hide All" executes as if the button were clicked
**Given** the GM presses `?` in the Director's Board
**When** the event fires
**Then** a shortcut reference panel opens listing all keyboard shortcuts with their current bindings
**Given** the GM navigates to keyboard shortcut settings
**When** they open module settings
**Then** `Ctrl+Shift+V`, `Ctrl+Shift+S`, `Ctrl+Shift+H`, `Ctrl+Shift+P` are all configurable
**And** the `?` panel reflects the currently configured bindings
## Epic 3: Scene-Aware Camera Automation (Scene Presets)
### Story 3.1: Save & Load Scene Presets
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.
**Acceptance Criteria:**
**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: {...} }`
**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
**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."
**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
**Given** a preset is loaded
**When** all clients receive the broadcast
**Then** a notification fires: "GM applied preset: [Preset Name]" via `ui.notifications`
**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)
**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
---
### Story 3.2: Scene Auto-Apply & ConfirmationBar
As a **GM**,
I want a Scene Preset to automatically apply when I activate a Scene, with immediate strip-local feedback and a one-click Undo,
So that camera layouts change seamlessly with scene transitions without manual intervention.
**Acceptance Criteria:**
**Given** a Scene has a preset association configured
**When** the GM activates that Scene (triggering `updateScene` hook)
**Then** the associated preset applies after the configured pre-delay (05000ms)
**And** all clients receive "Scene changed: camera layout updated" via `ui.notifications`
**Given** auto-apply fires for a Scene
**When** the Visibility Matrix update is broadcast
**Then** the `ConfirmationBar` appears in `StripOverlayLayer` at `position: absolute; bottom: 0` showing "Preset applied — N hidden, N visible"
**And** an "Undo" button is present
**Given** the "Undo" button is clicked
**When** the click is processed
**Then** the Visibility Matrix immediately reverts to the state before the preset was applied
**And** all clients receive the reverted state
**Given** the `ConfirmationBar` is visible and idle
**When** 8 seconds elapse (or 4 seconds if ≥2 presets applied within 60 seconds)
**Then** the bar dismisses via `opacity` transition only (never `height` or `max-height` animation)
**Given** a second `ConfirmationBar` would appear while one is already visible
**When** the second is triggered
**Then** it instantly replaces the first with zero crossfade (instant-replace rule)
**Given** auto-apply is disabled for a specific Scene
**When** that Scene is activated
**Then** no preset applies and no automation notification fires
**And** the Director's Board manual override remains fully functional
**Given** auto-apply is disabled globally in module settings
**When** any Scene is activated
**Then** no preset auto-applies regardless of Scene-level associations
**Given** a Scene has a pre-delay of N ms configured
**When** the Scene activates
**Then** the preset applies exactly N ms after the `updateScene` hook fires
**Given** the partial-fail case (some participants unreachable)
**When** the `ConfirmationBar` renders
**Then** it uses the amber variant: "Preset applied — N hidden, N visible (some updates pending)"
---
### Story 3.3: Preset Import & Export
As a **GM**,
I want to export all Scene Presets as a JSON file and import them into another world or campaign,
So that I can reuse proven camera arrangements across campaigns without re-entering them manually.
**Acceptance Criteria:**
**Given** the GM opens the Preset management UI
**When** they click "Export Presets"
**Then** a JSON file is downloaded containing all world presets in human-readable format
**And** the exported file matches the format documented in the module README
**Given** the GM clicks "Import Presets" and selects a valid JSON file
**When** the file is parsed
**Then** the GM is prompted to choose: "Merge" (add new, keep existing) or "Replace" (overwrite all)
**Given** the GM selects "Merge"
**When** the import is processed
**Then** new presets from the file are added; existing presets with matching names are left unchanged
**And** a success message shows how many presets were added
**Given** the GM selects "Replace"
**When** the import is processed
**Then** a confirmation dialog warns about data loss before proceeding
**And** on confirmation, all existing presets are removed and replaced with the imported set
**Given** the imported file contains invalid JSON
**When** parsing fails
**Then** an error notification shows: "Import failed: invalid JSON format" and no changes are made
**Given** the imported JSON has an unrecognised schema version or missing required fields
**When** validation fails
**Then** an error notification shows with specific field details and no changes are made
## Epic 4: Player Privacy Panel
### Story 4.1: Player Privacy Panel & Automation Opt-ins
As a **player**,
I want to see and control every automation effect that can change my on-screen presence, and opt in or out at any time,
So that I'm never surprised by automatic camera behaviours I didn't agree to.
**Acceptance Criteria:**
**Given** a player opens FoundryVTT module settings
**When** they navigate to the privacy section
**Then** the Player Privacy Panel is visible, listing all automation effects with their current opt-in status
**Given** the panel is open for their own user
**When** the player views it
**Then** "Reaction Cam" (default: off) and "HP-Reactive Cam Styling" (default: off) are listed with toggle controls
**Given** a player toggles "Reaction Cam" to enabled
**When** the toggle is confirmed
**Then** the opt-in flag persists in world-level user flags and takes effect for all future Reaction Cam triggers
**Given** "Reaction Cam" is disabled for a player
**When** a Reaction Cam trigger fires
**Then** that player is silently skipped — no notification, no error, no indication to the GM
**Given** "Reaction Cam" is enabled for a player
**When** a Reaction Cam trigger fires
**Then** the Director's Board shows a "Reaction Cam: Enabled" badge on that participant's card
**Given** the GM opens another player's Privacy Panel
**When** viewing it
**Then** all controls are visible (read-only) but disabled — no editing is possible
**Given** a player toggles "HP-Reactive Cam Styling" to enabled
**When** the toggle is confirmed
**Then** the opt-in flag persists in world-level user flags
**And** the GM is not notified of the change
**Given** a player refreshes or rejoins the session
**When** the module re-initialises
**Then** both opt-in flags return to their last configured state
---
### Story 4.2: Custom Portrait Fallback
As a **player**,
I want to choose a custom image to display when my camera feed is unavailable,
So that my on-screen presence is represented the way I prefer even when my camera isn't working.
**Acceptance Criteria:**
**Given** a player opens their Player Privacy Panel
**When** they view the "Portrait Fallback" section
**Then** a file picker button is shown alongside a preview of the current fallback image
**Given** the player selects a PNG, JPG, WEBP, or static GIF file
**When** the picker accepts it
**Then** the file is accepted and the preview updates to the selected image
**Given** the player selects a file with an unsupported format (e.g. `.svg`, `.mp4`)
**When** the picker attempts to accept it
**Then** an error shows: "Unsupported format. Please use PNG, JPG, WEBP, or static GIF."
**And** the previous fallback image remains unchanged
**Given** a custom Portrait Fallback is saved
**When** the participant's state is `never-connected` or `cam-lost`
**Then** the custom fallback image is displayed at AV tile dimensions (same size as a live camera feed tile) with no layout shift
**Given** no custom fallback is set
**When** the fallback is needed
**Then** the module uses the FoundryVTT user avatar; if no avatar exists, the system placeholder is used
**Given** the participant clicks "Remove custom image"
**When** the action is confirmed
**Then** the fallback reverts to the FoundryVTT user avatar (or system placeholder)
@@ -0,0 +1,113 @@
# Decision Log — Video View Manager PRD
**Workspace:** `_bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/`
**Created:** 2026-05-19
**Author:** Morr
---
## Decisions
### D-1 — PRD scope: full vision with roadmap
**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.
**Rationale:** User selected "Full vision PRD" during working mode selection.
**Status:** Captured in §10 Product Roadmap.
### D-2 — WebRTC track disabling preferred
**Date:** 2026-05-19
**Decision:** Real WebRTC track disabling is the preferred visibility implementation. CSS/DOM cosmetic hiding is the fallback. The choice is surfaced at runtime via a module config flag.
**Rationale:** User selected "Real WebRTC track disabling preferred" during working mode selection.
**Status:** Captured in FR-7. OQ-1 is the open research question to confirm feasibility.
### D-3 — FoundryVTT v14 minimum; no v13 backport
**Date:** 2026-05-19
**Decision:** Module targets FoundryVTT v14+ exclusively. No v13 compatibility.
**Rationale:** User selected "v14 only" during working mode selection.
**Status:** Captured in §5 Non-Goals and §6 MVP Scope.
### D-4 — Public community release (Foundry Hub)
**Date:** 2026-05-19
**Decision:** Module is intended for public listing on Foundry Hub / FoundryVTT package repository. PRD rigor calibrated to medium stakes (public community release).
**Rationale:** User selected "Public FoundryVTT module release" during stakes calibration.
**Status:** Informs Success Metrics (SM-1, SM-2) and Cross-Cutting NFRs (compatibility, privacy).
### D-5 — Progressive Enhancement Architecture as structural principle
**Date:** 2026-05-19
**Decision:** The module's UI and feature set is organized around 3 levels: Level 1 (right-click, zero new UI), Level 2 (Director's Board popout), Level 3 (Scene Presets + automation). Features are additive; Level 1 alone is a shippable product.
**Rationale:** From brainstorming SCAMPER-E result (#13). Confirmed as the structural model for §4 Features.
**Status:** Captured in feature descriptions for §4.1, §4.2, §4.3.
### D-6 — Seating chart metaphor for Director's Board
**Date:** 2026-05-19
**Decision:** The Director's Board uses a spatial seating chart layout, not a list or grid of raw settings.
**Rationale:** From brainstorming SCAMPER-S insight. Reinforced by Role Playing Persona 4 (Alex).
**Status:** Captured in FR-10.
### D-7 — Reaction Cam is opt-in at the player level
**Date:** 2026-05-19
**Decision:** Any automation that auto-spotlights a player's face (Reaction Cam, Combat Cinematics Mode) requires explicit player opt-in (or alternatively opt-out via Privacy Panel). The opt-out flag (FR-24) is included in v1.0 even though the Reaction Cam itself is a Later feature.
**Rationale:** Privacy tension between Marcus (GM) and Sofia (player) resolved in Role Playing phase: "Reaction cam opt-in resolved the Marcus/Sofia privacy tension elegantly."
**Status:** Captured in FR-24. Opt-out scaffolding ships in v1.0 to avoid a breaking change when Reaction Cam is released.
### D-8 — i18n string keys in v1.0; English strings only at ship
**Date:** 2026-05-19
**Decision:** All UI strings are externalized to i18n key files in v1.0 (enabling community translation contributions), but only English strings ship at the initial release.
**Rationale:** Standard FoundryVTT module convention. Community translation is a common contribution pattern in the ecosystem.
**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
**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.
**Rationale:** User confirmed: "Native Foundry socket API."
**Status:** Captured in FR-2 and §9 Assumptions Index. OQ-2 closed.
### D-10 — WebRTC track disable feasibility deferred to API inspection
**Date:** 2026-05-19
**Decision:** Whether real WebRTC track disabling is feasible will be determined by inspecting the FoundryVTT v14 AV API during development. CSS/DOM cosmetic hiding remains the safe default until confirmed.
**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.
| OQ | Phase Blocker? | Status |
|---|---|---|
| OQ-1: WebRTC track disable API access | Yes | Open — to be checked against v14 API during development; CSS fallback is safe default |
| OQ-2: socketlib vs native socket API | Yes | **RESOLVED** — native FoundryVTT socket API |
| OQ-3: GM own-feed hidden behavior | No | **RESOLVED** — GM always sees all activated feeds; own self-view is a configurable option |
| 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 |
| OQ-6: Partial vs full preset application | No | Open — to be resolved during FR-15/FR-16 implementation |
### D-13 — Reaction Cam / automation effects are opt-in (not opt-out)
**Date:** 2026-05-19
**Decision:** Any automation feature that auto-spotlights or visually modifies a player's feed requires explicit player opt-in. Default state is OFF.
**Rationale:** Reconciliation found the PRD had silently inverted the brainstorming's model. User confirmed opt-in is correct.
**Status:** FR-24, FR-25, and §4.5 description updated.
### D-14 — Director's Board supports both setup and live-monitor modes
**Date:** 2026-05-19
**Decision:** The Director's Board is valid as a pre-session setup tool and as a live monitor kept open during play. Both modes are explicitly supported.
**Rationale:** User confirmed both modes valid.
**Status:** FR-9 updated.
### D-15 — GM override guarantee as cross-cutting rule
**Date:** 2026-05-19
**Decision:** Every automation feature must expose a one-click GM override. Director's Board "Hide All" is the universal panic path.
**Rationale:** From brainstorming "panic button" + Marcus persona. Elevated to cross-cutting design rule.
**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.
**Status:** Closed.
@@ -0,0 +1,543 @@
---
title: "Video View Manager — FoundryVTT Webcam Visibility Control Module"
status: final
created: 2026-05-19
updated: 2026-05-19
---
# PRD: Video View Manager
## 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 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`.
---
## 1. Vision
Video View Manager gives the GM granular, real-time control over webcam visibility in FoundryVTT v14. Its core value is simple: **the GM can hide or reveal any Participant's camera in one click, and every Viewer updates immediately.** Existing FoundryVTT AV controls do not provide this.
From that core, the module extends into session cinematography. Scene Presets let the GM configure camera states during prep and apply them on scene transitions. The Director's Board gives the GM bulk visibility control for larger groups. Contextual Notifications explain camera-state changes so feeds never disappear silently.
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.
---
## 2. Target Users
### 2.1 Primary Personas
**Marcus — The Veteran GM**
Marcus runs a biweekly campaign with 6 players on FoundryVTT. He preps meticulously and wants zero friction during play. He wants camera automation he can configure once, not an interface he must reopen mid-session. Every automation needs an obvious one-click override. He will use Scene Presets and the Director's Board extensively.
**Sofia — The Privacy-Conscious Player**
Sofia joins two games a week. She has a home office setup and is comfortable on camera, but she does not want her face spotlighted unexpectedly during a dramatic moment she did not choose. She needs to know what state her feed is in at all times, and she expects a way to opt out of any effect that touches her camera automatically.
**Jake — The Actual-Play Streamer**
Jake runs a public streamed game. He needs complete independence between what his players see and what his stream audience sees. Keyboard shortcuts are essential during live broadcast. He sees the Browser Source API and Spectator View (Later roadmap) as the features that turn this module into a production stack.
**Alex — The New Player**
Alex has just joined their first online TTRPG. They are comfortable with Zoom but have never used FoundryVTT. They need the interface to use plain language ("Show" / "Hide") rather than technical vocabulary, toast notifications that explain what is happening, and a Portrait Fallback if their camera fails.
### 2.2 Jobs to Be Done
**Functional**
- Hide a distracting Participant's video feed without removing them from the session
- Spotlight one Participant for a dramatic reveal or emotional scene climax
- Apply a Scene Preset when transitioning between scenes without manual intervention
- Know at a glance which of 6+ Participants are hidden, visible, or offline
**Social / Emotional**
- Feel in control of the table atmosphere as a GM — the visual equivalent of adjusting the lights
- Trust that no Participant is hidden without knowing it (Participant transparency)
- Participate fully in the session even when camera equipment fails (Portrait Fallback, Reaction Clip System)
### 2.3 Non-Users (v1.0)
- Solo FoundryVTT users with no AV participants
- Groups using FoundryVTT v13 or earlier
- 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
- **AV Tile** — The FoundryVTT UI element that renders a Participant's audio/video feed. The primary surface for right-click interactions in Level 1 of the Progressive Enhancement Architecture.
- **Browser Source API** — A Later-roadmap streaming interface that exposes camera-layout outputs for external broadcast tooling such as OBS.
- **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 Stage Lighting States** — A Later-roadmap preset vocabulary tier for cinematic bulk actions such as Wash, Focus, and Blackout.
- **Dual Layout System** — A Later-roadmap architecture that separates the Participant-facing layout from the Spectator View.
- **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.
- **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 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.
- **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`.
- **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.
- **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.
- **Scene Preset** — A saved snapshot of the Visibility Matrix, optionally linked to a FoundryVTT Scene for automatic application on scene activation.
- **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.
- **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.
- **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.
- **Viewer** — A Participant who is receiving (watching) another Participant's camera feed.
---
## 4. Features
### 4.1 Core Visibility Toggle
**Description:** The North Star feature. The GM right-clicks any AV Tile to toggle that Participant's visibility for all Viewers. The change broadcasts to all connected clients, persists across reconnections, and updates the AV Tile with a clear indicator explaining *why* the feed is in its current state. All eight Participant States render without layout disruption. Realizes UJ-1, UJ-4.
This feature implements **Level 1** of the Progressive Enhancement Architecture. It adds no new UI beyond a context-menu action on existing AV Tiles.
**Participant States (FR-5):**
| State | Description |
|---|---|
| `active` | Camera on; feed visible to permitted Viewers |
| `hidden` | Camera on; GM has set Visibility State to hidden |
| `self-muted` | Participant voluntarily turned off their own camera |
| `offline` | Participant's connection dropped entirely |
| `cam-lost` | Participant connected but camera device failed |
| `reconnecting` | Transitional; feed is expected to return |
| `never-connected` | Participant joined with no camera device |
| `ghost` | GM observing silently (no AV presence broadcast) |
**Functional Requirements:**
#### FR-1: GM toggles Participant visibility via right-click
The GM can hide or show any Participant's camera feed by right-clicking their AV Tile and selecting "Hide Camera" / "Show Camera." Realizes UJ-1, UJ-4.
**Consequences (testable):**
- Selecting "Hide Camera" sets the target Participant's Visibility State to `hidden` for all Viewers.
- Selecting "Show Camera" sets the target Participant's Visibility State to `active` for all Viewers.
- The AV Tile indicator updates on all connected clients within 500 ms.
- The context-menu entry appears on all AV Tiles when the user is logged in as GM.
**Out of Scope:** Asymmetric visibility (hiding from specific Viewers only) — deferred to Later roadmap.
#### FR-2: Visibility state broadcast via socket
All Visibility Matrix changes are broadcast to all connected clients in real time.
**Consequences (testable):**
- A client that joins mid-session receives the current Visibility Matrix on connection.
- State-change latency from GM action to all-client update stays at or below 500 ms on a local network.
- The module uses the native FoundryVTT socket API through a registered module event (for example, `module.video-view-manager.visibilityUpdate`).
#### FR-3: Visibility state persistence
Visibility Matrix state persists in world-level settings across page refreshes and session breaks.
**Consequences (testable):**
- A Participant who disconnects and reconnects returns to the previously set Visibility State.
- The saved state survives a full FoundryVTT server restart.
- A new Participant defaults to `active` on first connection to the world [ASSUMPTION].
#### FR-4: AV Tile visual indicator for Participant State
Each AV Tile displays a state indicator that distinguishes all relevant Participant States by using plain language and an icon. Realizes UJ-4.
**Consequences (testable):**
- `hidden` state renders a grey overlay, a lock icon, and the tooltip "Camera hidden by GM."
- `self-muted` state renders a camera-off icon distinct from the lock icon.
- `offline` state renders a disconnection icon and the tooltip "Participant offline."
- `cam-lost` state renders a camera-error icon and the tooltip "Camera unavailable."
- `reconnecting` state renders a spinner icon.
- All icons come from the FoundryVTT icon library; the module adds no external dependency.
#### FR-5: Eight Participant States rendered without layout disruption
All Participant States produce appropriate visual feedback and do not cause AV Tile reflow or layout shift for other Participants.
**Consequences (testable):**
- 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.
- 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
The GM always sees all activated Participant feeds regardless of Visibility State. The GM's own feed in their own view can be toggled in a module setting.
**Consequences (testable):**
- Hidden tiles in the GM's view render at reduced opacity with a lock icon overlay.
- The GM hears audio from all Participants regardless of Visibility State.
- The module setting "Show my own feed to myself" (default: ON) controls whether the GM's own AV Tile appears in the GM's interface.
- Other Participants do not render hidden feeds.
#### FR-7: WebRTC track disabling with CSS fallback
Real WebRTC track disabling is the preferred implementation when the FoundryVTT v14 API allows programmatic track access. CSS/DOM cosmetic hiding is the fallback.
**Consequences (testable):**
- When WebRTC track disabling is active, a hidden feed does not consume inbound video bandwidth on the receiving client.
- A world setting reports which mode is active: WebRTC track disabling or CSS fallback.
- [ASSUMPTION: FoundryVTT v14's `game.webrtc` exposes RTCPeerConnection access sufficient for track disabling; see OQ-1.]
**Out of Scope:** Audio track manipulation — this module manages video visibility only. Audio muting remains native FoundryVTT functionality.
#### FR-8: Portrait Fallback for no-camera Participants
When a Participant has no camera device (`never-connected`) or enters `cam-lost` state, their AV Tile displays a Portrait Fallback image.
**Consequences (testable):**
- 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).
- Portrait Fallback renders at the same dimensions as a live camera-feed tile.
**Feature-specific NFRs:**
- Visibility Matrix updates must not block the FoundryVTT rendering loop; state changes apply asynchronously.
- The module must not interfere with FoundryVTT's native AV mute/unmute controls.
---
### 4.2 Director's Board
**Description:** The Director's Board is a floating window for bulk Visibility Matrix management in sessions with 4+ Participants. It is **Level 2** of the Progressive Enhancement Architecture. The seating-chart layout matches how TTRPG groups visualize the table. Realizes UJ-3.
**Functional Requirements:**
#### FR-9: GM opens Director's Board via sidebar button and keyboard shortcut
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):**
- 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.
- 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.
#### FR-10: Director's Board displays full Visibility Matrix in seating-chart layout
All connected Participants are shown with their current Visibility State, name, and portrait.
**Consequences (testable):**
- Every Participant card displays the name, portrait, current Participant State, and current Visibility State.
- 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.
#### 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.
**Consequences (testable):**
- The action matches FR-1 for behavior and persistence.
- The GM toggles a Participant with a single click on that Participant card.
#### FR-12: Bulk actions — Show All and Hide All
Two bulk-action buttons apply Show or Hide to all Participants simultaneously.
**Consequences (testable):**
- "Show All" sets every eligible Participant Visibility State to `active`.
- "Hide All" sets every eligible Participant Visibility State to `hidden`.
- A one-step "Undo" action restores the Visibility Matrix state that existed immediately before the bulk action.
- Participants in `ghost` state are excluded from bulk actions.
#### FR-13: Spotlight action on a single Participant
"Spotlight" shows exactly one Participant's feed and hides all others in a single action.
**Consequences (testable):**
- Spotlight stores the current Visibility Matrix as a pre-spotlight snapshot.
- A "Restore" action reverts to that pre-spotlight snapshot.
- Spotlight remains distinct from a manual Hide All plus Show One sequence: one dedicated action and one undo step.
#### FR-14: Keyboard shortcuts for Director's Board actions
All primary Director's Board actions are accessible without a mouse. Realizes UJ-3.
**Consequences (testable):**
- `Space` or `Enter` toggles the focused Participant.
- Arrow keys move focus between Participant cards.
- `Ctrl+Shift+S` runs Show All, `Ctrl+Shift+H` runs Hide All, and `Ctrl+Shift+P` spotlights the focused Participant.
- The `?` key opens a shortcut reference panel within the Director's Board.
- All shortcuts are configurable and documented in module settings.
---
### 4.3 Scene-Aware Visibility Presets
**Description:** Scene Presets are saved Visibility Matrix configurations that can be applied manually or automatically when a FoundryVTT Scene activates. This is **Level 3** of the Progressive Enhancement Architecture: configure during prep, then use during play. Realizes UJ-1.
**Functional Requirements:**
#### FR-15: GM saves a named Scene Preset from the current Visibility Matrix
Any current Visibility Matrix state can be saved as a named preset with a single action from the Director's Board or module settings.
**Consequences (testable):**
- The preset captures the full current Visibility Matrix at save time.
- The preset name is editable, and names remain unique within a world.
- Up to 50 presets can be stored in world settings [ASSUMPTION: adequate for any campaign].
#### FR-16: GM loads a Scene Preset at any time
Applying a preset overrides the current Visibility Matrix.
**Consequences (testable):**
- All clients receive the preset state within 500 ms through the same path as FR-2.
- Loading a preset generates the Contextual Notification defined in FR-20: "GM applied preset: [Preset Name]."
- The preset applies regardless of which Participants are online; offline Participants receive the stored state on reconnection under FR-3.
#### FR-17: Scene Preset auto-applies on FoundryVTT Scene activation
A preset can be linked to a Scene; it applies automatically when that Scene is activated.
**Consequences (testable):**
- 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].
- 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
GMs retain full manual override.
**Consequences (testable):**
- A per-scene toggle disables auto-apply for that Scene without removing the association.
- A global "Disable All Auto-Apply" toggle in module settings overrides all per-Scene configurations.
- The Director's Board always provides a manual override during play, regardless of automation state.
#### FR-19: Preset import/export as JSON
Presets can be exported to and imported from a JSON file.
**Consequences (testable):**
- Export writes all presets to one human-readable JSON file that the browser downloads.
- Import reads a JSON file and merges or replaces existing presets, based on user choice; invalid JSON shows an error.
- The module README documents the exported JSON format for community sharing.
---
### 4.4 Contextual Notifications
**Description:** Contextual Notifications use plain-language toasts to inform Participants when camera states change. They prevent silent surprises, and notification verbosity is configurable. Realizes UJ-4.
**Functional Requirements:**
#### FR-20: Toast notification on GM visibility change
When the GM changes any Participant's Visibility State, all Participants receive a toast notification in plain language.
**Consequences (testable):**
- The message uses the Participant display name: "GM hid [Name]'s camera" or "GM showed [Name]'s camera."
- The toast uses FoundryVTT's native notification UI.
- The affected Participant receives a distinct personal notification: "GM has hidden your camera. Your portrait is shown to other Participants."
#### FR-21: Notification verbosity configuration
Notification output is configurable per user.
**Consequences (testable):**
- Three modes are available: `All` (default), `GM Only`, and `Silent`.
- The configuration is stored in user-level client settings, not world settings.
- `Silent` mode still shows the personal notification to the affected Participant; the GM cannot suppress the FR-20 personal message.
#### FR-22: Persistent feed status indicator on own AV Tile
Each Participant always sees the current state of their own feed through a persistent status badge on their own AV Tile.
**Consequences (testable):**
- The badge shows one of these states: "Live," "Hidden by GM," "Muted," or "No Camera."
- The badge updates within 500 ms when the state changes.
- Only the owning Participant sees the badge; other Participants do not see badges on other AV Tiles.
---
### 4.5 Player Privacy Panel
**Description:** The Player Privacy Panel contains per-user settings for consenting to, or withdrawing consent from, cinematic automation effects that touch individual Participants. Design principle: **automation effects that touch a Participant's on-screen presence require explicit opt-in and remain off by default.** The GM retains unconditional hide/show authority under FR-1. Automation that auto-spotlights or visually modifies a Participant remains opt-in by user choice. Realizes UJ-2.
**Functional Requirements:**
#### FR-23: Player Privacy Panel accessible from module settings
Each user can open their Player Privacy Panel from the module settings tab in FoundryVTT.
**Consequences (testable):**
- The panel lists every automation effect that can touch the owning user, along with the current opt-in status.
- The owning user can edit the panel; the GM can view but not edit another Participant's panel settings.
- The settings persist in world-level user flags.
#### FR-24: Opt-in to Reaction Cam automation
A Participant must explicitly opt in before Reaction Cam (Later roadmap) can auto-spotlight them. The default is **off**.
**Consequences (testable):**
- Reaction Cam remains disabled for a Participant unless that Participant explicitly enables it in the Player Privacy Panel.
- The Director's Board displays a "Reaction Cam: Enabled" badge on opted-in Participant cards; no badge means off.
- The opt-in flag persists across sessions until the user changes it.
- Combat Cinematics Mode and any other Reaction Cam trigger respect this flag; they skip opted-out Participants silently.
#### FR-25: Opt-in to HP-Reactive Cam Styling
A Participant must explicitly opt in before HP-Reactive Cam Styling (Later roadmap) applies to their feed. The default is **off**.
**Consequences (testable):**
- HP-Reactive Cam Styling remains disabled for a Participant unless that Participant explicitly enables it.
- The GM is not notified of individual styling opt-in statuses; this preference remains private.
#### FR-26: Custom Portrait Fallback
A Participant can set a custom image as their Portrait Fallback (used in FR-8 when the camera is unavailable).
**Consequences (testable):**
- A file picker in the Player Privacy Panel sets the custom portrait.
- 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.
---
## 5. Non-Goals (Explicit)
- **Not an AV transport layer.** This module does not host, relay, or record audio/video streams. FoundryVTT's native WebRTC / AV stack handles all transport.
- **No audio control.** The module manages video visibility only. Muting audio remains native FoundryVTT functionality.
- **No recording or archiving.** No session video capture or replay features in v1.0.
- **FoundryVTT v13 and earlier are not supported.** v14 is the minimum compatibility floor; no backport is planned.
- **No central preset registry.** Community preset sharing is a local JSON export/import workflow only. No cloud or server-side storage.
- **No AI-driven visibility decisions.** All visibility changes are GM-initiated or rule-based (Scene Preset auto-apply). No ML or heuristic automation.
- **No asymmetric per-Viewer visibility (v1.0).** The Visibility Matrix is GM-to-everyone in v1.0; the full `Map<participantId, Map<viewerId, VisibilityState>>` model is architecturally supported but is not exposed in the UI until the Later roadmap.
- **No third-party streaming platform integration.** No OBS, Twitch, or YouTube API in v1.0. Browser Source API is a Later roadmap item.
- **Native WebRTC AV backend only (v1.0).** The module operates exclusively with FoundryVTT's built-in WebRTC stack. Jitsi Meeting Server and other third-party AV backends are not supported in v1.0; they are deferred to the Later roadmap.
---
## 6. MVP Scope
### 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
- 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
- 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
- FoundryVTT v14+ compatibility; `module.json` per v14 manifest schema
- English UI strings; i18n-ready string keys for community translation
### 6.2 Out of Scope for MVP
- Combat Cinematics Mode (auto-spotlight active combatant) — deferred to Later; see §10. `[NOTE FOR PM: This is emotionally load-bearing for Marcus-persona GMs. Consider as v1.1 if Day 1 ship goes smoothly.]`
- Reaction Cam (auto-spotlight on damage/dramatic event) — deferred to Later; see §10
- Director's Board Stage Lighting States (Wash / Focus / Blackout presets) — deferred to Later
- Token-Anchored Floating Cams — deferred to Later; requires deep canvas integration
- HP-Reactive Cam Styling — deferred to Later
- Spectator View / Dual Layout System — deferred to Later
- Browser Source API (OBS-ready tile URLs) — deferred to Later
- NPC Presence Tiles — deferred to Later
- Zero-UI Full Automation Mode — deferred to Later
- Pull Visibility Model (opt-in to see feeds) — deferred to Later
- Reaction Clip System (video snippet fallback for no-camera Participants) — deferred to Later
- Full asymmetric per-Viewer visibility in the Director's Board — deferred to Later
- FoundryVTT v13 compatibility backport — will not build
---
## 7. Success Metrics
**Primary**
- **SM-1: Installation count** — 500 installs within 90 days of Foundry Hub listing. Validates FR-1 through FR-8 (broad appeal of the core feature).
- **SM-2: Weekly active worlds** — ≥30% of installing worlds use the module in at least one session per week after the first week. Validates overall module stickiness across all v1.0 features.
**Secondary**
- **SM-3: Core toggle regression-free** — Zero bug reports for FR-1 through FR-4 (basic toggle + persistence) in the first 30 days. Validates Day 1 reliability.
- **SM-4: Open P1 bug count** — No more than 3 open P1 bugs at any point in the first 90 days. Validates overall module quality.
- **SM-5: State-persistence failures** — Zero user-reported Visibility State loss across reconnection (FR-3) after 30 days.
**Counter-metrics (do not optimize)**
- **SM-C1: Feature depth penetration** — Do not optimize for users enabling every feature. The module is successful if most users remain at Level 1 (right-click only) and only power users reach Level 3 (Scene Presets + Director's Board). Depth adoption is a health signal, not the goal. Counterbalances SM-1 and SM-2.
- **SM-C2: Notification frequency** — Do not make toast notifications more prominent, frequent, or verbose. FR-20 notifications exist to remove confusion, not to draw attention. Counterbalances SM-3.
---
## 8. Open Questions
1. **OQ-1:** Does FoundryVTT v14's `game.webrtc` / `AVMaster` API expose a programmatic method for disabling a specific peer's incoming video track (RTCPeerConnection track manipulation), or must the module hook at a lower level? To be resolved by inspecting the v14 AV API during development. FR-7 CSS fallback is the safe default until confirmed.
2. ~~**OQ-2 (Phase blocker):** Is `socketlib` the recommended socket broadcast approach for v14, or does FoundryVTT v14 expose a sufficient native socket API?~~ **RESOLVED:** Native FoundryVTT socket API will be used. `socketlib` dependency will not be added.
3. ~~**OQ-3:** What is the intended behavior when the GM's own feed is set to `hidden` — should the GM see their own feed in their view unchanged, or see the hidden state overlay like other GMs would?~~ **RESOLVED:** The GM always sees all activated Participant feeds. The GM's own feed visibility in their own view is a configurable option (show/hide self-view).
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.
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.
---
## 9. Assumptions Index
- **§4.1 / FR-3:** New Participants default to `active` Visibility State on first connection to a world. This is the socially safe default.
- **§4.1 / FR-7:** FoundryVTT v14's WebRTC implementation exposes RTCPeerConnection objects at a level accessible to a module, enabling programmatic track disabling without patching the core AV system.
- **§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-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.
---
## 10. Product Roadmap (Later Features)
The following concepts from the brainstorming session are architecturally consistent with v1.0 but are deferred to keep the initial release focused. They are listed here to prevent scope creep in v1.0 tickets while preserving the broader product vision.
| Concept | Brainstorm ID | Theme | Notes |
|---|---|---|---|
| Combat Cinematics Mode | #4 | Automation | Auto-spotlight active combatant; requires Reaction Cam opt-in (FR-24) already in v1.0 |
| Reaction Cam | #5, #17 | Cinematic | Opt-in per FR-24; triggers from game events |
| Director's Board Stage Lighting States | #7 | Cinematic | Wash / Focus / Blackout vocabulary tier |
| Token-Anchored Floating Cams | #8 | Cinematic | Deep canvas integration; high complexity |
| HP-Reactive Cam Styling | #9 | Cinematic | Opt-in per FR-25 already scaffolded in v1.0 |
| NPC Presence Tiles | #10 | Extended Presence | Static image tiles for NPC voice actors |
| Spectator View | #11 | Streaming | Independent audience layout |
| Zero-UI Full Automation Mode | #12 | Automation | Full configure-once, no-touch operation |
| The Living Table (full seating chart) | #1 | Core Architecture | Full `Map<p, Map<v, State>>` UI exposure |
| Pull Visibility Model | #14 | Core Architecture | Nothing is visible until actively requested |
| Reaction Clip System | #15 | Privacy + Presence | Video snippet fallback for no-camera Participants |
| Dual Layout System + Browser Source API | #18, #19 | Streaming | OBS-ready tile URLs; production stack |
---
## Cross-Cutting NFRs
**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.
- All hooks must use FoundryVTT's `Hooks.on()` registration pattern, never `Hooks.once()` for persistent behavior.
**Performance**
- 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.
- Socket message payload for a Visibility Matrix update must be ≤ 4 KB.
**Reliability**
- 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.
**Privacy**
- 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.
**Accessibility**
- 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.
**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.
- This two-tier vocabulary principle applies to all present and future features. Downstream contributors must follow the plain-language default; advanced or cinematic terms may appear only in optional advanced mode.
**GM Override Guarantee**
- Every automation feature — present (Scene Presets, FR-17/FR-18) and future (Combat Cinematics Mode, Reaction Cam, Zero-UI Full Automation Mode) — must expose an obvious one-click GM override that is accessible without opening configuration UI.
- The Director's Board "Hide All" action (FR-12) serves as the module's emergency path: the GM can silence all cameras in one click at any time, regardless of active automation state.
- 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)**
- 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.
@@ -0,0 +1,47 @@
# Reconciliation: Brainstorming → PRD
## Gaps Found (5 total)
### G-1: Reaction Cam consent model flipped from opt-in to opt-out
**Source:** Brainstorming Phase 3 / Sofia persona + Key Tension Resolved (brainstorming-session-2026-05-19-221747.md:130-150)
**Gap:** The brainstorming resolved the Marcus/Sofia tension with a consent-aware rule: Reaction Cam is **opt-in at setup**. The PRD partially preserves the privacy concern, but FR-24 defines Reaction Cam as an **opt-out** setting, which weakens the explicit consent model and contradicts the brainstorming resolution.
**Severity:** Critical
**Suggestion:** Update the PRD so Reaction Cam is consistently framed as opt-in everywhere (Vision, Privacy Panel, Roadmap, and any future automation language). If the product owner intentionally changed this, document the rationale explicitly as a scope decision rather than leaving it as a silent inversion.
### G-2: Vocabulary-tier UX and product voice are under-specified
**Source:** Alex persona + Idea #21 / Week 12 priorities (brainstorming-session-2026-05-19-221747.md:142-146, 173-178, 225-230)
**Gap:** The brainstorming made "plain language by default, power terms in advanced mode" a distinct product idea and onboarding strategy. The PRD keeps some plain-language copy examples, but it does not preserve the broader UX rule of **default-simple vocabulary with optional advanced/cinematic terminology**. Because the PRD is glossary-heavy, downstream implementation could drift toward expert-facing labels and lose the beginner-friendly voice that Alex validated.
**Severity:** Moderate
**Suggestion:** Add an explicit UX/content principle or requirement: default labels use plain language (Show/Hide/Spotlight), while cinematic or advanced terminology is optional in advanced mode/tooltips/documentation.
### G-3: Director's Board usage model drifted away from the brainstormed workflow
**Source:** SCAMPER Combine decision + Marcus/Jake personas (brainstorming-session-2026-05-19-221747.md:101, 124-140)
**Gap:** The brainstorming positioned the seating-chart popout as a **low-frequency setup/control tool**: open at session start, close during play, and reopen instantly via shortcut when needed. The PRD includes keyboard access, but UJ-3 reframes the Director's Board as something that stays open in a corner during the session. That changes the intended operating model and may push the implementation toward a noisier, always-on control-room UI instead of a lightweight prep-time tool.
**Severity:** Moderate
**Suggestion:** Revise the PRD to clarify the intended workflow: the Director's Board is primarily a pre-session / between-scenes tool with instant shortcut recall, not something assumed to remain open throughout play. If both modes are desirable, state that explicitly.
### G-4: "Every automation needs a one-click GM override" is not elevated as a cross-cutting rule
**Source:** Marcus persona + Question Storming "panic button" (brainstorming-session-2026-05-19-221747.md:54, 124-128)
**Gap:** The brainstorming established a strong control principle: automation is welcome only if the GM can immediately override it, including an instant hide-all/panic path. The PRD implements some related mechanics (Hide All, manual overrides for Scene Presets), but it does not carry forward the broader **product rule** that every present and future automation surface must expose an immediate GM override. That omission matters because downstream work on Scene Presets, Combat Cinematics, and Reaction Cam could become harder to interrupt safely.
**Severity:** Moderate
**Suggestion:** Add a cross-cutting requirement or design principle: every automation-capable feature must provide an obvious one-click GM override, and the module must expose an emergency hide-all action reachable without opening deep configuration UI.
### G-5: The brainstorm's phased-delivery discipline is blurred in the PRD
**Source:** North Star + Priority Stack + Session Summary (brainstorming-session-2026-05-19-221747.md:208-238, 277-293)
**Gap:** The brainstorming repeatedly anchored the product around a tightly scoped Day 1 ship: right-click toggle first, then practical Week 12 enhancements, then cinematic/streaming power features later. The PRD preserves the concepts but largely collapses Day 1 and Week 12 work into one v1.0 scope. That weakens the brainstorming's explicit risk-control signal: keep the first release rooted in the shippable core toggle.
**Severity:** Moderate
**Suggestion:** Add a release-phasing addendum or split scope more clearly into MVP / post-MVP milestones (for example: Level 1 ship first, then Director's Board + Presets, then privacy/automation scaffolding). If the all-in v1.0 scope is intentional, note the increased delivery risk explicitly.
## Intentional Drops (confirmed out of scope)
- Full asymmetric per-viewer visibility control / who-sees-who UI exposure — correctly deferred to Later roadmap / v1.0 non-goal.
- Pull Visibility Model — correctly deferred to Later roadmap.
- Combat Cinematics Mode — correctly deferred to Later roadmap.
- Reaction Cam feature execution — correctly deferred to Later roadmap (while privacy prerequisites remain relevant now).
- Stage Lighting States / theatrical vocabulary mode — correctly deferred to Later roadmap.
- Token-Anchored Floating Cams — correctly deferred to Later roadmap.
- HP-Reactive Camera Styling runtime behavior — correctly deferred to Later roadmap.
- NPC Presence Tiles — correctly deferred to Later roadmap.
- Spectator Curtain / Dual Layout System — correctly deferred to Later roadmap.
- Browser Source API / OBS-ready tile URLs — correctly deferred to Later roadmap.
- Zero-UI Full Automation Mode — correctly deferred to Later roadmap.
- Reaction Clip System — correctly deferred to Later roadmap.
- Non-native AV backends (e.g. Jitsi) — correctly excluded for v1.0 by explicit non-goal/open-question resolution.
@@ -0,0 +1,817 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scrying Pool — UX Design Directions</title>
<style>
:root {
--sp-surface: #141618;
--sp-surface-raised: #1c1f22;
--sp-border: #282c30;
--sp-text-primary: #dde2e8;
--sp-text-secondary: #7a8390;
--sp-accent: #4a9e6b;
--sp-focus: #63c287;
--sp-urgency-director: #c8982a;
--sp-state-active: #4a9e6b;
--sp-state-hidden: #6b7280;
--sp-state-self-muted: #8b92a5;
--sp-state-offline: #4b5563;
--sp-state-cam-lost: #9ca3af;
--sp-state-reconnecting: #c8982a;
--sp-state-never-connected: #374151;
--sp-state-ghost: #1f2937;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0e1012;
color: var(--sp-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
}
/* ── NAV ── */
.nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: #0a0c0e;
border-bottom: 1px solid var(--sp-border);
display: flex; align-items: center; gap: 4px;
padding: 8px 16px;
overflow-x: auto;
}
.nav h1 { font-size: 12px; font-weight: 600; color: var(--sp-accent); margin-right: 12px; white-space: nowrap; }
.nav-btn {
background: transparent; border: 1px solid var(--sp-border);
color: var(--sp-text-secondary); border-radius: 4px;
padding: 4px 10px; font-size: 11px; cursor: pointer; white-space: nowrap;
transition: all 0.15s;
}
.nav-btn:hover, .nav-btn.active {
background: var(--sp-surface-raised); border-color: var(--sp-accent);
color: var(--sp-text-primary);
}
/* ── LAYOUT ── */
.stage { padding-top: 48px; }
.direction {
display: none; min-height: calc(100vh - 48px);
padding: 24px;
}
.direction.visible { display: flex; gap: 24px; flex-wrap: wrap; }
.direction-label {
width: 100%; padding-bottom: 8px; border-bottom: 1px solid var(--sp-border);
margin-bottom: 16px;
}
.direction-label h2 { font-size: 16px; font-weight: 600; color: var(--sp-text-primary); }
.direction-label p { font-size: 12px; color: var(--sp-text-secondary); margin-top: 4px; }
/* ── FOUNDRY CHROME MOCK ── */
.foundry-chrome {
background: #1a1c1f;
border: 1px solid #2a2d32;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.foundry-titlebar {
background: #111315;
padding: 6px 12px;
font-size: 11px;
color: #5a6070;
border-bottom: 1px solid #222528;
display: flex; justify-content: space-between; align-items: center;
}
.foundry-titlebar span { color: var(--sp-accent); font-weight: 600; }
/* ── COMPACT STRIP ── */
.strip {
background: var(--sp-surface);
border: 1px solid var(--sp-border);
border-radius: 4px;
overflow: hidden;
min-width: 200px;
}
.strip-header {
padding: 6px 10px;
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--sp-text-secondary);
border-bottom: 1px solid var(--sp-border);
display: flex; justify-content: space-between; align-items: center;
}
.strip-header .sp-tag {
color: var(--sp-accent); font-size: 9px;
}
.strip-row {
display: flex; align-items: center; gap: 10px;
padding: 0 10px; height: 32px;
border-bottom: 1px solid #1e2124;
position: relative; cursor: pointer;
transition: background 0.1s;
}
.strip-row:hover { background: var(--sp-surface-raised); }
.strip-row:hover .row-actions { opacity: 1; pointer-events: all; }
.strip-row:last-child { border-bottom: none; }
.avatar {
width: 24px; height: 24px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; flex-shrink: 0;
position: relative;
}
.avatar.active { background: #1e3b2d; border: 2px solid var(--sp-state-active); }
.avatar.hidden { background: #252729; border: 2px dashed var(--sp-state-hidden); opacity: 0.7; }
.avatar.muted { background: #232530; border: 2px solid var(--sp-state-self-muted); }
.avatar.offline { background: #1a1c1f; border: 2px solid var(--sp-state-offline); opacity: 0.5; }
.avatar.reconnect { background: #2a2010; border: 2px solid var(--sp-state-reconnecting); }
.eye-slash {
position: absolute; bottom: -1px; right: -1px;
width: 10px; height: 10px; border-radius: 50%;
background: var(--sp-surface);
display: flex; align-items: center; justify-content: center;
font-size: 7px;
}
.row-name {
font-size: 13px; color: var(--sp-text-primary); flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-name.dimmed { color: var(--sp-text-secondary); }
.state-chip {
font-size: 9px; font-weight: 600; text-transform: uppercase;
padding: 1px 5px; border-radius: 3px; letter-spacing: 0.04em;
}
.chip-active { background: #1e3b2d; color: var(--sp-state-active); }
.chip-hidden { background: #252729; color: #9ca3af; }
.chip-muted { background: #232530; color: var(--sp-state-self-muted); }
.chip-reconnect { background: #2a2010; color: var(--sp-state-reconnecting); }
.row-actions {
display: flex; gap: 4px;
opacity: 0; pointer-events: none;
transition: opacity 0.15s;
position: absolute; right: 8px;
}
.action-btn {
background: var(--sp-border); border: none;
color: var(--sp-text-secondary); border-radius: 3px;
padding: 2px 6px; font-size: 10px; cursor: pointer;
transition: all 0.1s;
}
.action-btn:hover { background: var(--sp-accent); color: #000; }
.action-btn.hide-btn:hover { background: #4b5563; color: var(--sp-text-primary); }
/* ── DIRECTOR'S BOARD ── */
.board {
background: var(--sp-surface);
border: 1px solid var(--sp-border);
border-radius: 6px;
overflow: hidden;
min-width: 340px;
}
.board-header {
padding: 8px 12px;
background: #111315;
border-bottom: 1px solid var(--sp-border);
display: flex; justify-content: space-between; align-items: center;
}
.board-title { font-size: 12px; font-weight: 600; color: var(--sp-text-primary); }
.board-subtitle { font-size: 10px; color: var(--sp-text-secondary); margin-top: 1px; }
.board-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 1px; background: var(--sp-border);
padding: 1px;
}
.board-card {
background: var(--sp-surface-raised);
padding: 10px 8px; text-align: center;
cursor: pointer; transition: background 0.1s;
position: relative;
}
.board-card:hover { background: #252830; }
.board-card .card-avatar {
width: 40px; height: 40px; border-radius: 50%;
margin: 0 auto 6px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700;
}
.board-card .card-name { font-size: 10px; color: var(--sp-text-secondary); }
.board-card .card-state { font-size: 9px; margin-top: 3px; }
.board-card .card-toggle {
position: absolute; top: 4px; right: 4px;
font-size: 10px; opacity: 0;
transition: opacity 0.1s;
}
.board-card:hover .card-toggle { opacity: 1; }
/* ── PLAYER BADGE ── */
.player-view {
background: #1a1c20;
border: 1px solid var(--sp-border);
border-radius: 6px;
padding: 16px;
min-width: 260px;
}
.player-view h3 { font-size: 11px; color: var(--sp-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 12px; }
.video-tile {
background: #0d0f11;
border-radius: 4px;
aspect-ratio: 16/9;
position: relative;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
}
.video-tile.dimmed { opacity: 0.35; }
.visibility-badge {
position: absolute; bottom: 6px; left: 6px;
background: rgba(20,22,24,0.9);
border: 1px solid var(--sp-border);
border-radius: 10px;
padding: 3px 8px 3px 6px;
display: flex; align-items: center; gap: 4px;
font-size: 10px;
backdrop-filter: blur(4px);
cursor: pointer;
}
.badge-dot {
width: 6px; height: 6px; border-radius: 50%;
flex-shrink: 0;
}
.badge-dot.active { background: var(--sp-state-active); }
.badge-dot.hidden { background: var(--sp-state-hidden); }
.badge-dot.muted { background: var(--sp-state-self-muted); }
.first-encounter {
margin-top: 8px;
background: var(--sp-surface-raised);
border: 1px solid var(--sp-border);
border-radius: 4px;
padding: 8px 10px;
font-size: 11px; color: var(--sp-text-secondary);
line-height: 1.5;
}
.first-encounter strong { color: var(--sp-text-primary); display: block; margin-bottom: 4px; }
.got-it-btn {
margin-top: 6px;
background: var(--sp-accent); border: none;
color: #000; border-radius: 3px;
padding: 3px 10px; font-size: 10px; font-weight: 600;
cursor: pointer;
}
/* ── TOAST ── */
.toast {
background: #1c1f22;
border: 1px solid var(--sp-border);
border-left: 3px solid var(--sp-urgency-director);
border-radius: 4px;
padding: 8px 12px;
font-size: 12px; color: var(--sp-text-primary);
display: flex; align-items: center; gap: 8px;
}
.toast-icon { color: var(--sp-urgency-director); font-size: 14px; }
/* ── DIRECTION-SPECIFIC ── */
/* Direction 1: Docked vertical strip, minimal */
.d1-layout {
display: flex; gap: 16px; align-items: flex-start;
width: 100%;
}
.d1-foundry {
flex: 1; background: #16181b;
border: 1px solid #222528; border-radius: 6px;
height: 400px; position: relative;
display: flex; align-items: center; justify-content: center;
color: #3a3f48; font-size: 13px;
}
.d1-strip-dock {
position: absolute; right: 0; top: 0; bottom: 0;
width: 220px; border-left: 1px solid var(--sp-border);
background: var(--sp-surface);
display: flex; flex-direction: column;
}
/* Direction 2: Floating strip */
.d2-floating-strip {
position: absolute; right: 16px; top: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
border-radius: 6px;
z-index: 10;
}
/* Direction 3: Avatar-only condensed strip */
.strip-compact {
display: flex; flex-direction: column; gap: 2px;
padding: 6px;
background: var(--sp-surface);
border: 1px solid var(--sp-border);
border-radius: 6px;
width: 44px;
}
.avatar-only {
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700;
cursor: pointer; position: relative;
transition: transform 0.1s;
}
.avatar-only:hover { transform: scale(1.1); }
.avatar-only.active { background: #1e3b2d; border: 2px solid var(--sp-state-active); }
.avatar-only.hidden { background: #252729; border: 2px dashed var(--sp-state-hidden); opacity: 0.65; }
.avatar-only.reconnect { background: #2a2010; border: 2px solid var(--sp-state-reconnecting); }
.avatar-only.offline { background: #1a1c1f; border: 2px solid var(--sp-state-offline); opacity: 0.4; }
/* ── ANNOTATION ── */
.annotation {
background: #0f1113;
border: 1px solid #222528;
border-radius: 4px;
padding: 10px 14px;
font-size: 11px;
color: var(--sp-text-secondary);
line-height: 1.6;
min-width: 220px;
max-width: 300px;
}
.annotation strong { color: var(--sp-text-primary); display: block; margin-bottom: 4px; font-size: 12px; }
.annotation .pro { color: var(--sp-state-active); }
.annotation .con { color: #e57373; }
.annotation ul { padding-left: 14px; margin: 4px 0; }
.annotation li { margin-bottom: 2px; }
/* ── SECTION HEADERS ── */
.section-row {
display: flex; gap: 16px; align-items: flex-start;
flex-wrap: wrap; width: 100%;
}
/* PULSE ANIMATION */
@keyframes pulse-ring {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.avatar.reconnect { animation: pulse-ring 2s ease-in-out infinite; }
.avatar-only.reconnect { animation: pulse-ring 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.avatar.reconnect, .avatar-only.reconnect { animation: none; }
}
</style>
</head>
<body>
<nav class="nav">
<h1>🔮 Scrying Pool</h1>
<button class="nav-btn active" onclick="show(1)">D1 · Docked Strip</button>
<button class="nav-btn" onclick="show(2)">D2 · Floating Strip</button>
<button class="nav-btn" onclick="show(3)">D3 · Avatar-Only Strip</button>
<button class="nav-btn" onclick="show(4)">D4 · Director's Board Primary</button>
<button class="nav-btn" onclick="show(5)">D5 · Rich Row Strip</button>
<button class="nav-btn" onclick="show(6)">D6 · Player View</button>
</nav>
<div class="stage">
<!-- ════════════════════════════════════════════ DIRECTION 1 ═══ -->
<div class="direction visible" id="d1">
<div class="direction-label" style="width:100%">
<h2>Direction 1 — Docked Vertical Strip (Expert Path)</h2>
<p>Compact strip permanently docked to Foundry's right panel. Always visible, zero chrome, ambient authority. Expert-first; novice learns through Learning Bridge.</p>
</div>
<div style="position:relative; width:600px; height:400px; flex-shrink:0;">
<div class="d1-foundry" style="width:100%; height:100%;">
← Canvas / Map area →
<div class="d1-strip-dock">
<div class="strip-header">
Scrying Pool <span class="sp-tag">● Live</span>
</div>
<div class="strip-row">
<div class="avatar active">M</div>
<span class="row-name">Marcus</span>
<span class="state-chip chip-active">Live</span>
<div class="row-actions">
<button class="action-btn hide-btn">Hide</button>
</div>
</div>
<div class="strip-row">
<div class="avatar hidden">
S
<span class="eye-slash">👁</span>
</div>
<span class="row-name dimmed">Sofia</span>
<span class="state-chip chip-hidden">Hidden</span>
<div class="row-actions">
<button class="action-btn">Show</button>
</div>
</div>
<div class="strip-row">
<div class="avatar muted">J</div>
<span class="row-name">Jake</span>
<span class="state-chip chip-muted">Muted</span>
<div class="row-actions">
<button class="action-btn hide-btn">Hide</button>
</div>
</div>
<div class="strip-row">
<div class="avatar reconnect">A</div>
<span class="row-name">Alex</span>
<span class="state-chip chip-reconnect">Rejoining</span>
<div class="row-actions">
<button class="action-btn hide-btn">Hide</button>
</div>
</div>
<div class="strip-row">
<div class="avatar offline">?</div>
<span class="row-name dimmed">Player 5</span>
<span class="state-chip" style="color:#4b5563">Offline</span>
</div>
<div style="padding:8px 10px; margin-top:auto; border-top:1px solid var(--sp-border);">
<button style="width:100%; padding:5px; background:var(--sp-surface-raised); border:1px solid var(--sp-border); border-radius:3px; color:var(--sp-text-secondary); font-size:10px; cursor:pointer;">
Open Director's Board ↗
</button>
</div>
</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:12px; max-width:280px;">
<div class="annotation">
<strong>Direction 1 — Docked Strip</strong>
Key behaviours:
<ul>
<li>Hover row → action rail slides in from right</li>
<li>Right-click → context menu (fast path)</li>
<li>Strip always open; no toggle needed</li>
<li>Strip → Director's Board via footer CTA</li>
</ul>
<br>
<span class="pro">✓ Zero extra chrome; ambient state overview always visible</span><br>
<span class="pro">✓ Expert path natively; strip teaches itself on first hover</span><br>
<span class="con">✗ Requires docking API support in v14</span><br>
<span class="con">✗ Competes with Foundry's native right panel</span>
</div>
<div class="toast">
<span class="toast-icon"></span>
<span>Scrying Pool: Sofia visibility updated</span>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════ DIRECTION 2 ═══ -->
<div class="direction" id="d2">
<div class="direction-label" style="width:100%">
<h2>Direction 2 — Floating Strip (ApplicationV2 Default)</h2>
<p>Strip as a small floating ApplicationV2 window. Draggable, resizable, position persisted. Keyboard shortcut to open/close. Default open state true.</p>
</div>
<div style="position:relative; width:600px; height:420px; flex-shrink:0; background:#16181b; border:1px solid #222528; border-radius:6px; overflow:hidden; display:flex; align-items:center; justify-content:center; color:#3a3f48; font-size:13px;">
← Canvas / Map area →
<div class="d2-floating-strip strip" style="min-width:200px;">
<div class="strip-header" style="cursor:move;">
🔮 Scrying Pool <span class="sp-tag">● 4 live</span>
</div>
<div class="strip-row">
<div class="avatar active">M</div>
<span class="row-name">Marcus</span>
<div class="row-actions"><button class="action-btn hide-btn">Hide</button></div>
</div>
<div class="strip-row">
<div class="avatar hidden">S<span class="eye-slash">👁</span></div>
<span class="row-name dimmed">Sofia</span>
<div class="row-actions"><button class="action-btn">Show</button></div>
</div>
<div class="strip-row">
<div class="avatar muted">J</div>
<span class="row-name">Jake</span>
<div class="row-actions"><button class="action-btn hide-btn">Hide</button></div>
</div>
<div class="strip-row">
<div class="avatar reconnect">A</div>
<span class="row-name">Alex</span>
<div class="row-actions"><button class="action-btn hide-btn">Hide</button></div>
</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:12px; max-width:280px;">
<div class="annotation">
<strong>Direction 2 — Floating Strip</strong>
Key behaviours:
<ul>
<li>Opens with keyboard shortcut (single chord)</li>
<li>Draggable titlebar; resize from edges</li>
<li>Position/open state → GM User flag</li>
<li>Stays on top of canvas; can be minimised</li>
</ul>
<br>
<span class="pro">✓ Zero footprint until opened; no panel conflict</span><br>
<span class="pro">✓ Standard ApplicationV2 pattern — trivial to implement</span><br>
<span class="con">✗ GM must remember shortcut; discoverability gap</span><br>
<span class="con">✗ Occludes map when open at wrong position</span>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════ DIRECTION 3 ═══ -->
<div class="direction" id="d3">
<div class="direction-label" style="width:100%">
<h2>Direction 3 — Avatar-Only Condensed Strip</h2>
<p>Ultra-compact vertical strip showing only 32px avatars with state rings. Click to expand to a full-detail popover. Maximum density, minimum screen real-estate.</p>
</div>
<div style="display:flex; gap:16px; align-items:flex-start;">
<div>
<div style="font-size:10px; color:var(--sp-text-secondary); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">Collapsed state</div>
<div class="strip-compact">
<div class="avatar-only active">M</div>
<div class="avatar-only hidden" style="position:relative;">
S
<span style="position:absolute;bottom:-1px;right:-1px;font-size:7px;background:var(--sp-surface);border-radius:50%;width:10px;height:10px;display:flex;align-items:center;justify-content:center;">👁</span>
</div>
<div class="avatar-only" style="background:#232530;border:2px solid var(--sp-state-self-muted);">J</div>
<div class="avatar-only reconnect">A</div>
<div class="avatar-only offline" style="font-size:9px;">?</div>
</div>
</div>
<div>
<div style="font-size:10px; color:var(--sp-text-secondary); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">On avatar click → popover</div>
<div style="background:var(--sp-surface); border:1px solid var(--sp-border); border-radius:6px; padding:10px 12px; min-width:200px; box-shadow:0 4px 16px rgba(0,0,0,.5);">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<div class="avatar hidden" style="width:32px;height:32px;font-size:13px;">S<span class="eye-slash">👁</span></div>
<div>
<div style="font-size:13px; font-weight:600;">Sofia</div>
<div style="font-size:10px; color:var(--sp-text-secondary);">Not visible to others</div>
</div>
</div>
<button style="width:100%; padding:6px; background:var(--sp-accent); border:none; border-radius:3px; color:#000; font-size:11px; font-weight:600; cursor:pointer;">Show to table</button>
<button style="width:100%; padding:6px; background:var(--sp-surface-raised); border:1px solid var(--sp-border); border-radius:3px; color:var(--sp-text-secondary); font-size:11px; cursor:pointer; margin-top:4px;">Add note…</button>
</div>
</div>
<div class="annotation">
<strong>Direction 3 — Avatar-Only</strong>
Key behaviours:
<ul>
<li>Strip width: 44px — near-zero footprint</li>
<li>All detail in popover on click/hover</li>
<li>State ring + icon only — no text labels</li>
<li>Works inside Foundry AV strip space</li>
</ul>
<br>
<span class="pro">✓ Absolute minimum screen real-estate</span><br>
<span class="pro">✓ At-a-glance state by ring shape/colour</span><br>
<span class="con">✗ Copy/state labels hidden by default — accessibility concern</span><br>
<span class="con">✗ Popover adds click cost for every action</span>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════ DIRECTION 4 ═══ -->
<div class="direction" id="d4">
<div class="direction-label" style="width:100%">
<h2>Direction 4 — Director's Board as Primary Surface</h2>
<p>Director's Board (Level 2) promoted as the main GM control surface. Compact strip becomes a secondary summary-only view. Board-first = more information density per action.</p>
</div>
<div class="board" style="min-width:360px; max-width:440px;">
<div class="board-header">
<div>
<div class="board-title">🔮 Director's Board</div>
<div class="board-subtitle">Scrying Pool · 5 participants · 3 visible</div>
</div>
<button style="background:var(--sp-accent);border:none;border-radius:3px;color:#000;font-size:10px;font-weight:600;padding:3px 8px;cursor:pointer;">Hide All</button>
</div>
<div class="board-grid">
<div class="board-card">
<div class="card-avatar active" style="background:#1e3b2d;border:2px solid var(--sp-state-active);">M</div>
<div class="card-name">Marcus</div>
<div class="card-state" style="color:var(--sp-state-active);">● Live</div>
<div class="card-toggle">👁</div>
</div>
<div class="board-card">
<div class="card-avatar" style="background:#252729;border:2px dashed var(--sp-state-hidden);opacity:.65;">S</div>
<div class="card-name" style="color:var(--sp-text-secondary);">Sofia</div>
<div class="card-state" style="color:#9ca3af;">✕ Hidden</div>
<div class="card-toggle">🚫</div>
</div>
<div class="board-card">
<div class="card-avatar" style="background:#232530;border:2px solid var(--sp-state-self-muted);">J</div>
<div class="card-name">Jake</div>
<div class="card-state" style="color:var(--sp-state-self-muted);">🔇 Muted</div>
<div class="card-toggle">👁</div>
</div>
<div class="board-card">
<div class="card-avatar reconnect" style="background:#2a2010;border:2px solid var(--sp-state-reconnecting);width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;animation:pulse-ring 2s ease-in-out infinite;">A</div>
<div class="card-name">Alex</div>
<div class="card-state" style="color:var(--sp-state-reconnecting);">↺ Rejoining</div>
<div class="card-toggle">👁</div>
</div>
<div class="board-card" style="opacity:.5;">
<div class="card-avatar" style="background:#1a1c1f;border:2px solid var(--sp-state-offline);width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;">?</div>
<div class="card-name" style="color:var(--sp-text-secondary);">Player 5</div>
<div class="card-state" style="color:#4b5563;">✕ Offline</div>
</div>
</div>
<div style="padding:8px 12px; border-top:1px solid var(--sp-border); display:flex; gap:8px;">
<button style="flex:1; padding:5px; background:var(--sp-surface-raised); border:1px solid var(--sp-border); border-radius:3px; color:var(--sp-text-secondary); font-size:10px; cursor:pointer;">Show All</button>
<button style="flex:1; padding:5px; background:var(--sp-surface-raised); border:1px solid var(--sp-border); border-radius:3px; color:var(--sp-text-secondary); font-size:10px; cursor:pointer;">Save Preset…</button>
</div>
</div>
<div class="annotation">
<strong>Direction 4 — Board Primary</strong>
Key behaviours:
<ul>
<li>Board is default open surface for GMs</li>
<li>Card click = toggle visibility</li>
<li>Hover → card-level toggle icon</li>
<li>Compact strip = read-only summary</li>
</ul>
<br>
<span class="pro">✓ Optimal for bulk operations (Level 2)</span><br>
<span class="pro">✓ Video thumbnails can live here</span><br>
<span class="con">✗ Heavier to open than strip for single-player changes</span><br>
<span class="con">✗ Board should remain Level 2 opt-in — making it primary changes the product story</span>
</div>
</div>
<!-- ════════════════════════════════════════════ DIRECTION 5 ═══ -->
<div class="direction" id="d5">
<div class="direction-label" style="width:100%">
<h2>Direction 5 — Rich Row Strip with Inline State Labels</h2>
<p>Wider strip showing avatar + name + full state label + action. Maximum information at a glance. Best for novice GMs who need copy to orient them.</p>
</div>
<div style="display:flex; gap:16px; align-items:flex-start;">
<div class="strip" style="min-width:260px;">
<div class="strip-header">
🔮 Scrying Pool
<span class="sp-tag">5 participants</span>
</div>
<!-- Rich rows with state label column -->
<div class="strip-row" style="height:36px;">
<div class="avatar active">M</div>
<div style="flex:1; display:flex; flex-direction:column; justify-content:center;">
<span style="font-size:12px; color:var(--sp-text-primary); line-height:1.2;">Marcus</span>
<span style="font-size:10px; color:var(--sp-state-active);">Visible to all</span>
</div>
<div class="row-actions"><button class="action-btn hide-btn">Hide from table</button></div>
</div>
<div class="strip-row" style="height:36px; background:rgba(107,114,128,0.05);">
<div class="avatar hidden">S<span class="eye-slash">👁</span></div>
<div style="flex:1; display:flex; flex-direction:column; justify-content:center;">
<span style="font-size:12px; color:var(--sp-text-secondary); line-height:1.2;">Sofia</span>
<span style="font-size:10px; color:#9ca3af;">Not visible to others</span>
</div>
<div class="row-actions"><button class="action-btn">Show to table</button></div>
</div>
<div class="strip-row" style="height:36px;">
<div class="avatar muted">J</div>
<div style="flex:1; display:flex; flex-direction:column; justify-content:center;">
<span style="font-size:12px; color:var(--sp-text-primary); line-height:1.2;">Jake</span>
<span style="font-size:10px; color:var(--sp-state-self-muted);">Camera off (player choice)</span>
</div>
<div class="row-actions"><button class="action-btn hide-btn">Hide from table</button></div>
</div>
<div class="strip-row" style="height:36px;">
<div class="avatar reconnect">A</div>
<div style="flex:1; display:flex; flex-direction:column; justify-content:center;">
<span style="font-size:12px; color:var(--sp-text-primary); line-height:1.2;">Alex</span>
<span style="font-size:10px; color:var(--sp-state-reconnecting);">Rejoining view…</span>
</div>
<div class="row-actions"><button class="action-btn hide-btn">Hide from table</button></div>
</div>
<div class="strip-row" style="height:36px; opacity:.5;">
<div class="avatar offline">?</div>
<div style="flex:1; display:flex; flex-direction:column; justify-content:center;">
<span style="font-size:12px; color:var(--sp-text-secondary); line-height:1.2;">Player 5</span>
<span style="font-size:10px; color:#4b5563;">Disconnected</span>
</div>
</div>
</div>
<div class="annotation">
<strong>Direction 5 — Rich Row Strip</strong>
Key behaviours:
<ul>
<li>Row height 36px (vs 32px) for two-line label</li>
<li>State copy uses player-facing vocabulary</li>
<li>Action label is canonical: "Hide from table"</li>
<li>Best combined with novice primary path</li>
</ul>
<br>
<span class="pro">✓ Self-teaching — no tooltip or docs needed</span><br>
<span class="pro">✓ Canonical copy reinforced in GM strip</span><br>
<span class="con">✗ Wider strip (240280px) — larger screen footprint</span><br>
<span class="con">✗ Copy density may feel verbose at 8+ participants</span>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════ DIRECTION 6 ═══ -->
<div class="direction" id="d6">
<div class="direction-label" style="width:100%">
<h2>Direction 6 — Player View: VisibilityBadge & First Encounter</h2>
<p>The player's experience: badge on their own video tile; first-encounter explanation; ongoing state legibility. This is what Sofia sees.</p>
</div>
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:flex-start;">
<div>
<div style="font-size:10px; color:var(--sp-text-secondary); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">State: Active (visible)</div>
<div class="player-view" style="max-width:240px;">
<div class="video-tile" style="height:135px;">
👤
<div class="visibility-badge">
<div class="badge-dot active"></div>
<span style="color:var(--sp-text-primary);">Visible to all</span>
</div>
</div>
</div>
</div>
<div>
<div style="font-size:10px; color:var(--sp-text-secondary); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">State: Hidden (first encounter)</div>
<div class="player-view" style="max-width:260px;">
<div class="video-tile dimmed" style="height:135px;">
👤
<div class="visibility-badge" style="border-color:var(--sp-state-hidden);">
<div class="badge-dot hidden"></div>
<span style="color:var(--sp-text-secondary);">Not visible to others</span>
<span style="color:var(--sp-text-secondary); font-size:9px; margin-left:2px;">?</span>
</div>
</div>
<div class="first-encounter">
<strong>What is this badge?</strong>
This badge shows who can currently see your camera. Click anytime to learn more.
<br>
<button class="got-it-btn">Got it</button>
</div>
</div>
</div>
<div>
<div style="font-size:10px; color:var(--sp-text-secondary); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">State: Reconnecting</div>
<div class="player-view" style="max-width:240px;">
<div class="video-tile" style="height:135px; background:#0f110d;">
👤
<div class="visibility-badge" style="border-color:var(--sp-state-reconnecting); animation:pulse-ring 2s ease-in-out infinite;">
<div class="badge-dot" style="background:var(--sp-state-reconnecting);"></div>
<span style="color:var(--sp-urgency-director);">Rejoining view…</span>
</div>
</div>
</div>
</div>
<div class="annotation" style="max-width:260px;">
<strong>Direction 6 — Player View</strong>
Key behaviours:
<ul>
<li>Badge: <code>position:absolute</code> bottom-left of tile</li>
<li>Fade: 300500ms on player's own tile only</li>
<li>First encounter: pulsed badge + "Got it" panel</li>
<li>Badge always clickable for state explanation</li>
<li>Audio never affected by any state change</li>
<li>Copy: dignity-first vocabulary throughout</li>
</ul>
<br>
<span class="pro">✓ Player always informed; never blindsided</span><br>
<span class="pro">✓ "Got it" sets firstGMActivation (not dismiss)</span><br>
<span class="con">✗ Badge must not obscure face at 24px tile size</span><br>
<em style="font-size:10px; color:#7a8390;">Consider: bottom-left vs bottom-right placement based on AV grid layout testing</em>
</div>
</div>
</div>
</div><!-- /stage -->
<script>
function show(n) {
document.querySelectorAll('.direction').forEach(d => d.classList.remove('visible'));
document.getElementById('d' + n).classList.add('visible');
document.querySelectorAll('.nav-btn').forEach((b, i) => {
b.classList.toggle('active', i === n - 1);
});
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff