- HTML5 drag-and-drop reordering of strip participants (per-GM flag) - Shift+click toggles spotlight focus on a participant (gold ring indicator) - Escape exits focus mode - Auto-save strip position on drag end + every 30s with viewport validation - Reset strip position button in Director's Board - French locale strings for reset button
57 KiB
stepsCompleted, stepsReviewed, workflowType, lastStep, status, completedAt, inputDocuments, workflowType, project_name, user_name, date, extendedAt, extendedFeatures
| stepsCompleted | stepsReviewed | workflowType | lastStep | status | completedAt | inputDocuments | workflowType | project_name | user_name | date | extendedAt | extendedFeatures | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
architecture | 8 | complete | 2026-05-20 |
|
architecture | video-view-manager | Morr | 2026-05-27 | 2026-05-27 |
|
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8] stepsReviewed: { "6": "party-mode" } workflowType: 'architecture' lastStep: 8 status: 'complete' completedAt: '2026-05-20' inputDocuments:
- _bmad-output/planning-artifacts/briefs/brief-video-view-manager-2026-05-19/brief.md
- _bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/prd.md
- _bmad-output/planning-artifacts/ux-design-specification.md workflowType: 'architecture' project_name: 'video-view-manager' user_name: 'Morr' date: '2026-05-27' extendedAt: '2026-05-27' extendedFeatures:
- reorder-participants
- spotlight-focus
- auto-position-snapshots
Architecture Decision Document: Video View Manager (Scrying Pool)
This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together.
Project Context Analysis
Requirements Overview
Functional Requirements: 26 FRs across 5 feature groups implementing the Progressive Enhancement Architecture:
- Level 1 — Core Visibility Toggle (FR-1–8): Right-click GM toggle on Scrying Pool's own roster strip, socket broadcast, world-level persistence, 8 Participant States (
active,hidden,self-muted,offline,cam-lost,reconnecting,never-connected,ghost), visual state indicators, WebRTC track disabling with CSS fallback, Portrait Fallback. - Level 2 — Director's Board (FR-9–14):
ApplicationV2floating window, seating-chart layout, per-card toggle, bulk Show/Hide with one-step undo, Spotlight with pre-spotlight snapshot, full keyboard navigation. - Level 3 — Scene-Aware Presets (FR-15–19): Save/load/auto-apply Visibility Matrix snapshots linked to FoundryVTT Scene activation, per-scene and global disable toggles, JSON import/export. (Scope note: Level 3 is in-scope for v1.0 per PRD §6.1. If delivery risk materialises, FR-1–8 is the minimum shippable increment and Level 3 can shift to v1.1 without breaking the architecture — PRD §Cross-Cutting NFRs.)
- Contextual Notifications (FR-20–22): 3-tier coalesced notification bus above
ui.notifications, role-differentiated verbosity, persistent self-status badge on own tile. - Player Privacy Panel (FR-23–26): Per-user opt-in for future automation effects (Reaction Cam, HP-Reactive Styling), custom Portrait Fallback.
Non-Functional Requirements:
- State update latency ≤500ms (local network), all-client including Director's Board reflection
- Director's Board renders interactive within 1s for 12 Participants
- Socket payload ≤4KB per Visibility Matrix update
- No blocking of FoundryVTT main render loop (async state application)
- Graceful degradation when
game.webrtcis unavailable — no errors, all UI hidden or disabled - Hook chaining required on all
Hooks.on()registrations; no naked overrides - No external data transmission; no analytics or telemetry
- WCAG AA: all interactive elements keyboard-navigable with ARIA labels
- CSS scoped to
.scrying-poolwith SP semantic token alias layer over Foundry tokens
Scale & Complexity: Medium-high for a FoundryVTT module. No infrastructure layer; FoundryVTT handles transport, persistence backbone, and rendering context. Complexity comes from real-time sync, optimistic UI with reconciliation, role-differentiated rendering, and 3-tier progressive UI.
- Primary domain: Platform-embedded browser module (FoundryVTT v14 ES module)
- Complexity level: Medium-high
- Estimated core modules: 6–8 (
VisibilityManager,SocketHandler,StateStore,DirectorsBoard,NotificationBus,ScenePresetManager,PlayerPrivacyPanel,AudienceView/RoleRenderer)
Technical Constraints & Dependencies
- FoundryVTT v14 API only —
ApplicationV2for floating windows,game.settingsfor world/client persistence, native socket API for broadcast,Hookssystem for lifecycle integration,game.webrtc/AVMasterfor optional track disabling (OQ-1 unresolved) - No external libraries — Font Awesome 6 (Foundry-bundled) + Foundry CSS custom properties only; no UI frameworks, no socketlib
- ES module architecture — standard FoundryVTT v14 module pattern
- CSS scoping discipline — all selectors under
.scrying-pool;--sp-*semantic tokens mapping to Foundry tokens with hardcoded fallbacks;--color-*/--font-*/--border-*Foundry tokens forbidden directly inside.scrying-pool - Right-click surface — must target Scrying Pool's own roster strip, NOT core AV tile DOM (WebRTC DOM is not a stable extension surface;
contextmenuon<video>may surface browser native media menu) - OQ-1 (architectural risk #1): WebRTC track disabling API availability on v14 — if
game.webrtcdoes not exposeRTCPeerConnectionat sufficient depth, privacy/bandwidth semantics change and CSS fallback becomes the permanent path; must be spiked before Level 1 is finalized - OQ-5 (open):
updateScenehook timing for preset auto-apply — defer to integration testing - OQ-6 (open): Partial vs unconditional preset application for offline Participants — unresolved; carries destructive-apply risk; must be decided during FR-15/16 implementation
Cross-Cutting Concerns Identified
-
State authority contract (prerequisite) — Before any component design, a canonical visibility state contract must exist: who is the authoritative source for each state change (GM manual, Scene Preset, privacy opt-in, default), conflict precedence order, persistence tier per source, and failure behavior per path. Without this, every subsystem will invent its own truth. (Party Mode consensus: Mary, Winston, Amelia, Sally)
-
Socket reliability & optimistic reconciliation — All state mutations go through a write-then-ACK cycle;
pendingOps: Map<opId, timeoutId>guards stale ACKs; ~2s timeout triggers revert + retry notification to GM. Silent failure is not acceptable. -
Role-differentiated rendering — GM and Player UIs are separate component trees; shared layer is the data model only. Must be enforced architecturally, not via conditional rendering masks.
-
World vs. client persistence boundary — Visibility Matrix and Presets in world settings; notification verbosity in client settings;
firstBadgeEncounterinlocalStoragepending GDPR decision (see below). -
GDPR / consent boundary (hard decision required) —
firstBadgeEncounterstorage (Userflag = world-persistent, linked to identity vs.localStorage= client-local, no server record) is not a deferred implementation detail — it changes the data model and privacy posture. Must be resolved as an explicit architecture decision before FR-23–26 design. Default recommendation:localStoragefor v1.0, documented as v2 upgrade path. (Party Mode: Amelia) -
Hook chaining discipline — All
Hooks.on()registrations must chain upstream handlers; singleton guards on keyboard shortcut registration andDirectorsBoardwindow instance. -
Graceful AV degradation — All module surfaces must check
game.webrtcavailability and disable/hide cleanly; never throw on a missing AV stack. -
Notification coalescing —
Map<participantId, {timer, lastState}>coalescing layer is a first-class architectural component aboveui.notifications; must not be bolted onto individual state mutations after the fact. -
Missing artefacts to produce during architecture phase (Party Mode: Amelia, Paige):
- Failure matrix: 8 Participant States × GM/Player role × AV available/unavailable
- Persistence map: world / client / user / scene / localStorage
- Event flow spec: toggle → socket → reconcile → render
- Scene Preset JSON schema with versioning strategy
Starter Template Evaluation
Primary Technology Domain
Platform-embedded FoundryVTT v14 ES module. No traditional web-app starter applies. Foundry's Electron/Chrome runtime is the module loader — the only required transformation is LESS → CSS.
Options Considered
| Option | Verdict |
|---|---|
| asmazovec/fvtt-module-ts-template (TS + Vite 8) | Excluded — full TypeScript/bundler not desired |
| League of Foundry Developers Module Template | Excluded — targets v10 era, no LESS, no v14 patterns |
| Custom minimal scaffold | Selected — built from FoundryVTT fundamentals up |
Selected Scaffold: Custom Minimal + Logic Test Harness
Rationale: Native ESM + LESS-only build step is the minimum viable toolchain.
The first deliverable is module.json — not package.json. JSDoc type-checking
and a unit test harness for pure logic are added to guard against behavioral drift
across 6–8 modules without shipping TypeScript.
Initialization:
npm init -y
npm install --save-dev \
less@4.6.4 \
chokidar@5.0.0 \
vitest@2.1.8 \
happy-dom@20.x \
typescript@5.9.3 \
@league-of-foundry-developers/foundry-vtt-types@{pin to commit SHA for v14} \
@types/node@22.x \
eslint \
eslint-plugin-jsdoc \
eslint-plugin-import
Note:
chokidaris scoped to LESS watch only —less --watchdoes not detect changes in@import-ed partials. chokidar triggerslesscon anystyles/**/*.lesschange.Note:
foundry-vtt-typesMUST be pinned to a specific commit SHA, not#main. Document the SHA and the Foundry v14 version it targets. Update deliberately, not automatically.
npm Scripts (Explicit Verb Surface)
"scripts": {
"build": "lessc styles/scrying-pool.less dist/styles/scrying-pool.css",
"watch": "chokidar 'styles/**/*.less' -c 'lessc styles/scrying-pool.less dist/styles/scrying-pool.css'",
"typecheck": "tsc --noEmit",
"lint": "eslint src/ module.js",
"test": "vitest run",
"test:watch": "vitest",
"release": "node scripts/package.mjs"
}
tsconfig.json — Key Flags
{
"compilerOptions": {
"checkJs": true,
"strict": true,
"noEmit": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node16",
"allowJs": true
}
}
ESLint rule: jsdoc/require-jsdoc on all exported symbols.
Rationale: Without
checkJs+strict,tsc --noEmitpasses silently on unannotated exports, defeating the entire type-safety rationale.
Structural Constraint — Foundry Dependency Injection (Hard Rule)
Modules targeted for unit testing (StateStore, SocketHandler, VisibilityManager,
RoleRenderer) MUST have zero direct game.* access. All Foundry API dependencies
are constructor-injected or accessed through a FoundryAdapter interface.
- Violation: any
game.*,ui.*,canvas.*call inside a testable module - Rationale: Direct
game.*calls cannot be mocked in Vitest; tests are immediately abandoned and the test harness becomes vestigial.
Story 0 Deliverables (Scaffold Acceptance Blockers)
| # | File | Purpose | AC Blocker |
|---|---|---|---|
| 1 | module.json |
v14 manifest (id, title, version, compatibility, esmodules, styles, languages) | ✅ |
| 2 | tsconfig.json |
checkJs, strict, noEmit, ESNext target |
✅ |
| 3 | .eslintrc.js |
jsdoc/require-jsdoc on exported symbols; import/no-restricted-paths boundary enforcement |
✅ |
| 4 | vitest.config.js |
happy-dom environment; path aliases; coverage config | ✅ |
| 5 | scripts/package.mjs |
Produces module.zip; reads version from package.json, writes into module.json at release. Single version source of truth. |
✅ |
| 6 | tests/fixtures/socket-payloads.js |
Socket payload schema source of truth; sender/receiver symmetry validated by contract tests | ✅ |
| 7 | .gitignore |
dist/, node_modules/, *.zip |
✅ |
| 8 | styles/scrying-pool.less |
Entry point (@imports token partials) |
✅ |
| 9 | lang/en.json |
i18n skeleton | ✅ |
Architectural Decisions Established
| Decision | Choice | Rationale |
|---|---|---|
| Language | Vanilla JavaScript (ES2022+), native ESM, JSDoc annotations | No bundler needed; Foundry is the loader |
| Type-checking | tsc --noEmit with checkJs+strict; ESLint jsdoc/require-jsdoc |
Type safety without TS compilation |
| Styles | LESS 4.6.4 → CSS via chokidar watch | @import partial detection requires chokidar |
| Templates | Handlebars .hbs (static, loaded by ApplicationV2 PARTS) |
FoundryVTT v14 pattern |
| i18n | lang/en.json registered in module.json |
Standard Foundry localisation |
| Testing | Vitest; Foundry deps constructor-injected into testable modules | game.* cannot be mocked directly |
| CI | typecheck + tests on every push; GitHub Actions release via scripts/package.mjs |
Automated quality gates |
Core Architectural Decisions
Decision Priority Analysis
Critical (Block Implementation):
- State persisted as world setting
scrying-pool.visibilityMatrix; any-GM, last-write-wins module.jsentry-point orchestrator; explicit constructor injection (Option A)- FoundryAdapter surface contract;
game.webrtcfeature-detected; hooks mediated init/readysplit with SocketHandler queue/drain bridging the seam- PendingOp DTO + authoritative echo reconciliation; 3s timeout is fallback only
- Release via Gitea publish script;
package.jsonis version source of truth
Important (Shape Architecture):
- Privacy consent: world setting, GM-controlled (trust mode)
firstBadgeEncounter: user flag (game.user.setFlag), not world settinggame.webrtcunavailable → CSS fallback silently; no player-facing error- Failure notification: GM only
- DirectorsBoard: lazy construction
Deferred (Post-MVP):
- Primary-GM authority protocol (v2)
- Schema migration tooling (world setting + scene flag versioning)
game.webrtctrack disabling implementation (blocked on OQ-1)
Data Architecture
| Decision | Choice | Rationale |
|---|---|---|
| Visibility Matrix persistence | World setting: scrying-pool.visibilityMatrix |
Survives reloads; GM-global base layer |
| State authority | Any GM, last-write-wins | Simplest v1 policy; no locking needed |
| Schema versioning | { _version: 1, matrix: {...} } wrapper from day one |
Safe future migrations |
| Pending ops | Map<opId, PendingOp>, 3s fallback timeout |
Authoritative echo is primary; timeout is safety net |
| PendingOp contract | { opId, userId, targetState, previousState, issuedAt, timeoutId } |
Deterministic revert; fully testable |
| Privacy consent | World setting, GM-controlled | Trust mode; no per-player consent UI |
firstBadgeEncounter |
game.user.setFlag('video-view-manager', 'firstBadgeEncounter') |
User-scoped; each player/GM has independent state |
| Scene Preset storage | Scene flag on Scene document, versioned: { _version: 1, presets: {...} } |
Preset is scene-scoped; base matrix in world setting |
Authentication & Security
| Decision | Choice |
|---|---|
| Trust model | Full trust between GM and players (explicit design choice) |
| Permission boundary | All state mutation gated behind game.user.isGM |
| Multi-GM policy | Any connected GM may write; last-write-wins; no locking |
| Data scope | No external data transmission |
API & Communication Patterns
Socket Reconciliation Flow:
GM intent → socket.emit(intent) → all clients receive authoritative echo → GM clears PendingOp
↓ (3s — no echo received)
revert to previousState + GM-only notification
| Decision | Choice |
|---|---|
| Transport | Native FoundryVTT socket API; payload ≤4KB |
| Message direction | Intent in (GM only); authoritative state/diff out (broadcast to all) |
| Timeout | 3s fallback; authoritative echo is primary resolution path |
| Failure target | GM-only notification |
game.webrtc absent |
CSS fallback; silent to players |
| Payload contract | src/contracts/socket-message.js; validated at send and receive |
Module Structure & API Boundaries
Wiring Pattern: Entry-Point Orchestration (Option A)
module.js is the wiring diagram. It imports all modules, constructs them with injected dependencies, and holds no business logic.
FoundryAdapter Surface Contract:
settings.register(key, config) / .get(key) / .set(key, value)
socket.emit(event, payload) / .on(event, handler) / .off(event, handler)
users.get(userId) / .all() / .isGM(userId?) / .current()
scenes.current() / .get(id)
notifications.info(msg) / .warn(msg) / .error(msg)
webrtc.getConnection(userId) // feature-detected; null if OQ-1 unresolvable
hooks.on(event, handler) / .once(event, handler)
Initialisation Order:
Hooks.once('init')
→ register world settings
→ construct FoundryAdapter
→ construct StateStore(adapter.settings)
→ construct SocketHandler(adapter.socket, adapter.hooks)
// SocketHandler: messageQueue=[], isReady=false
Hooks.once('ready')
→ construct VisibilityManager(stateStore, adapter)
→ SocketHandler.setReady(visibilityManager) // drains messageQueue
→ construct NotificationBus(adapter.notifications)
→ construct RoleRenderer(visibilityManager, adapter)
→ construct RosterStrip(visibilityManager, roleRenderer)
→ if game.user.isGM → register DirectorsBoard (lazy — constructed on demand)
Contract Files (src/contracts/):
visibility-matrix.js— matrix shape +_versionwrappersocket-message.js— intent and echo payload schemaspending-op.js— PendingOp DTOscene-preset.js— preset shape +_versionwrapper
Hard Rule: StateStore, SocketHandler, VisibilityManager, RoleRenderer have zero direct game.* access. All Foundry dependencies are constructor-injected via FoundryAdapter.
Infrastructure & Deployment
| Decision | Choice |
|---|---|
| Release pipeline | Gitea publish script |
| CI quality gates | Gitea CI: lint + typecheck + test on every push (.gitea/workflows/) |
| Version source of truth | package.json → written into module.json by scripts/package.mjs |
| Release artifact | module.zip (module.json + dist/ + lang/ + templates/) |
Decision Impact Analysis
Implementation Sequence:
- Story 0: scaffold +
module.json+tsconfig.json+.eslintrc.js+scripts/package.mjs+src/contracts/+ socket fixtures +.gitea/workflows/ - Story 1:
FoundryAdapter+ world setting registration +StateStore+SocketHandler(with queue/drain) - Story 2:
VisibilityManager+RoleRenderer+RosterStrip(L1 core) - Story 3:
NotificationBus+DirectorsBoard(L2) - Story 4:
ScenePresetManager(L3)
Cross-Component Dependencies:
- All testable modules depend on
FoundryAdapterinterface, not implementation SocketHandlerqueue bridges theinit→readylifecycle seamRoleRendererdepends onVisibilityManager+webrtccapability contractDirectorsBoarddepends onVisibilityManager+SocketHandlerScenePresetManagerdepends onStateStore+adapter.scenes
Implementation Patterns & Consistency Rules
Critical Conflict Points Identified
14 areas where agents could diverge and produce incompatible code if unspecified.
Naming Patterns
JS Module Files
- Classes and modules: PascalCase →
StateStore.js,FoundryAdapter.js,DirectorsBoard.js - Utility/helper files: camelCase →
uuid.js - Contract files: kebab-case →
socket-message.js,pending-op.js - Test files:
{SourceFile}.test.js→StateStore.test.js
World Setting Keys
Always scrying-pool.{camelCase}:
scrying-pool.visibilityMatrix
scrying-pool.privacyConsent
scrying-pool.firstBadgeEncounter (user flag via game.user.setFlag)
❌ Never: video-view-manager.X, sp.X, vvm.X
Socket Event Names
Commands (GM intent, in): scrying-pool.{noun}.{verb} — imperative tense
scrying-pool.visibility.set
scrying-pool.preset.apply
Emitted events (broadcasts, out): scrying-pool.{noun}.{pastTense}
scrying-pool.visibility.updated
scrying-pool.preset.applied
❌ Never: sp:toggle, scryingPool.stateChange, bare event names
Internal Foundry Hook Names
scrying-pool:{camelCasePastTense}
scrying-pool:stateChanged
scrying-pool:socketReady
scrying-pool:presetApplied
CSS Classes
BEM-lite, all scoped under .scrying-pool:
- Element:
scrying-pool__participant-card,scrying-pool__roster-strip - Modifier:
scrying-pool__participant-card--hidden - State:
sp-state-{name}— exactly one per element at a time - Pending:
sp-state-pending(optimistic UI in-flight)
❌ Never: global selectors, !important (except a11y overrides)
JS Variables, Functions, Exports
- Variables + functions:
camelCase - Module-level constants:
SCREAMING_SNAKE_CASE—VISIBILITY_STATES,SOCKET_TIMEOUT_MS - Private methods:
_prefix—_drainQueue(),_revertOp() - Export convention: named exports only
// ✅ export class StateStore { ... } // ❌ export default class StateStore { ... }
Structure Patterns
Import Boundary Rule (Hard)
src/core/ → may import: src/contracts/, src/utils/ ONLY
src/foundry/ → may import: src/contracts/, src/utils/
src/notifications/ → may import: src/core/, src/contracts/, src/utils/
src/presets/ → may import: src/core/, src/contracts/, src/utils/
src/ui/ → may import: src/core/, src/contracts/, src/utils/
src/contracts/ → no internal imports
src/utils/ → no internal imports
module.js → may import: all of src/
❌ src/core/ importing src/foundry/, src/ui/, src/notifications/, or src/presets/ is a hard violation.
❌ src/foundry/ importing src/core/ or src/ui/ is a hard violation.
Enforcement: .eslintrc.js must configure eslint-plugin-import import/no-restricted-paths zones
for all the above rules. This is not aspirational documentation — it must be wired into
npm run lint and Gitea CI. Testing import boundaries via runtime tests is the wrong tool.
Test Structure
One spec file per source file, mirrored path:
src/core/StateStore.js → tests/unit/core/StateStore.test.js
Format Patterns
Contracts (src/contracts/*.js)
Every contract file exports:
/** @typedef {{ opId: string, ... }} PendingOp */ // 1. JSDoc typedef
export function createPendingOp(input) { ... } // 2. Factory
export function validatePendingOp(dto) { ... } // 3. Validator
// Validator rules: reject unknown keys; timestamp finite non-negative integer;
// id fields non-empty string; arrays default []; nullable fields explicit null.
// Throws TypeError with field name on violation.
Socket Payload Size
Payload ≥ 4096 bytes → throw before emit. Boundary tested: 4095 OK / 4096 reject.
Null vs Undefined
Public API "not found": null — never undefined. VisibilityState always a string enum value.
Timestamps
Internal: Date.now() (ms integer). Persisted: ISO 8601 string.
Communication Patterns
State Authority
StateStore is the sole writer for in-memory state AND sole caller of adapter.settings.set().
All other modules call stateStore.setState(). Direct map mutation from outside StateStore is forbidden.
After every mutation:
Hooks.callAll('scrying-pool:stateChanged', { userId, state, previousState, timestamp })
Constructor Rule: Side-Effect Free
// ✅ constructor(adapter) { this._adapter = adapter; }
// init() { this._adapter.hooks.once('ready', () => this._onReady()); }
// ❌ constructor(adapter) { adapter.hooks.once('ready', ...); }
Lifecycle registration: module.js owns Hooks.once('init') and Hooks.once('ready').
Individual modules own their own cleanup in teardown().
Guard Clauses: Early Return
// ✅ setState(userId, state) { if (!userId) return; if (!VISIBILITY_STATES.includes(state)) return; }
// ❌ nested if blocks
UI Safety
All participant display names via .textContent not .innerHTML. Interactive elements use data-action selectors.
Process Patterns
Error Handling by Layer
| Layer | On Error |
|---|---|
src/core/ |
throw — errors are testable contracts |
src/foundry/ |
catch + console.warn/error('[ScryingPool]', ...) |
src/ui/ |
catch + route to NotificationBus (GM) or swallow (player) |
module.js |
catch init errors + console.error('[ScryingPool]', ...) + graceful abort |
Console prefix convention: '[ScryingPool]' on all console calls.
Async
async/await only — no raw .then() chains. Errors caught at UI/module boundary.
Optimistic UI / PendingOp Lifecycle
- Apply state + add
sp-state-pendingCSS class + register PendingOp - Authoritative echo received → remove
sp-state-pending, confirm state, clear PendingOp - 3s timeout → revert to
previousState, removesp-state-pending, GM notification
Test Patterns
// Canonical mock — always use this, no ad-hoc stubs:
import { createFoundryAdapterMock } from '../helpers/foundryAdapterMock.js'
const adapter = createFoundryAdapterMock({ settings: { get: () => 'custom' } })
// Fake timers for PendingOp timeout paths:
vi.useFakeTimers()
vi.advanceTimersByTime(3001)
expect(stateStore.getState(userId)).toBe(previousState)
// Fixtures are frozen:
export const SOCKET_PAYLOADS = Object.freeze({ ... })
LESS / CSS Patterns
State Map — 9 States (Single Source of Truth)
styles/tokens/_states.less:
@sp-states: {
active: { color: @sp-color-active, icon: '\f06e'; shape: solid; };
hidden: { color: @sp-color-hidden, icon: '\f070'; shape: dashed; };
self-muted: { color: @sp-color-muted, icon: '\f131'; shape: solid; };
offline: { color: @sp-color-offline, icon: '\f00d'; shape: none; };
cam-lost: { color: @sp-color-warning, icon: '\f03d'; shape: dashed; };
reconnecting: { color: @sp-color-info, icon: '\f021'; shape: pulse; };
never-connected: { color: @sp-color-neutral, icon: '\f068'; shape: none; };
ghost: { color: @sp-color-ghost, icon: '\f2be'; shape: dotted; };
pending: { color: @sp-color-neutral, icon: '\f110'; shape: pulse; };
};
Second-Signal Rule (Accessibility)
Every sp-state-* conveys state through three independent channels: color + icon + shape.
Color alone is an accessibility violation. Shape = left border variant / badge ring / dashed outline.
State Precedence (one class max per element)
pending > cam-lost > reconnecting > offline > never-connected > self-muted > hidden > ghost > active
VisibilityManager/RoleRenderer resolves conflicts before rendering. CSS never handles multi-state.
Theme Safety
All state tokens defined as light/dark surface variants:
--sp-state-{name}-text, --sp-state-{name}-border, --sp-state-{name}-bg
Motion (styles/tokens/_motion.less)
Transitions: opacity, background-color, border-color only.
reconnecting: subtle pulse. cam-lost: single blink, not looping.
@media (prefers-reduced-motion: reduce) {
.scrying-pool * { animation: none !important; transition: none !important; }
}
Focus Ring (styles/tokens/_focus.less)
Module-wide pattern: high-contrast outer ring + inner offset. Must survive all 9 state background combinations. Never rely on browser default alone.
Enforcement Summary — All Agents MUST:
- Named exports only (
export class X, neverexport default) src/core/imports:src/contracts/andsrc/utils/only- Console:
console.*('[ScryingPool]', ...)— no custom logger - Scope CSS to
.scrying-pool— no global selectors - Prefix world settings:
scrying-pool. - Prefix socket events:
scrying-pool. - State mutation only via
stateStore.setState() - One spec per source file in
tests/unit/{mirror-path}/ - Mock via
createFoundryAdapterMock()— no ad-hoc stubs Object.freeze()all fixture exports- Fake timers for PendingOp timeout tests
- Return
nullnotundefinedfrom public APIs - Every
sp-state-*must signal: color + icon + shape async/awaitnot.then()
Project Structure & Boundaries
Requirements → Component Mapping
| FR Group | FRs | Primary Files |
|---|---|---|
| L1 Core Visibility Toggle | FR-1–8 | StateStore, SocketHandler, VisibilityManager, RosterStrip, RoleRenderer |
| L2 Director's Board | FR-9–14 | DirectorsBoard, ParticipantCard, RoleRenderer |
| L3 Scene-Aware Presets | FR-15–19 | ScenePresetManager, ScenePresetPanel |
| Contextual Notifications | FR-20–22 | NotificationBus, PlayerStatusBadge |
| Player Privacy Panel | FR-23–26 | PlayerPrivacyPanel |
Complete Project Directory Structure
video-view-manager/
│
├── module.json ← FoundryVTT v14 manifest (id, title, version,
│ compatibility, esmodules, styles, languages)
├── package.json ← npm config + explicit scripts block
├── tsconfig.json ← checkJs, strict, noEmit, ESNext
├── vitest.config.js ← happy-dom environment; path aliases; coverage config
├── .eslintrc.js ← jsdoc/require-jsdoc on exported symbols;
│ import/no-restricted-paths boundary enforcement
├── .gitignore ← dist/, node_modules/, *.zip
├── README.md
├── CHANGELOG.md
│
├── .gitea/
│ └── workflows/
│ └── ci.yml ← typecheck + test on every push
│
├── scripts/
│ └── package.mjs ← produces module.zip; reads version from
│ package.json, writes into module.json
│
├── module.js ← Entry point + wiring diagram (no business logic)
│
├── src/
│ │
│ ├── contracts/ ← DTOs: typedef + createX() + validateX()
│ │ ├── visibility-matrix.js ← { _version, matrix: Map<userId,VisibilityState> }
│ │ ├── socket-message.js ← intent + echo payload schemas
│ │ ├── pending-op.js ← { opId, userId, targetState, previousState,
│ │ │ issuedAt, timeoutId }
│ │ └── scene-preset.js ← { _version, name, matrix snapshot }
│ │
│ ├── core/ ← Pure logic; imports contracts/ + utils/ only
│ │ ├── StateStore.js ← Sole writer of visibility state + persistence
│ │ │ Emits scrying-pool:stateChanged on every mutation
│ │ ├── SocketHandler.js ← Socket emit/receive; queue during init, drain
│ │ │ on ready (drain() called explicitly by module.js
│ │ │ at ready hook — NOT self-registered internally);
│ │ │ authoritative echo reconciliation
│ │ └── VisibilityManager.js ← Toggle logic, optimistic ops, PendingOp lifecycle,
│ │ state precedence resolution (9 states)
│ │
│ ├── foundry/
│ │ └── FoundryAdapter.js ← Sole gateway to game.*; feature-detects webrtc;
│ │ exposes: settings, socket, users, scenes,
│ │ notifications, webrtc, hooks
│ │
│ ├── ui/
│ │ ├── RoleRenderer.js ← Reactive dispatcher: re-renders on game.user role
│ │ │ change (Hooks.on('updateUser')); dispatches to
│ │ │ gm/ or player/ subtree; tears down + rebuilds on
│ │ │ mid-session role change
│ │ │
│ │ ├── gm/
│ │ │ ├── RosterStrip.js ← L1: right-click surface on Scrying Pool's own
│ │ │ │ roster strip; registers context menu handler
│ │ │ ├── DirectorsBoard.js ← L2: ApplicationV2 floating window; lazy-constructed
│ │ │ │ seating chart; bulk Show/Hide; Spotlight; keyboard nav
│ │ │ └── ScenePresetPanel.js ← L3: preset save/load/import/export UI; embedded
│ │ │ inside DirectorsBoard as collapsible drawer/tab
│ │ │ (NOT standalone — shares ApplicationV2 lifecycle)
│ │ │
│ │ ├── player/
│ │ │ └── PlayerPrivacyPanel.js ← FR-23–25: runtime opt-in panel; note FR-26
│ │ │ (GM-controlled world setting) is registered in
│ │ │ module settings, NOT rendered in this panel
│ │ │
│ │ └── shared/
│ │ ├── ParticipantCard.js ← Shared card: state display, 9 visual states,
│ │ │ sp-state-pending, second-signal (color+icon+shape)
│ │ └── PlayerStatusBadge.js ← FR-22: DOM overlay on Foundry's AV tile; uses
│ │ absolute positioning anchored to tile element;
│ │ visible to both GM and player; NOT a first-class
│ │ Foundry UI component — wraps Foundry's output
│ │
│ ├── notifications/
│ │ └── NotificationBus.js ← Coalescing bus above ui.notifications;
│ │ Map<userId,{timer,lastState}>; GM-only
│ │
│ ├── presets/
│ │ └── ScenePresetManager.js ← L3: save/load scene flags; auto-apply on
│ │ updateScene hook; JSON import/export
│ │
│ └── utils/
│ └── uuid.js ← opId generation for PendingOp
│
├── styles/
│ ├── scrying-pool.less ← Entry point (@imports only)
│ │
│ ├── tokens/
│ │ ├── _base.less ← Tier 2: --sp-* alias tokens over Foundry base
│ │ │ (Foundry tokens never used directly inside
│ │ │ .scrying-pool)
│ │ ├── _states.less ← @sp-states map: all 9 states (incl. pending)
│ │ │ color + icon + shape per state; loop-generated CSS
│ │ ├── _motion.less ← Transition rules; reconnecting pulse; cam-lost
│ │ │ single blink; prefers-reduced-motion override
│ │ └── _focus.less ← Module-wide focus ring: high-contrast outer ring
│ │ + inner offset; survives all 9 state backgrounds
│ │
│ └── components/
│ ├── _participant-card.less ← Card layout; state class application; pending UI;
│ │ @imports _motion.less for animation states
│ ├── _roster-strip.less ← L1 strip layout; right-click target
│ ├── _directors-board.less ← L2 seating chart; bulk controls; spotlight overlay
│ ├── _scene-preset-panel.less ← L3 preset list; import/export controls
│ ├── _notification.less ← Notification bus visual styles
│ ├── _player-badge.less ← Self-status badge (PlayerStatusBadge overlay)
│ └── _player-panel.less ← Player privacy panel (PlayerPrivacyPanel)
│
├── templates/
│ ├── directors-board.hbs ← L2: ApplicationV2 PARTS template
│ ├── participant-card.hbs ← Shared card partial
│ ├── roster-strip.hbs ← L1: roster strip layout
│ ├── scene-preset-panel.hbs ← L3: preset management panel
│ └── player-panel.hbs ← Player privacy panel
│
├── lang/
│ └── en.json ← All i18n keys; registered in module.json
│
├── dist/ ← Generated; gitignored
│ └── styles/
│ └── scrying-pool.css
│
└── tests/
├── unit/ ← Mirrors src/ (one spec per source file)
│ │ UI components (src/ui/) deferred to Story 1+
│ ├── core/
│ │ ├── StateStore.test.js
│ │ ├── SocketHandler.test.js
│ │ └── VisibilityManager.test.js
│ ├── foundry/
│ │ └── FoundryAdapter.test.js ← Tests: interface shape parity with mock,
│ │ delegation to game.*, error surface behaviour
│ ├── notifications/
│ │ └── NotificationBus.test.js
│ ├── presets/
│ │ └── ScenePresetManager.test.js
│ ├── contracts/
│ │ ├── visibility-matrix.test.js
│ │ ├── socket-message.test.js
│ │ ├── pending-op.test.js
│ │ └── scene-preset.test.js
│ └── module-init.test.js ← Wiring order assertions only (drain() after
│ StateStore.init(), VisibilityManager gets injected
│ deps); import boundary enforcement is ESLint's job
│
├── helpers/
│ └── foundryAdapterMock.js ← createFoundryAdapterMock(overrides={})
│ Canonical mock; no ad-hoc stubs in specs
│
└── fixtures/ ← All Object.freeze'd; must include negative/invalid
│ fixtures for every validateX() rejection branch
├── socket-payloads.js ← valid + malformed (missing opId, wrong enum, extra keys)
├── visibility-states.js ← valid matrix + userId → null (invalid)
├── state-store-snapshots.js
├── scene-preset.js ← valid + empty matrix {} edge case
├── pending-op.js ← valid + timeoutId: null + expired issuedAt
└── foundry-adapter.js ← minimal frozen game-stub (settings, socket, users)
Architectural Boundaries
Module Dependency Graph
module.js
├── FoundryAdapter (foundry/)
├── StateStore (core/) ← FoundryAdapter.settings
├── SocketHandler (core/) ← FoundryAdapter.socket, .hooks
├── VisibilityManager (core/) ← StateStore, FoundryAdapter
├── NotificationBus (notifications/) ← FoundryAdapter.notifications
├── RoleRenderer (ui/) ← VisibilityManager, FoundryAdapter
├── RosterStrip (ui/gm/) ← VisibilityManager, RoleRenderer
├── DirectorsBoard [lazy] (ui/gm/) ← VisibilityManager, SocketHandler
└── ScenePresetManager (presets/) ← StateStore, FoundryAdapter.scenes
Data Flow — GM Visibility Toggle (L1/L2)
RosterStrip / DirectorsBoard
→ VisibilityManager.toggle(userId, newState)
→ StateStore.setState(userId, newState) [optimistic, immediate]
→ PendingOp registered in Map<opId, PendingOp>
→ SocketHandler.emit('scrying-pool.visibility.set', { userId, newState, opId })
→ RoleRenderer.render() [immediate visual update]
Socket echo received ('scrying-pool.visibility.updated'):
→ SocketHandler → VisibilityManager.reconcile(echo)
→ StateStore.setState(userId, echo.state) [authoritative]
→ pendingOps.delete(opId) + clearTimeout
→ Hooks.callAll('scrying-pool:stateChanged', ...)
→ RoleRenderer.render() [confirm visual state]
[3s — no echo]:
→ pendingOp timeout fires
→ StateStore.setState(userId, previousState) [revert]
→ NotificationBus.enqueue(userId, 'timeout') [GM notification only]
→ RoleRenderer.render() [revert visual]
Data Flow — Scene Preset Auto-Apply (L3)
Hooks.on('updateScene', scene)
→ ScenePresetManager.onSceneActivate(scene)
→ scene.getFlag('video-view-manager', 'preset')
→ VisibilityManager.applyMatrix(preset.matrix)
→ StateStore.setMatrix(preset.matrix)
→ SocketHandler.emit('scrying-pool.visibility.updated', fullMatrix)
→ scrying-pool:stateChanged (bulk)
→ RoleRenderer.render()
Data Flow — Notification Bus
scrying-pool:stateChanged hook
→ NotificationBus.enqueue(userId, newState)
→ if existing timer for userId: clearTimeout, update lastState
→ set new debounce timer (300ms coalesce window)
→ timer fires → adapter.notifications.info(coalesced message)
[GM client only; players see visual state only]
Integration Points
FoundryVTT Hooks Used
| Hook | Where | Purpose |
|---|---|---|
Hooks.once('init') |
module.js |
Register settings; construct core layer |
Hooks.once('ready') |
module.js |
Construct UI layer; call socketHandler.drain() |
Hooks.on('updateScene') |
ScenePresetManager |
L3 auto-apply presets |
Hooks.on('updateUser') |
RoleRenderer |
Detect mid-session GM role change; rebuild UI subtree |
Hooks.on('renderActorSheet') |
RosterStrip |
Attach to Scrying Pool roster strip (TBD) |
Hooks.callAll('scrying-pool:stateChanged') |
StateStore |
Internal broadcast |
FoundryVTT APIs Used
| API | Adapter Method | Risk |
|---|---|---|
game.settings.register/get/set |
adapter.settings.* |
Low |
game.socket |
adapter.socket.* |
Low |
game.users |
adapter.users.* |
Low |
game.scenes.current |
adapter.scenes.current() |
Low |
ui.notifications |
adapter.notifications.* |
Low |
game.webrtc + RTCPeerConnection |
adapter.webrtc.getConnection() |
OQ-1 HIGH RISK |
Open Question OQ-1 (Blocking for WebRTC Track Disabling)
game.webrtc.getConnection(userId) — it is unknown whether FoundryVTT v14 exposes
RTCPeerConnection for programmatic track disabling. Investigation required before
Story 2. Fallback: CSS visibility: hidden on video element (confirmed available).
Development Workflow
npm Scripts
"scripts": {
"build": "lessc styles/scrying-pool.less dist/styles/scrying-pool.css",
"watch": "chokidar 'styles/**/*.less' -c 'lessc styles/scrying-pool.less dist/styles/scrying-pool.css'",
"typecheck": "tsc --noEmit",
"lint": "eslint src/ module.js",
"test": "vitest run",
"test:watch": "vitest",
"release": "node scripts/package.mjs"
}
Story 0 Acceptance Criteria (Scaffold Complete When)
module.jsonvalid and loads in FoundryVTT v14 without errorsnpm run buildproducesdist/styles/scrying-pool.css(confirmmodule.jsonreferencesdist/styles/scrying-pool.css)npm run watchrecompiles on anystyles/**/*.lesschange (incl. @imports)npm run typecheckpasses with zero errors on all src/ files- AC-5a:
npm run test -- tests/unit/contracts/passes all contract validator assertions (incl. negative fixture rejection paths) - AC-5b:
npm run test -- tests/unit/module-init.test.jspasses wiring order assertions npm run releaseproducesmodule.zipwith correct version frompackage.jsonnpm run lintpasses with zero errors (import boundaryno-restricted-pathsviolations counted as errors)- Gitea CI passes all gates on push (release-gate confirmation)
Architecture Validation Results
Coherence Validation ✅
Decision Compatibility:
All technology choices are compatible and conflict-free. Native ESM + LESS + chokidar + Vitest
is internally consistent. tsc --noEmit with checkJs+strict on JS files works correctly.
FoundryVTT v14 with ApplicationV2 matches all UI decisions. Socket reconciliation pattern
(intent in → authoritative echo out → PendingOp clear) is self-consistent.
Corrections applied during validation:
tsconfig.json moduleResolutionchanged from"bundler"to"node16"—"bundler"is for TypeScript bundler pipelines;"node16"is correct for pure ESM in Vitest.eslint-plugin-importadded to npm install command — required forno-restricted-pathsboundary enforcement.- CI gates updated to include
lintalongsidetypecheckandtest.
Pattern Consistency:
Naming conventions are internally consistent across all layers: scrying-pool. prefix for
world settings and socket events; BEM-lite CSS; _prefix for private methods; named exports
only; console.*('[ScryingPool]', ...) logging. No contradictions found across any section.
Structure Alignment:
After Party Mode fixes (Step 6 review), the project structure fully supports all architectural
decisions. Import boundaries are explicit for all 7 layers including notifications/ and
presets/. SocketHandler drain correctly wired to module.js (not self-registered). RoleRenderer
is reactive on updateUser. ScenePresetPanel placement resolved (embedded in DirectorsBoard).
Requirements Coverage Validation ✅
Functional Requirements Coverage — All 26 FRs:
| FR Group | FRs | Primary Components | Status |
|---|---|---|---|
| L1 Core Visibility Toggle | FR-1–8 | StateStore, SocketHandler, VisibilityManager, RosterStrip, RoleRenderer |
✅ |
| L2 Director's Board | FR-9–14 | DirectorsBoard, ParticipantCard, RoleRenderer |
✅ |
| L3 Scene-Aware Presets | FR-15–19 | ScenePresetManager, ScenePresetPanel |
✅ |
| Contextual Notifications | FR-20–22 | NotificationBus, PlayerStatusBadge |
✅ |
| Player Privacy Panel | FR-23–26 | PlayerPrivacyPanel (FR-23–25 runtime); module-settings (FR-26 GM world setting) |
✅ |
Non-Functional Requirements Coverage:
| NFR | Architecture Support | Status |
|---|---|---|
| State latency ≤500ms | Optimistic UI + authoritative echo; no blocking sync operations | ✅ |
| Director's Board ≤1s / 12 participants | Lazy construction; ApplicationV2 PARTS rendering | ✅ |
| Socket payload ≤4KB | Validated at send; 4095/4096 boundary tested in fixtures | ✅ |
| No blocking main render loop | Async state application throughout | ✅ |
| Graceful AV degradation | FoundryAdapter.webrtc feature-detected; null → CSS fallback silently |
✅ |
| Hook chaining / no naked overrides | Use Hooks.on() / Hooks.once() only; never assign Hooks._hooks[event] directly |
✅ |
| No external data transmission | No external deps; no analytics | ✅ |
| WCAG AA keyboard navigation | DirectorsBoard keyboard nav defined; _focus.less module-wide focus ring |
✅ |
CSS scoped to .scrying-pool |
ESLint-enforced; no global selectors | ✅ |
Open Questions Status:
| OQ | Status | Resolution |
|---|---|---|
| OQ-1 WebRTC track disabling (HIGH RISK) | Open — Story 2 blocker | CSS visibility: hidden fallback confirmed; spike before L1 finalized |
OQ-5 updateScene hook timing |
Deferred | Integration testing during FR-15/16 |
| OQ-6 Partial vs. unconditional preset apply | Deferred | Decision during FR-15/16 implementation |
Implementation Readiness Validation ✅
Decision Completeness:
All critical decisions are documented with specific versions. foundry-vtt-types SHA-pin is
intentionally unresolved at planning time (correct — SHA is captured at scaffold time).
Structure Completeness:
File tree is complete across all layers (50+ named files). Integration points fully mapped.
Hooks table includes updateUser for RoleRenderer reactivity. FR→component mapping covers
all 26 FRs. Three data flows documented (toggle, preset auto-apply, notification bus).
Pattern Completeness:
All 14 conflict points from Step 5 addressed. Error handling by layer, async discipline,
test patterns, CSS architecture, and process patterns are all documented. Import boundary
enforcement mechanism is specified (ESLint no-restricted-paths) and wired into CI.
Gap Analysis Results
Critical Gaps — Resolved Inline:
| # | Gap | Resolution |
|---|---|---|
| C1 | eslint-plugin-import missing from npm install |
Added to npm install command |
| C2 | CI gate description missing lint |
Updated to lint + typecheck + test |
Important Gaps — Resolved Inline:
| # | Gap | Resolution |
|---|---|---|
| I1 | tsconfig moduleResolution: "bundler" incorrect for pure ESM |
Changed to "node16" |
| I3 | Step 3 npm scripts missing lint |
Added "lint": "eslint src/ module.js" |
| I4 | vitest.config.js missing from Story 0 deliverables |
Added as deliverable #4 |
Nice-to-Have Gaps — Deferred (Not Blocking):
| # | Gap | Deferral Rationale |
|---|---|---|
| N1 | Bulk Show/Hide one-step undo (FR-12) architectural pattern | Implementer decision; undo stack is self-contained in DirectorsBoard |
| N2 | JSON import/export (FR-19) serialization boundary pattern | Standard JSON.parse/stringify; no cross-origin complexity |
Validation Issues Addressed
No architectural conflicts were found. All issues identified were additive gaps (missing tool dependencies, missing CI step, incorrect tsconfig value) resolved inline during validation without reopening any architectural decision.
The Party Mode review of Step 6 (Winston, Amelia, Sally) produced 7 improvements that were folded back into the document before this validation pass, which is why the coherence check is clean.
Architecture Completeness Checklist
Requirements Analysis
- Project context thoroughly analyzed
- Scale and complexity assessed
- Technical constraints identified
- Cross-cutting concerns mapped
Architectural Decisions
- Critical decisions documented with versions
- Technology stack fully specified
- Integration patterns defined
- Performance considerations addressed
Implementation Patterns
- Naming conventions established
- Structure patterns defined
- Communication patterns specified
- Process patterns documented
Project Structure
- Complete directory structure defined
- Component boundaries established
- Integration points mapped
- Requirements to structure mapping complete
Architecture Readiness Assessment
Overall Status: READY FOR IMPLEMENTATION
Confidence Level: High — all 16 checklist items confirmed; no Critical Gaps remain; both critical gaps found during validation were resolved inline without reopening decisions.
Key Strengths:
- Hard import boundary rule with ESLint enforcement — agents cannot drift without CI failure
- Constructor injection + FoundryAdapter DI hard rule — all testable modules are genuinely testable
- Optimistic UI + authoritative echo + PendingOp lifecycle — race conditions handled by design
- Progressive enhancement structure (L1 → L2 → L3) — minimum shippable increment is always FR-1–8
- Story 0 ACs are atomic, verifiable, and wired to CI gates
Areas for Future Enhancement:
- Primary-GM authority protocol (v2) — last-write-wins is correct for v1
- Schema migration tooling for world settings and scene flags (v2)
game.webrtctrack disabling (blocked on OQ-1 spike — investigate before Story 2)- Bulk Show/Hide undo stack pattern (implementer discretion in Story 3)
Implementation Handoff
AI Agent Guidelines:
- Follow all architectural decisions exactly as documented
- Use implementation patterns consistently across all components
- Respect project structure and boundaries (ESLint enforces them)
- Refer to this document for all architectural questions
- Begin with Story 0 scaffold — no feature code before all ACs are green
First Implementation Priority:
Story 0 scaffold — module.json + tsconfig.json + vitest.config.js + .eslintrc.js +
scripts/package.mjs + src/contracts/ (with validators + frozen fixtures) +
.gitea/workflows/ci.yml — all Story 0 ACs green before any Story 1 code.
Feature Addendum (2026-05-27): Re-order Participants
Problem
Strip participant order is fixed to user connection order. GM cannot rearrange participants to match table seating, spotlight priority, or personal preference.
Decision: Drag-and-drop via native HTML5 API
| Decision | Choice | Rationale |
|---|---|---|
| Mechanism | HTML5 Drag & Drop (draggable, dragstart/dragover/drop events) |
Zero dependencies; works in Foundry's embedded Chromium; no external lib needed |
| Persistence | User flag: game.user.setFlag('scrying-pool', 'participantOrder', string[]) |
Per-GM order; does not affect other GMs or players |
| Storage format | Ordered array of user IDs | Minimal, sortable, forward-compatible |
| Visual feedback | opacity: 0.3 on dragged tile + box-shadow drop indicator on target gap |
Lightweight; no layout shift |
| Reset | Double-click the grip area → reset to connection order | Escape hatch if order gets confusing |
| Scope | Strip only (not Director's Board) | Strip is the primary real-estate; DB reorder adds complexity with no clear need |
Implementation Notes
ScryingPoolStripgets_onDragStart,_onDragOver,_onDrop,_onDragEndhandlers- Attached in
_onRenderviael.addEventListener - On drop: reorder participant list in
_prepareContext, persist to user flag _prepareContextreads flag, applies order before filtering hidden participants- No socket broadcast — order is per-GM/local-only
Feature Addendum (2026-05-27): Spotlight / Focus
Problem
In all layouts, participants share equal visual weight. GM cannot temporarily focus on one participant's video feed (e.g., a player speaking, a dramatic reveal).
Decision: One-tile-expand mode within the strip
| Decision | Choice | Rationale |
|---|---|---|
| Activation | Click on participant avatar while holding Shift (or context menu → "Focus") |
Intentional action; avoids accidental triggers |
| Visual | Selected tile expands to fill strip content area; other tiles collapse to height: 0; overflow: hidden (preserved in DOM for instant restore) |
No layout reflow on restore; preserved DOM state |
| Restore | Click Shift+click again, or click an "Exit focus" button that replaces the toolbar, or press Escape |
Multiple escape hatches |
| State | In-memory only: `_focusedUserId: string | nullonScryingPoolStrip` |
| Indicator | Focused tile gets .sp-state-focused class → gold state ring (--sp-urgency-director) |
Visual consistency with existing state ring system |
| Strip sizing | setPosition recomputed with 1 participant during focus |
Window snaps to single-tile dimensions |
| Layout compatibility | Works in all layouts (vertical, horizontal, mosaic) | Tile fills available space via same CSS that handles single-participant edge case |
| Director's Board | Unaffected — spotlight is strip-only | DB maintains overview while strip focuses |
Implementation Notes
_focusedUserIdfield onScryingPoolStrip_prepareContextfilters or transforms participant list: if_focusedUserIdset, only that participant is visible; others havehidden: true(but preserved in DOM)- Restore clears
_focusedUserId→ re-render → full list visible - CSS:
.sp-participant-avatar.sp-state-focusedinherits existing state ring pattern (green → gold via--sp-urgency-director) - No socket broadcast — purely local UI state
Feature Addendum (2026-05-27): Auto Position Snapshots
Problem
Strip position is saved only on close. If the browser window is resized, display changed, or the strip is accidentally dragged off-screen, there is no way to restore a known-good position without relaunching.
Decision: Periodic auto-save + explicit save/restore
| Decision | Choice | Rationale |
|---|---|---|
| Auto-save trigger | debounced save on mouseup after drag ends |
Saves only when user finishes moving; no save storm during drag |
| Auto-save interval | Also every 30s via setInterval while strip is open |
Safety net if drag event fails to fire |
| Storage | User flag: game.user.setFlag('scrying-pool', 'stripState') |
Already partially used (saves on close); extend to include timestamp |
| Save payload | { left, top, width, height, savedAt } |
Position + dimensions + timestamp for diagnostics |
| Restore trigger | On _onRender — if saved position exists and strip has no explicit position yet |
First render gets saved position |
| Reset | Director's Board button "Reset strip position" → clears flag + re-renders at default position | Manual escape hatch |
| Multi-monitor safety | Validate saved.left and saved.top are within available viewport (window.screen.availWidth/Height) before applying |
Prevents strip from loading off-screen after monitor config change |
Implementation Notes
- Extend existing
_loadPosition()inScryingPoolStrip— already readsstripStateflag - Add
_savePosition()called onmouseupafter drag + every 30s interval - Viewport validation:
saved.left < screen.availWidth - 50 && saved.top < screen.availHeight - 50 - On validation failure: silently fall back to default position (no error notification)
- Director's Board: add "Reset strip position" button (minor template change)
- Extends existing
stripStateflag — no new flags needed, backward-compatible with old saves