58 KiB
stepsCompleted, inputDocuments
| stepsCompleted | inputDocuments | |||||||
|---|---|---|---|---|---|---|---|---|
|
|
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 0–5000 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
.hbstemplates (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-jsdocon exported symbols;import/no-restricted-pathsboundary enforcement),vitest.config.js(happy-dom environment),scripts/package.mjs(producesmodule.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,RoleRendererMUST have zero directgame.*access; all Foundry API dependencies constructor-injected viaFoundryAdapterinterface (required for Vitest testability). - FoundryAdapter surface contract:
settings,socket,users,scenes,notifications,webrtc(feature-detected;nullif OQ-1 unresolvable),hooks— surfaces defined and injected at init time. - Initialisation order:
Hooks.once('init')→ register world settings → constructFoundryAdapter→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.visibilityMatrixwith{ _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 clearsPendingOp; 3s fallback timeout; retry-once before revert; stale ACK guard byopId/revision;PendingOpcontract:{ 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 viascripts/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;StateStoreis the sole writer for in-memory state and sole caller ofadapter.settings.set(). - Naming/prefix conventions: All world settings prefixed
scrying-pool.; all socket events prefixedscrying-pool.; all CSS classes prefixed.sp-or scoped under.scrying-pool; public API "not found" returnsnull, neverundefined.
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 theVisibilityBadge:rootexception is documented: badge tokens are declared on:rootbecause the badge is mounted outside the.scrying-poolroot 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 = falseconfirmed 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 insrc/adapters/foundry-adapter.jswith 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,FoundryAdapterare all testable via Vitest with injected mocks (zerogame.*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 (0–5000ms)
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)