Files
scrying-pool/_bmad-output/planning-artifacts/epics.md
T
2026-05-21 23:08:34 +02:00

58 KiB
Raw Blame History

stepsCompleted, inputDocuments
stepsCompleted inputDocuments
1
2
3
4
_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 FoundryAdapterStateStoreSocketHandler (queue+drain); Hooks.once('ready')VisibilityManagerSocketHandler.setReady()NotificationBusRoleRendererRosterStrip/ScryingPoolStripDirectorsBoard (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; firstBadgeEncountergame.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.mjsmodule.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


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)