1094 lines
52 KiB
Markdown
1094 lines
52 KiB
Markdown
---
|
||
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-20'
|
||
---
|
||
|
||
# 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):** `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-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.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: 6–8 (`VisibilityManager`, `SocketHandler`, `StateStore`, `DirectorsBoard`, `NotificationBus`, `ScenePresetManager`, `PlayerPrivacyPanel`, `AudienceView/RoleRenderer`)
|
||
|
||
---
|
||
|
||
### Technical Constraints & Dependencies
|
||
|
||
- **FoundryVTT v14 API only** — `ApplicationV2` 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-23–26 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 coalescing** — `Map<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 6–8 modules without shipping TypeScript.
|
||
|
||
**Initialization:**
|
||
```bash
|
||
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)
|
||
|
||
```json
|
||
"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
|
||
|
||
```json
|
||
{
|
||
"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 (`@import`s 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:**
|
||
```js
|
||
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 `init`→`ready` 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.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**
|
||
```js
|
||
// ✅ 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:
|
||
```js
|
||
/** @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
|
||
```js
|
||
// ✅ 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
|
||
```js
|
||
// ✅ 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
|
||
```js
|
||
// 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`:
|
||
```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.
|
||
```less
|
||
@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-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
|
||
```json
|
||
"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-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**
|
||
- [x] Project context thoroughly analyzed
|
||
- [x] Scale and complexity assessed
|
||
- [x] Technical constraints identified
|
||
- [x] Cross-cutting concerns mapped
|
||
|
||
**Architectural Decisions**
|
||
- [x] Critical decisions documented with versions
|
||
- [x] Technology stack fully specified
|
||
- [x] Integration patterns defined
|
||
- [x] Performance considerations addressed
|
||
|
||
**Implementation Patterns**
|
||
- [x] Naming conventions established
|
||
- [x] Structure patterns defined
|
||
- [x] Communication patterns specified
|
||
- [x] Process patterns documented
|
||
|
||
**Project Structure**
|
||
- [x] Complete directory structure defined
|
||
- [x] Component boundaries established
|
||
- [x] Integration points mapped
|
||
- [x] 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.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.
|