Files
2026-05-21 23:08:34 +02:00

895 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)