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

1094 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-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 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-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 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 68 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-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
```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-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**
- [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-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.