Files
uberwald 9e80c2c028 Add re-order, spotlight/focus, and auto-position-snapshots features
- 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
2026-05-27 11:44:24 +02:00

57 KiB
Raw Permalink Blame History

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
1
2
3
4
5
6
7
8
6
party-mode
architecture 8 complete 2026-05-20
_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
architecture video-view-manager Morr 2026-05-27 2026-05-27
reorder-participants
spotlight-focus
auto-position-snapshots

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-18): 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-914): ApplicationV2 floating 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-1519): 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-18 is the minimum shippable increment and Level 3 can shift to v1.1 without breaking the architecture — PRD §Cross-Cutting NFRs.)
  • Contextual Notifications (FR-2022): 3-tier coalesced notification bus above ui.notifications, role-differentiated verbosity, persistent self-status badge on own tile.
  • Player Privacy Panel (FR-2326): 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.webrtc is 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-pool with 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: 68 (VisibilityManager, SocketHandler, StateStore, DirectorsBoard, NotificationBus, ScenePresetManager, PlayerPrivacyPanel, AudienceView/RoleRenderer)

Technical Constraints & Dependencies

  • FoundryVTT v14 API onlyApplicationV2 for floating windows, game.settings for world/client persistence, native socket API for broadcast, Hooks system for lifecycle integration, game.webrtc / AVMaster for 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; contextmenu on <video> may surface browser native media menu)
  • OQ-1 (architectural risk #1): WebRTC track disabling API availability on v14 — if game.webrtc does not expose RTCPeerConnection at sufficient depth, privacy/bandwidth semantics change and CSS fallback becomes the permanent path; must be spiked before Level 1 is finalized
  • OQ-5 (open): updateScene hook 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

  1. 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)

  2. 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.

  3. 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.

  4. World vs. client persistence boundary — Visibility Matrix and Presets in world settings; notification verbosity in client settings; firstBadgeEncounter in localStorage pending GDPR decision (see below).

  5. GDPR / consent boundary (hard decision required)firstBadgeEncounter storage (User flag = 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-2326 design. Default recommendation: localStorage for v1.0, documented as v2 upgrade path. (Party Mode: Amelia)

  6. Hook chaining discipline — All Hooks.on() registrations must chain upstream handlers; singleton guards on keyboard shortcut registration and DirectorsBoard window instance.

  7. Graceful AV degradation — All module surfaces must check game.webrtc availability and disable/hide cleanly; never throw on a missing AV stack.

  8. Notification coalescingMap<participantId, {timer, lastState}> coalescing layer is a first-class architectural component above ui.notifications; must not be bolted onto individual state mutations after the fact.

  9. 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 68 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: chokidar is scoped to LESS watch only — less --watch does not detect changes in @import-ed partials. chokidar triggers lessc on any styles/**/*.less change.

Note: foundry-vtt-types MUST 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 --noEmit passes 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.js entry-point orchestrator; explicit constructor injection (Option A)
  • FoundryAdapter surface contract; game.webrtc feature-detected; hooks mediated
  • init/ready split with SocketHandler queue/drain bridging the seam
  • PendingOp DTO + authoritative echo reconciliation; 3s timeout is fallback only
  • Release via Gitea publish script; package.json is version source of truth

Important (Shape Architecture):

  • Privacy consent: world setting, GM-controlled (trust mode)
  • firstBadgeEncounter: user flag (game.user.setFlag), not world setting
  • game.webrtc unavailable → 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.webrtc track 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 + _version wrapper
  • socket-message.js — intent and echo payload schemas
  • pending-op.js — PendingOp DTO
  • scene-preset.js — preset shape + _version wrapper

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:

  1. Story 0: scaffold + module.json + tsconfig.json + .eslintrc.js + scripts/package.mjs + src/contracts/ + socket fixtures + .gitea/workflows/
  2. Story 1: FoundryAdapter + world setting registration + StateStore + SocketHandler (with queue/drain)
  3. Story 2: VisibilityManager + RoleRenderer + RosterStrip (L1 core)
  4. Story 3: NotificationBus + DirectorsBoard (L2)
  5. Story 4: ScenePresetManager (L3)

Cross-Component Dependencies:

  • All testable modules depend on FoundryAdapter interface, not implementation
  • SocketHandler queue bridges the initready lifecycle seam
  • RoleRenderer depends on VisibilityManager + webrtc capability contract
  • DirectorsBoard depends on VisibilityManager + SocketHandler
  • ScenePresetManager depends on StateStore + 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.jsStateStore.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_CASEVISIBILITY_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

  1. Apply state + add sp-state-pending CSS class + register PendingOp
  2. Authoritative echo received → remove sp-state-pending, confirm state, clear PendingOp
  3. 3s timeout → revert to previousState, remove sp-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, never export default)
  • src/core/ imports: src/contracts/ and src/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 null not undefined from public APIs
  • Every sp-state-* must signal: color + icon + shape
  • async/await not .then()

Project Structure & Boundaries

Requirements → Component Mapping

FR Group FRs Primary Files
L1 Core Visibility Toggle FR-18 StateStore, SocketHandler, VisibilityManager, RosterStrip, RoleRenderer
L2 Director's Board FR-914 DirectorsBoard, ParticipantCard, RoleRenderer
L3 Scene-Aware Presets FR-1519 ScenePresetManager, ScenePresetPanel
Contextual Notifications FR-2022 NotificationBus, PlayerStatusBadge
Player Privacy Panel FR-2326 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-2325: 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.json valid and loads in FoundryVTT v14 without errors
  • npm run build produces dist/styles/scrying-pool.css (confirm module.json references dist/styles/scrying-pool.css)
  • npm run watch recompiles on any styles/**/*.less change (incl. @imports)
  • npm run typecheck passes 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.js passes wiring order assertions
  • npm run release produces module.zip with correct version from package.json
  • npm run lint passes with zero errors (import boundary no-restricted-paths violations 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 moduleResolution changed from "bundler" to "node16""bundler" is for TypeScript bundler pipelines; "node16" is correct for pure ESM in Vitest.
  • eslint-plugin-import added to npm install command — required for no-restricted-paths boundary enforcement.
  • CI gates updated to include lint alongside typecheck and test.

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-18 StateStore, SocketHandler, VisibilityManager, RosterStrip, RoleRenderer
L2 Director's Board FR-914 DirectorsBoard, ParticipantCard, RoleRenderer
L3 Scene-Aware Presets FR-1519 ScenePresetManager, ScenePresetPanel
Contextual Notifications FR-2022 NotificationBus, PlayerStatusBadge
Player Privacy Panel FR-2326 PlayerPrivacyPanel (FR-2325 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-18
  • 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.webrtc track 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

  • ScryingPoolStrip gets _onDragStart, _onDragOver, _onDrop, _onDragEnd handlers
  • Attached in _onRender via el.addEventListener
  • On drop: reorder participant list in _prepareContext, persist to user flag
  • _prepareContext reads 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

  • _focusedUserId field on ScryingPoolStrip
  • _prepareContext filters or transforms participant list: if _focusedUserId set, only that participant is visible; others have hidden: true (but preserved in DOM)
  • Restore clears _focusedUserId → re-render → full list visible
  • CSS: .sp-participant-avatar.sp-state-focused inherits 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() in ScryingPoolStrip — already reads stripState flag
  • Add _savePosition() called on mouseup after 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 stripState flag — no new flags needed, backward-compatible with old saves