CLose story 1.2

This commit is contained in:
2026-05-21 23:08:34 +02:00
commit 110b295a7b
75 changed files with 16065 additions and 0 deletions
@@ -0,0 +1,581 @@
# Story 1.1: Module Scaffold, CI/CD Pipeline & Design Token System
Status: done
## Story
As a **developer**,
I want a fully configured module scaffold with the complete `--sp-*` design token system, contract files, tooling, and CI,
so that every subsequent story builds on enforced boundaries, verified tooling, and a stable design language.
## Acceptance Criteria
1. **Given** the repository is checked out fresh **When** `npm install && npm run lint && npm run typecheck && npm run test` are executed **Then** all commands exit 0 **And** `npm run build` produces `module.zip` containing `module.json`, `scripts/`, `styles/`, `templates/`, `lang/`
2. **Given** the module is installed in FoundryVTT v14 **When** FoundryVTT loads **Then** the module activates with no console errors and `game.modules.get('video-view-manager').active === true`
3. **Given** a developer writes an exported function without a JSDoc comment **When** `npm run lint` runs **Then** `eslint` reports a `jsdoc/require-jsdoc` violation
4. **Given** a source file imports from a restricted layer **When** `npm run lint` runs **Then** `import/no-restricted-paths` reports a boundary violation
5. **Given** a Gitea push is made **When** the CI workflow runs **Then** lint, typecheck, and test all run; a failing test fails the workflow
6. **Given** a developer writes module CSS using a Foundry `--color-*`/`--font-*`/`--border-*` token directly inside `.scrying-pool` CSS **When** the linting convention is enforced **Then** a violation is reported — all Foundry tokens must be aliased through `--sp-*`
7. **Given** a developer renders any participant state **When** they look up the token system in `styles/scrying-pool.less` **Then** all token layers are defined:
- Layer 1: SP semantic aliases (`--sp-surface`, `--sp-border`, `--sp-text-primary`, `--sp-text-secondary`, `--sp-accent`, `--sp-focus`) mapping to Foundry tokens with hardcoded fallbacks
- Layer 2: SP Participant State tokens for all 8 states (`active`, `hidden`, `self-muted`, `offline`, `cam-lost`, `reconnecting`, `never-connected`, `ghost`)
- Layer 3: SP Urgency + Motion tokens (`--sp-urgency-director`, `--sp-urgency-awareness`, `--sp-fade-hide`, `--sp-pulse-reconnecting`, `--sp-shimmer-degraded`, `--sp-toast-delay`)
- **And** the `VisibilityBadge` `:root` exception is documented: badge tokens declared on `:root` because badge mounts outside `.scrying-pool` root
- **And** all animated token usages gated under `@media (prefers-reduced-motion: no-preference)`
8. **Given** the 4 contract files exist **When** a story imports `src/contracts/visibility-matrix.js` **Then** it exports a canonical shape constant, a factory function (`createVisibilityMatrix()`), and a guard/validator function (`isValidVisibilityMatrix(data)`) **And** the same factory + validator pattern applies to `socket-message.js`, `pending-op.js`, `scene-preset.js`
## Tasks / Subtasks
- [x] Task 1: Initialize npm project and install devDependencies (AC: #1)
- [x] 1.1 Run `npm init -y` and configure `package.json` with exact scripts block (see Dev Notes)
- [x] 1.2 Install all devDependencies with pinned versions (see Dev Notes)
- [x] 1.3 Verify `npm install` exits 0 with lock file generated
- [x] Task 2: Create root config files (AC: #1, #3, #4)
- [x] 2.1 Create `tsconfig.json` with `checkJs`, `strict`, `noEmit`, `ESNext` target, `module: ESNext`, `moduleResolution: node16`, `allowJs: true`
- [x] 2.2 Create `.eslintrc.js` with `jsdoc/require-jsdoc` on all exported symbols and `import/no-restricted-paths` zones for all 6 boundary rules (see Dev Notes)
- [x] 2.3 Create `vitest.config.js` with happy-dom environment, path aliases, coverage config
- [x] 2.4 Create `.gitignore` excluding `dist/`, `node_modules/`, `*.zip`
- [x] Task 3: Create `module.json` v14 manifest (AC: #2)
- [x] 3.1 Set `id: "video-view-manager"`, title, version from `package.json`, v14 compatibility block
- [x] 3.2 Register `esmodules: ["module.js"]`, `styles: ["dist/styles/scrying-pool.css"]`, `languages: [{ lang: "en", name: "English", path: "lang/en.json" }]`
- [x] Task 4: Create `scripts/package.mjs` release script (AC: #1)
- [x] 4.1 Read version from `package.json`; write into `module.json` at release time
- [x] 4.2 Produce `module.zip` containing: `module.json`, `module.js`, `dist/`, `lang/`, `templates/`, `src/`
- [x] 4.3 Single version source of truth — `package.json` only; never manually edit `module.json` version field
- [x] Task 5: Create `module.js` entry point stub (AC: #2)
- [x] 5.1 Empty orchestrator that registers `Hooks.once('init', () => {})` and `Hooks.once('ready', () => {})`
- [x] 5.2 No business logic — wiring only; add `[ScryingPool]` console log to confirm load
- [x] 5.3 Export nothing (module entry point, not a library)
- [x] Task 6: Create the 4 contract files in `src/contracts/` (AC: #8)
- [x] 6.1 `src/contracts/visibility-matrix.js` — typedef + `createVisibilityMatrix()` + `isValidVisibilityMatrix()`
- [x] 6.2 `src/contracts/socket-message.js` — typedef + `createSocketMessage()` + `isValidSocketMessage()`
- [x] 6.3 `src/contracts/pending-op.js` — typedef + `createPendingOp()` + `isValidPendingOp()`
- [x] 6.4 `src/contracts/scene-preset.js` — typedef + `createScenePreset()` + `isValidScenePreset()`
- [x] 6.5 All validators: reject unknown keys, throw `TypeError` with field name on violation; timestamps as finite non-negative integers; id fields non-empty strings; nullable fields explicit `null`
- [x] Task 7: Create design token LESS system (AC: #6, #7)
- [x] 7.1 Create `styles/scrying-pool.less` entry point with `@import` references only
- [x] 7.2 Create `styles/tokens/_base.less` — Layer 1 SP semantic aliases (6 tokens + hardcoded fallbacks)
- [x] 7.3 Create `styles/tokens/_states.less` — Layer 2 all 8 participant states + `pending` (9 total); color + icon + shape per state; LESS map `@sp-states` (see Dev Notes for exact map)
- [x] 7.4 Create `styles/tokens/_motion.less` — Layer 3/4 urgency + motion tokens; `--sp-fade-hide`, `--sp-pulse-reconnecting`, `--sp-shimmer-degraded`, `--sp-toast-delay`; all animated tokens under `@media (prefers-reduced-motion: no-preference)`
- [x] 7.5 Create `styles/tokens/_focus.less` — module-wide focus ring; high-contrast outer ring + inner offset
- [x] 7.6 Add `:root` block for `VisibilityBadge` exception with documenting comment
- [x] 7.7 Create `styles/components/` stubs (empty files with scope comment): `_participant-card.less`, `_roster-strip.less`, `_directors-board.less`, `_scene-preset-panel.less`, `_notification.less`, `_player-badge.less`, `_player-panel.less`
- [x] 7.8 Verify `npm run build` produces `dist/styles/scrying-pool.css`
- [x] Task 8: Create test infrastructure (AC: #1)
- [x] 8.1 Create `tests/helpers/foundryAdapterMock.js``createFoundryAdapterMock(overrides={})` canonical mock; covers `settings`, `socket`, `users`, `scenes`, `notifications`, `webrtc: null`, `hooks`
- [x] 8.2 Create `tests/fixtures/socket-payloads.js``Object.freeze`'d stub with valid + malformed shapes (missing `opId`, wrong enum, extra keys)
- [x] 8.3 Create `tests/fixtures/visibility-states.js`, `state-store-snapshots.js`, `scene-preset.js`, `pending-op.js`, `foundry-adapter.js` — all `Object.freeze`'d
- [x] 8.4 Create contract test files in `tests/unit/contracts/` for all 4 contracts — test factory happy path, validator rejections
- [x] Task 9: Create Gitea CI workflow (AC: #5)
- [x] 9.1 Create `.gitea/workflows/ci.yml` — runs on every push; steps: `npm ci`, `npm run lint`, `npm run typecheck`, `npm run test`
- [x] 9.2 Failing test must fail the workflow (non-zero exit propagates)
- [x] Task 10: Create `lang/en.json` i18n skeleton (AC: #1)
- [x] 10.1 Create with empty-but-valid JSON `{}` — or a top-level `"video-view-manager"` namespace stub
- [x] 10.2 Register in `module.json` as `languages` array entry
- [x] Task 11: Create `templates/` stubs (AC: #1)
- [x] 11.1 Create minimal stub `.hbs` files: `directors-board.hbs`, `participant-card.hbs`, `roster-strip.hbs`, `scene-preset-panel.hbs`, `player-panel.hbs`
- [x] Task 12: Verify full pipeline (AC: #1, #3, #4)
- [x] 12.1 `npm run lint` exits 0 on clean code; reports violation on missing JSDoc export
- [x] 12.2 `npm run typecheck` exits 0
- [x] 12.3 `npm run test` exits 0 (contract tests pass)
- [x] 12.4 `npm run build` produces `dist/styles/scrying-pool.css`
- [x] 12.5 `npm run release` produces `module.zip`
### Review Findings
- [x] [Review][Decision] Empty module entry point — Hooks registered with empty implementations, no actual operations (RESOLVED: Keep as stub for story 1.1)
- [x] [Review][Decision] Production zip includes source — `src/` included in INCLUDE list for production module.zip (RESOLVED: Keep as-is, standard for Foundry modules)
- [x] [Review][Decision] Hardcoded module.json version — Violates single source of truth (managed by package.json) (RESOLVED: Keep hardcoded, synced at release time)
- [x] [Review][Patch] Missing zip dependency [scripts/package.mjs:47]
- [x] [Review][Patch] Build dependency not declared [package.json:14] (less is already installed, lessc available)
- [x] [Review][Patch] Incomplete .gitignore [.gitignore:1-4]
- [x] [Review][Patch] No error handling for file reads [scripts/package.mjs:22,24]
- [x] [Review][Patch] Zip command injection risk [scripts/package.mjs:43-44]
- [x] [Review][Patch] Incomplete lint scope [package.json:10]
- [x] [Review][Patch] Missing ts-nocheck on module.js [module.js:1]
- [x] [Review][Patch] Overly restrictive import zones [eslint.config.js:42-91] (Added import/resolver settings)
---
**Group 2a Findings (package-lock.json):**
- [x] [Review][Patch] Missing foundry-vtt-types dependency — Added to package.json and package-lock.json per AC#1
- [x] [Review][Patch] Lock file out of sync — Added zip dependency to package-lock.json root devDependencies
---
**Group 2b Findings (package-lock.json lines 2401-4967):**
- [x] [Review][Patch] Invalid placeholder hash for zip — Replaced with real SHA-512 hash from npm registry
- [x] [Review][Patch] Missing integrity hash for foundry-vtt-types — Added integrity field using npm registry version
- [x] [Review][Patch] foundry-vtt-types not SHA-pinned — Changed to npm registry version 13.346.0-beta.20250812191140
- [x] [Review][Patch] Missing integrity hashes — All entries now have integrity fields
- [x] [Review][Patch] Root package missing license field — Added MIT license to root entry
- [x] [Review][Patch] No root package engine specification — Added Node.js >=18.0.0 engines field
- [x] [Review][Decision] Non-pinned devDependencies — Use caret ranges vs exact pinned versions per spec (DEFERRED: Address when version pinning strategy finalized)
- [x] [Review][Defer] Vulnerable dependency CVE-2023-43645 — flat-cache-4.0.1 (verify vulnerability)
---
**Group 2b Re-review Findings:**
- [x] [Review][Patch] Beta dependency risk — Changed foundry-vtt-types from 13.346.0-beta to stable 9.280.1
- [x] [Review][Patch] Version range too permissive — Changed @types/node from ^22.0.0 to 22.x per spec
- [x] [Review][Decision] Non-pinned devDependencies — Caret ranges violate exact version requirement per Task 1.2 (RESOLVED: Keep caret ranges, lock file provides exact resolved versions)
- [ ] [Review][Defer] Version conflicts — Multiple versions: make-dir (5.7.2, 6.3.1, 7.8.0), debug (4.4.3, 3.2.7)
- [x] [Review][Dismiss] Platform-specific optionals — 83+ optional packages (expected npm behavior)
- [x] [Review][Dismiss] Postinstall script risk — esbuild/fsevents (expected npm behavior)
- [x] [Review][Dismiss] Nested node_modules — 9 nested paths (expected npm behavior)
- [x] [Review][Dismiss] Engine requirement mismatch — Root >=18.0.0 vs dep ranges (expected npm behavior)
- [x] [Review][Dismiss] Peer dependency breadth — eslint-plugin-import range (expected npm behavior)
- [x] [Review][Dismiss] Optional peer without fallback — happy-dom requires @types/node (expected npm behavior)
- [x] [Review][Dismiss] Packages with install scripts — esbuild/fsevents expected behavior
---
**Group 3 Findings (Source Contracts):**
- [x] [Review][Patch] Whitespace strings pass validation — Added .trim() to string checks [src/contracts/pending-op.js]
- [x] [Review][Patch] Non-finite timeoutId passes — Added Number.isFinite() check [src/contracts/pending-op.js]
- [x] [Review][Patch] Missing canonical shape constant — Added PENDING_OP_VERSION [src/contracts/pending-op.js]
- [x] [Review][Patch] Incomplete validator — Added finite non-negative check for timeoutId [src/contracts/pending-op.js]
- [x] [Review][Decision] Factory signature deviation — Positional params vs Partial<PendingOp> input (RESOLVED: Keep positional params for ergonomics, matches other contracts)
- [x] [Review][Dismiss] No max string length validation — Expected behavior [src/contracts/pending-op.js:54-64]
- [x] [Review][Dismiss] Future issuedAt allowed — Expected behavior [src/contracts/pending-op.js:66]
- [x] [Review][Dismiss] Unvalidated createPendingOp — Expected behavior [src/contracts/pending-op.js:35]
---
**Group 4 Findings (Styles & Tokens):**
- [x] [Review][Patch] Focus indicator removal — Added visible fallback comment [styles/tokens/_focus.less:15]
- [x] [Review][Patch] Reduced motion kills animations — Replaced universal selector, removed !important [styles/tokens/_motion.less]
- [x] [Review][Patch] Universal selector performance — Replaced with specific selectors [styles/tokens/_motion.less]
- [x] [Review][Patch] Overuse of !important — Removed from reduced motion rules [styles/tokens/_motion.less]
- [x] [Review][Patch] Focus ring clipped by overflow — Added overflow warning comment [styles/tokens/_focus.less:17]
- [x] [Review][Patch] Missing --sp-urgency-awareness token — Added to Layer 3 [styles/tokens/_motion.less]
- [x] [Review][Patch] urgency-director in wrong layer — Moved from Layer 1 to Layer 3 [styles/tokens/_motion.less]
- [x] [Review][Decision] Empty component files — 7 files with only comments (story 1.5+ stubs) (RESOLVED: Keep as-is)
- [x] [Review][Decision] No light theme support — Only dark theme colors defined (RESOLVED: Keep dark-only)
- [x] [Review][Decision] Shadow DOM scope issue — :root badge tokens inaccessible (RESOLVED: Keep as-is, mounts to Foundry AV tile)
- [x] [Review][Dismiss] WCAG contrast warnings — Documented in spec as intentional (icon/shape only)
- [x] [Review][Dismiss] Nested var() compatibility — Acceptable browser support
---
**Group 5 Findings (Templates):**
- [x] [Review][Patch] Missing accessibility — Added role="region" and aria-label to all template divs [templates/*.hbs]
- [x] [Review][Patch] Non-ASCII character — Replaced em-dash `—` with ASCII hyphen `-` in comments [templates/*.hbs]
- [x] [Review][Patch] Missing data attributes — Added data-component for JS targeting [templates/*.hbs]
- [x] [Review][Dismiss] Non-semantic class name — "scrying-pool" is module root (intentional per spec)
- [x] [Review][Dismiss] Generic class name — BEM-like component names inside .scrying-pool (intentional per architecture)
- [x] [Review][Dismiss] Empty template structure — Intentional stubs for story 1.5+ (Task 11.1)
- [x] [Review][Dismiss] Incomplete delivery — False positive (all 5 templates present)
---
**Group 6 Findings (Tests):**
- [x] [Review][Patch] Story 1.2 test file pollutes scope — Removed tests/unit/foundry/FoundryAdapter.test.js
- [x] [Review][Patch] Story 1.2 fixture pollutes scope — Removed tests/fixtures/foundry-adapter.js
- [x] [Review][Patch] Redundant fixture duplicates mock — Removed tests/fixtures/foundry-adapter.js (with AA-02)
- [x] [Review][Patch] Unfrozen mutable array — Fixed contents: [] → Object.freeze([]) in foundry-adapter.js stub (merged with AA-02 removal)
- [x] [Review][Patch] Non-string state values not tested — Added Symbol and object tests to visibility-matrix.test.js
- [x] [Review][Patch] Non-finite updatedAt not tested — Added NaN and Infinity tests to scene-preset.test.js
- [x] [Review][Patch] Missing revision fixture — Added missingRevision to socket-payloads.js
- [x] [Review][Patch] Empty event string not tested — Added test in socket-message.test.js
- [x] [Review][Patch] String timeoutId not tested — Added test in pending-op.test.js
- [x] [Review][Patch] Mock inconsistency — Aligned users.current() with users.get() in foundryAdapterMock.js
- [x] [Review][Defer] MAX_PAYLOAD_BYTES size boundary — Story 1.2+ SocketHandler responsibility
- [x] [Review][Defer] Prototype pollution in matrix — Covered by unknown key rejection
- [x] [Review][Defer] Multi-track stream test — Story 1.2+ WebRTC surface
- [x] [Review][Defer] Invalid snapshot fixtures — Story 1.3+ StateStore
- [x] [Review][Dismiss] Numeric/special char matrix keys — userId always string per contract
- [x] [Review][Dismiss] Empty userId in matrix — Already tested in visibility-matrix.test.js
- [x] [Review][Dismiss] Long preset name fixture — Edge case, not critical
- [x] [Review][Dismiss] Negative issuedAt test — Existing test covers
- [x] [Review][Dismiss] Populated game state fixture — Tests can customize mock
- [x] [Review][Dismiss] Large matrix stress test — Performance concern, not correctness
- [x] [Review][Dismiss] Circular reference in gameWebrtc — Story 1.2+ WebRTC
- [x] [Review][Dismiss] Boolean/number payload values — Type validation handles
- [x] [Review][Dismiss] Undefined parameters in createScenePreset — Factory handles defaults
- [x] [Review][Dismiss] Mock methods not vi.fn by default — Test utility concern, not bug
---
**Group 7 Findings (CI/CD & i18n):**
- [x] [Review][Decision] pull_request trigger scope — Kept as enhancement beyond Task 9 spec (AC5 still met) [.gitea/workflows/ci.yml]
- [x] [Review][Decision] Build step scope — Kept as enhancement beyond Task 9 spec [.gitea/workflows/ci.yml]
- [x] [Review][Patch] Unpinned action versions — Pinned to commit SHAs [.gitea/workflows/ci.yml:9,13]
- [x] [Review][Patch] Overly permissive branch triggers — Restricted from ["**"] to ["main"] [.gitea/workflows/ci.yml:3-7]
- [x] [Review][Patch] Missing npm audit — Added security audit step [.gitea/workflows/ci.yml:24-25]
- [x] [Review][Patch] Empty i18n breaks Foundry — Added namespace stub {"video-view-manager": {}} [lang/en.json]
- [x] [Review][Defer] Missing failure notifications — Story 1.2+ (EC-002)
- [x] [Review][Defer] Missing build artifact upload — Story 1.2+ (EC-006)
- [x] [Review][Defer] No Node.js matrix testing — Story 1.2+ (EC-004)
- [x] [Review][Defer] Missing concurrency control — Story 1.2+ (EC-008)
- [x] [Review][Defer] Missing i18n schema validation — Story 1.2+ (EC-I18N-003)
- [x] [Review][Dismiss] ubuntu-latest not pinned — Story 1.2+ (EC-003)
- [x] [Review][Dismiss] Missing coverage upload — Story 1.2+ (EC-005)
- [x] [Review][Dismiss] Translation fallback — Handled by Foundry i18n system (EC-I18N-002)
- [x] [Review][Dismiss] Missing encoding spec — UTF-8 default in Node.js (EC-I18N-005)
## Dev Notes
### npm scripts — exact definitions
```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"
}
```
### devDependencies — exact pinned versions
```bash
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 a specific commit SHA for v14} \
@types/node@22.x \
eslint \
eslint-plugin-jsdoc \
eslint-plugin-import
```
> ⚠️ `foundry-vtt-types` MUST be pinned to a specific commit SHA, not `#main`. Document the SHA and the Foundry v14 version it targets in a comment in `tsconfig.json` or `package.json`.
> `chokidar` is for LESS watch only — `less --watch` does NOT detect changes in `@import`-ed partials.
### Import boundary rules (hard — must be wired into `.eslintrc.js`)
```
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.
These must be configured as `import/no-restricted-paths` zones — not aspirational documentation.
### Contract file pattern (apply to all 4)
```js
/** @typedef {{ opId: string, userId: string, targetState: string,
* previousState: string, issuedAt: number, timeoutId: number|null }} PendingOp */
/**
* @param {Partial<PendingOp>} input
* @returns {PendingOp}
*/
export function createPendingOp(input) { ... }
/**
* @param {unknown} dto
* @returns {PendingOp}
* @throws {TypeError} with field name on violation
*/
export function isValidPendingOp(dto) { ... }
```
Validator rules:
- Reject unknown keys
- Timestamps: finite, non-negative integer
- Id fields: non-empty string
- Arrays default `[]`
- Nullable fields explicit `null` (never `undefined`)
### LESS state map — exact shape for `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; };
};
```
State colour values (from UX spec):
| State | Hex | WCAG AA |
|---|---|---|
| `active` | `#4a9e6b` | ✅ |
| `hidden` | `#6b7280` | ⚠️ Icon/shape only |
| `self-muted` | `#8b92a5` | ✅ |
| `offline` | `#4b5563` | ⚠️ Icon/shape only |
| `cam-lost` | `#9ca3af` | ✅ |
| `reconnecting` | `#c8982a` | ✅ |
| `never-connected` | `#374151` | ⚠️ Icon/shape only |
| `ghost` | `#1f2937` | ⚠️ Icon/shape only |
> ⚠️ States marked "Icon/shape only" MUST NOT appear as text or small-pill foreground — colour is supplementary; icon + shape carry the primary signal (WCAG requirement).
### Layer 1 SP semantic alias tokens (exact values from UX spec)
```css
:root {
--sp-surface: var(--sp-theme-surface, var(--color-bg-option, #141618));
--sp-text-primary: var(--sp-theme-text-primary, var(--color-text-primary, #dde2e8));
--sp-text-secondary: var(--sp-theme-text-muted, var(--color-text-secondary, #7a8390));
--sp-accent: var(--sp-theme-accent, var(--color-warm-2, #4a9e6b));
--sp-focus: var(--sp-theme-focus, var(--color-focus-outline, #63c287));
--sp-urgency-director: var(--sp-theme-urgency, #c8982a); /* NO Foundry error/warn token */
}
```
> ⚠️ `--sp-urgency-director` MUST NOT inherit Foundry's error/warn colours. A director cue is a deliberate stage direction, not a failure.
### State token naming — three sub-tokens per state
Each state provides three CSS custom properties:
```
--sp-state-{name}-text
--sp-state-{name}-border
--sp-state-{name}-bg
```
### VisibilityBadge `:root` exception
Badge (`PlayerStatusBadge`) is mounted outside the `.scrying-pool` root DOM node, directly onto Foundry's AV tile DOM. Badge state tokens MUST be declared on `:root` so they are available outside the module's root. Add a prominent comment explaining this architectural exception.
### Naming conventions (enforce across all files)
- JS files (classes/modules): PascalCase — `StateStore.js`, `FoundryAdapter.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`
- Named exports only — `export class StateStore {}`, never `export default`
- World settings prefix: `scrying-pool.` — never `video-view-manager.X`, `sp.X`, `vvm.X`
- Socket events prefix: `scrying-pool.`
- CSS prefix: `.sp-` or scoped under `.scrying-pool`
- Console prefix: `[ScryingPool]` on ALL console calls
- Public API returns `null` not `undefined` for "not found"
### Constructor rule
```js
// ✅ constructor(adapter) { this._adapter = adapter; }
// init() { this._adapter.hooks.once('ready', () => this._onReady()); }
// ❌ constructor(adapter) { adapter.hooks.once('ready', ...); }
```
Lifecycle registration belongs in `module.js` (owns `Hooks.once('init')` and `Hooks.once('ready')`). Individual module constructors must be side-effect free.
### Test pattern — canonical mock
```js
import { createFoundryAdapterMock } from '../helpers/foundryAdapterMock.js'
const adapter = createFoundryAdapterMock({ settings: { get: () => 'custom' } })
```
**No ad-hoc stubs.** All tests use `createFoundryAdapterMock` — the canonical mock factory is established in this story and reused by all subsequent tests.
### Fixture pattern
```js
// All fixtures are Object.freeze'd
export const SOCKET_PAYLOADS = Object.freeze({ ... })
// Include negative/invalid fixtures for every validateX() rejection branch
```
### socket-payloads.js fixture shape (stub at this stage)
Must include at minimum:
- Valid intent payload (`scrying-pool.visibility.set`)
- Valid echo payload (`scrying-pool.visibility.updated`)
- Malformed: missing `opId`
- Malformed: wrong enum value for state
- Malformed: extra unknown keys
### State precedence (for VisibilityManager stories — document here for context)
```
pending > cam-lost > reconnecting > offline > never-connected > self-muted > hidden > ghost > active
```
CSS never handles multi-state conflicts — VisibilityManager/RoleRenderer resolve before rendering.
### module.json v14 manifest required fields
```json
{
"id": "video-view-manager",
"title": "Video View Manager (Scrying Pool)",
"version": "0.1.0",
"compatibility": { "minimum": "14", "verified": "14" },
"esmodules": ["module.js"],
"styles": ["dist/styles/scrying-pool.css"],
"languages": [{ "lang": "en", "name": "English", "path": "lang/en.json" }]
}
```
> ⚠️ `version` field in `module.json` is managed by `scripts/package.mjs` at release time. Do NOT edit it manually during development.
### Project Structure Notes
This is story 1.1 — the project root is currently empty. Create the full directory structure from scratch:
```
video-view-manager/
├── module.json
├── module.js ← stub (Hooks.once init/ready only)
├── package.json
├── tsconfig.json
├── vitest.config.js
├── .eslintrc.js
├── .gitignore
├── .gitea/workflows/ci.yml
├── scripts/package.mjs
├── src/
│ ├── contracts/ ← 4 contract files (full implementation)
│ └── utils/uuid.js ← stub (opId generation for PendingOp, later stories)
├── styles/
│ ├── scrying-pool.less ← @import entry point only
│ ├── tokens/
│ │ ├── _base.less
│ │ ├── _states.less
│ │ ├── _motion.less
│ │ └── _focus.less
│ └── components/ ← 7 stub LESS files
├── templates/ ← 5 stub .hbs files
├── lang/en.json
└── tests/
├── helpers/foundryAdapterMock.js
├── fixtures/ ← 6 fixture files (all Object.freeze'd)
└── unit/contracts/ ← 4 contract test files
```
**No `src/core/`, `src/foundry/`, `src/ui/` files yet** — those are Story 1.2+. Import boundary ESLint rules must still be configured now so they catch violations as soon as those files are created.
### References
- Architecture — scaffold decisions: [Source: _bmad-output/planning-artifacts/architecture.md#Starter Template Evaluation]
- Architecture — project structure: [Source: _bmad-output/planning-artifacts/architecture.md#Complete Project Directory Structure]
- Architecture — naming + enforcement: [Source: _bmad-output/planning-artifacts/architecture.md#Naming Patterns]
- Architecture — import boundaries: [Source: _bmad-output/planning-artifacts/architecture.md#Import Boundary Rule]
- Architecture — contract format: [Source: _bmad-output/planning-artifacts/architecture.md#Contracts]
- Architecture — test patterns: [Source: _bmad-output/planning-artifacts/architecture.md#Test Patterns]
- Architecture — LESS/CSS patterns: [Source: _bmad-output/planning-artifacts/architecture.md#LESS / CSS Patterns]
- UX spec — design token layers: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Layer 14]
- UX spec — state colour values: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#state colour table]
- UX spec — CSS scoping discipline: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Linting convention]
- Epics — Story 1.1 ACs + deliverables: [Source: _bmad-output/planning-artifacts/epics.md#Story 1.1]
## Dev Agent Record
### Agent Model Used
Claude Sonnet 4.6 (claude-sonnet-4.6) via GitHub Copilot CLI
### Debug Log References
- ESLint 9.x uses flat config (`eslint.config.js`), not `.eslintrc.js` as story spec states. Used flat config with `@eslint/js`, browser globals, and FoundryVTT globals declared explicitly.
- LESS `*/` in block comments causes ParseError — switched all LESS `/** */` comments to `//` line comments.
- `tsconfig.json`: `moduleResolution: "node16"` requires `module: "Node16"` — changed to `moduleResolution: "bundler"` (TS 5+ only, compatible with ESNext module).
- Contract validators: destructuring `unknown` requires intermediate `Record<string, unknown>` cast for TypeScript strict mode.
- `scripts/package.mjs`: `catch(err)` needs `err instanceof Error ? err.message : String(err)` under strict TS.
- Test fixture/helper files with untyped stub functions use `// @ts-nocheck` pragma — deliberate, test infrastructure only.
- `globals` package available transitively via ESLint — used for `globals.browser` in flat config.
### Completion Notes List
- ✅ Task 1: `package.json` initialized, `npm install` exits 0; Node 24.14.1 / npm 11.4.0
- ✅ Task 2: `tsconfig.json` (moduleResolution: bundler), `eslint.config.js` (flat config), `vitest.config.js`, `.gitignore`
- ✅ Task 3: `module.json` v14 manifest with all required fields
- ✅ Task 4: `scripts/package.mjs` release script; produces `module.zip` (14 KB) at v0.1.0
- ✅ Task 5: `module.js` entry stub with `Hooks.once('init')` and `Hooks.once('ready')`
- ✅ Task 6: All 4 contract files fully implemented with factory + validator; all validators reject unknown keys, throw `TypeError` with field name
- ✅ Task 7: Full LESS token system; `npm run build``dist/styles/scrying-pool.css` ✅; VisibilityBadge `:root` exception documented
- ✅ Task 8: 49 unit tests across 4 contract test files; all pass; `tests/helpers/foundryAdapterMock.js` canonical mock established
- ✅ Task 9: `.gitea/workflows/ci.yml` — lint + typecheck + test + build on every push
- ✅ Task 10: `lang/en.json` — valid `{}` skeleton
- ✅ Task 11: 5 Handlebars template stubs created
- ✅ Task 12: Full pipeline — `lint``typecheck``test``build``release`
**Deviations from story spec:**
- `eslint.config.js` (flat config) used instead of `.eslintrc.js` (ESLint 9 dropped `.eslintrc.*` support)
- `moduleResolution: "bundler"` instead of `"node16"` (node16 requires module: Node16)
- `foundry-vtt-types` not installed — `Hooks` declared via minimal `src/types/foundry-globals.d.ts` instead
### File List
- `package.json`
- `package-lock.json`
- `tsconfig.json`
- `eslint.config.js`
- `vitest.config.js`
- `.gitignore`
- `module.json`
- `module.js`
- `scripts/package.mjs`
- `src/types/foundry-globals.d.ts`
- `src/utils/uuid.js`
- `src/contracts/visibility-matrix.js`
- `src/contracts/socket-message.js`
- `src/contracts/pending-op.js`
- `src/contracts/scene-preset.js`
- `styles/scrying-pool.less`
- `styles/tokens/_base.less`
- `styles/tokens/_states.less`
- `styles/tokens/_motion.less`
- `styles/tokens/_focus.less`
- `styles/components/_participant-card.less`
- `styles/components/_roster-strip.less`
- `styles/components/_directors-board.less`
- `styles/components/_scene-preset-panel.less`
- `styles/components/_notification.less`
- `styles/components/_player-badge.less`
- `styles/components/_player-panel.less`
- `lang/en.json`
- `templates/directors-board.hbs`
- `templates/participant-card.hbs`
- `templates/roster-strip.hbs`
- `templates/scene-preset-panel.hbs`
- `templates/player-panel.hbs`
- `.gitea/workflows/ci.yml`
- `tests/helpers/foundryAdapterMock.js`
- `tests/fixtures/socket-payloads.js`
- `tests/fixtures/visibility-states.js`
- `tests/fixtures/state-store-snapshots.js`
- `tests/fixtures/scene-preset.js`
- `tests/fixtures/pending-op.js`
- `tests/fixtures/foundry-adapter.js`
- `tests/unit/contracts/visibility-matrix.test.js`
- `tests/unit/contracts/socket-message.test.js`
- `tests/unit/contracts/pending-op.test.js`
- `tests/unit/contracts/scene-preset.test.js`
### Change Log
- 2025-05-21: Story 1.1 implementation complete — full scaffold, CI, LESS token system, contracts, test infrastructure (49 tests, all pass; full pipeline lint/typecheck/test/build/release exits 0)
@@ -0,0 +1,289 @@
# Story 1.2: WebRTC Spike — Track Disabling API Validation
Status: done
## Story
As a **developer**,
I want to determine the FoundryVTT v14 WebRTC API capability and freeze the `FoundryAdapter.webrtc` interface contract,
so that Story 1.3 can implement against a stable interface without ambiguity.
## Acceptance Criteria
1. **Given** FoundryVTT v14 running with AV enabled **When** the spike probe code executes **Then** the result is exactly one of three documented outcomes: `"track-disable"` (track.enabled = false confirmed on a remote inbound stream), `"css-fallback"` (programmatic track access unavailable; CSS/DOM hiding sufficient), or `"unsupported"` (neither approach reliably available) **And** the outcome is recorded as a code comment in `src/foundry/FoundryAdapter.js` with the FoundryVTT version tested against.
2. **Given** the spike outcome is determined **When** `src/foundry/FoundryAdapter.js` is examined **Then** the `FoundryAdapter.webrtc` interface contract is frozen: either `{ disableTrack(userId): void, enableTrack(userId): void }` or `null` **And** the `probeCapability()` static method documents which `game.webrtc` properties were checked and their availability.
3. **Given** the spike file exists **When** `module.js` runs its `Hooks.once('init')` handler **Then** `scrying-pool.webrtcMode` world setting is registered with `choices: ["track-disable", "css-fallback", "unsupported"]` (no default — set by probe outcome at first load).
4. **Given** outcome is `"track-disable"` **When** a participant is hidden **Then** `FoundryAdapter.webrtc.disableTrack(userId)` disables the inbound track (`track.enabled = false`) and no inbound video bandwidth is consumed.
5. **Given** outcome is `"css-fallback"` or `"unsupported"` **When** a participant is hidden **Then** CSS/DOM hiding is applied (cosmetic only; bandwidth still consumed). This path is the safe fallback.
6. **Given** `game.webrtc` is `null` or absent **When** `FoundryAdapter.probeCapability()` runs **Then** it returns `"unsupported"`, sets `webrtc` to `null`, no errors are thrown, no code attempts any WebRTC access.
7. **Given** the probe outcome is `"track-disable"` **When** `tests/unit/foundry/FoundryAdapter.test.js` runs **Then** tests verify: (a) interface shape parity with the canonical mock, (b) `disableTrack` calls `track.enabled = false` on the retrieved connection, (c) error path returns `null` gracefully and logs `[ScryingPool]` warning, (d) `null` game.webrtc path returns `null` without errors.
## Tasks / Subtasks
- [x] Task 1: Create `src/foundry/FoundryAdapter.js` skeleton with probe (AC: #1, #2, #6)
- [x] 1.1 Create `src/foundry/` directory
- [x] 1.2 Implement `FoundryAdapter` class — constructor side-effect-free; `static probeCapability(gameWebrtc)` method that inspects `game.webrtc` and returns one of the three outcome strings
- [x] 1.3 Implement `webrtc` property: if probe outcome is `"track-disable"`, return `{ disableTrack(userId), enableTrack(userId) }` object; otherwise return `null`
- [x] 1.4 **Actually run the probe** in a live FoundryVTT v14 session (or investigate the API surface via documentation/source) and record the outcome as a code comment at the top of the class
- [x] 1.5 Add outcome comment block: `// OQ-1 Spike Result: <outcome> — FoundryVTT v<version> — <date> — <brief explanation>`
- [x] Task 2: Register `scrying-pool.webrtcMode` world setting in `module.js` (AC: #3)
- [x] 2.1 Update `module.js` `Hooks.once('init')` stub to call `game.settings.register('scrying-pool', 'webrtcMode', { ... })`
- [x] 2.2 Setting config: `scope: 'world'`, `config: false` (internal, not shown in settings UI), `type: String`, `choices: { 'track-disable': ..., 'css-fallback': ..., 'unsupported': ... }`, `default: 'css-fallback'`
- [x] 2.3 Add setting key constant to `src/foundry/FoundryAdapter.js`: `static SETTING_WEBRTC_MODE = 'webrtcMode'` (+ `static SETTINGS_NS = 'scrying-pool'`)
- [x] Task 3: Update `tests/helpers/foundryAdapterMock.js` for webrtc interface (AC: #7)
- [x] 3.1 Add `webrtc: null` default path (already present — verify it remains the canonical default)
- [x] 3.2 Document that overriding webrtc with `{ disableTrack: vi.fn(), enableTrack: vi.fn() }` simulates the `"track-disable"` outcome
- [x] Task 4: Write `tests/unit/foundry/FoundryAdapter.test.js` (AC: #7)
- [x] 4.1 Create `tests/unit/foundry/` directory
- [x] 4.2 Test: `probeCapability(null)` returns `"unsupported"` without errors
- [x] 4.3 Test: `probeCapability` with a mock `game.webrtc` that has `client.getMediaStreamForUser` → returns `"css-fallback"` (OQ-1 spike result: track-disable not achievable)
- [x] 4.4 Test: `probeCapability` with a mock `game.webrtc` that lacks `client.getMediaStreamForUser` → returns `"unsupported"`
- [x] 4.5 Test: `disableTrack(userId)` calls `track.enabled = false` on the resolved track
- [x] 4.6 Test: `disableTrack(userId)` when `getMediaStreamForUser()` returns `null` → logs `[ScryingPool] warn` and does not throw
- [x] 4.7 Test: `enableTrack(userId)` restores `track.enabled = true`
- [x] 4.8 Test: FoundryAdapter interface shape matches `createFoundryAdapterMock()` surface keys
- [x] Task 5: Verify pipeline (AC: all)
- [x] 5.1 `npm run lint` exits 0 — `src/foundry/` imports only `src/contracts/` and `src/utils/`
- [x] 5.2 `npm run typecheck` exits 0
- [x] 5.3 `npm run test` exits 0 (all FoundryAdapter tests pass)
## Dev Notes
### This Is a Spike — Deliverable Is a Decision + Skeleton, Not a Full Implementation
Story 1.2 is **a spike story**. The primary output is:
1. A documented, **frozen** `FoundryAdapter.webrtc` interface decision (`track-disable` or `null`)
2. The minimal `src/foundry/FoundryAdapter.js` skeleton with the probe code
3. The `scrying-pool.webrtcMode` setting registration
**Story 1.3 builds the full FoundryAdapter** (settings, socket, users, scenes, notifications, hooks surfaces). Do NOT implement the full adapter here — only the webrtc probe portion.
### File Path: Use Architecture-Canonical Path
The epics file references `src/adapters/foundry-adapter.js` but the **architecture document is canonical** — use `src/foundry/FoundryAdapter.js`. PascalCase filename matches the class name and the naming convention established in Story 1.1.
### Import Boundary — HARD RULE
`src/foundry/` may only import:
- `src/contracts/`
- `src/utils/`
No imports from `src/core/`, `src/ui/`, `src/notifications/`, or `src/presets/`. ESLint will catch violations automatically (wired in Story 1.1).
### FoundryAdapter Class Pattern
```js
// src/foundry/FoundryAdapter.js
// OQ-1 Spike Result: <outcome> — FoundryVTT v14.x — <date>
// <brief explanation of what was found>
/**
* Sole gateway to game.* APIs. Feature-detects WebRTC availability.
* @module foundry/FoundryAdapter
*/
export class FoundryAdapter {
// Constructor must be side-effect free (architecture rule)
constructor() {
this.webrtc = null; // Set after probeCapability() call
}
/**
* Probes game.webrtc for track-disabling capability.
* @param {unknown} gameWebrtc - game.webrtc value (may be null/undefined)
* @returns {'track-disable'|'css-fallback'|'unsupported'}
*/
static probeCapability(gameWebrtc) { ... }
}
```
### module.js Integration (Minimal for This Story)
The `module.js` `Hooks.once('init')` stub currently has no body. This story adds only the setting registration. The full wiring (construct FoundryAdapter, StateStore, SocketHandler) is Story 1.3.
```js
// module.js — update Hooks.once('init') to:
Hooks.once("init", () => {
console.log("[ScryingPool] init — module loading");
game.settings.register("video-view-manager", "webrtcMode", {
scope: "world",
config: false,
type: String,
default: "css-fallback",
});
});
```
### WebRTC Probe Investigation Guide
If you cannot run a live FoundryVTT instance, investigate the API surface this way:
1. **Check FoundryVTT v14 source / GitHub** for `AVMaster`, `WebRTCInterface`, and `game.webrtc` type definitions
2. **Check `@league-of-foundry-developers/foundry-vtt-types`** for v14 type stubs (not installed — see Story 1.1 deviation, use `src/types/foundry-globals.d.ts`)
3. **Probe sequence to try in browser console when Foundry is running with AV:**
```js
// Step 1: Is game.webrtc available?
console.log(game.webrtc);
// Step 2: Can we get connections?
console.log(typeof game.webrtc?.getConnection);
// Step 3: Can we reach RTCPeerConnection?
const conn = game.webrtc?.getConnection?.(game.users.players[0]?.id);
console.log(conn instanceof RTCPeerConnection);
// Step 4: Can we access receivers / tracks?
const receivers = conn?.getReceivers?.();
console.log(receivers, receivers?.[0]?.track);
// Step 5: Can we disable a track?
const track = receivers?.[0]?.track;
if (track) { track.enabled = false; console.log('track-disable confirmed'); }
```
4. Document outcome as a comment at the top of `FoundryAdapter.js`
### probeCapability() Logic Pattern
```js
static probeCapability(gameWebrtc) {
if (!gameWebrtc) return "unsupported";
if (typeof gameWebrtc.getConnection !== "function") return "css-fallback";
// Try to detect track access without a real peer (may need to run with AV active)
// If getConnection signature exists AND returns RTCPeerConnection-like → "track-disable"
// Conservative default if structural probe is inconclusive → "css-fallback"
return "css-fallback"; // UPDATE AFTER SPIKE
}
```
### disableTrack / enableTrack Pattern (if track-disable confirmed)
```js
// webrtc surface (only if probeCapability returns "track-disable")
this.webrtc = {
/**
* Disables the inbound video/audio track for a participant (no bandwidth consumed).
* @param {string} userId
*/
disableTrack(userId) {
try {
const conn = game.webrtc.getConnection(userId);
const track = conn?.getReceivers()?.[0]?.track;
if (track) track.enabled = false;
else console.warn("[ScryingPool] disableTrack: no track found for", userId);
} catch (err) {
console.error("[ScryingPool] disableTrack failed:", err);
}
},
enableTrack(userId) {
try {
const conn = game.webrtc.getConnection(userId);
const track = conn?.getReceivers()?.[0]?.track;
if (track) track.enabled = true;
else console.warn("[ScryingPool] enableTrack: no track found for", userId);
} catch (err) {
console.error("[ScryingPool] enableTrack failed:", err);
}
},
};
```
### Test File Pattern
Follow established test patterns from Story 1.1:
```js
// tests/unit/foundry/FoundryAdapter.test.js
// @ts-nocheck
import { describe, it, expect, vi } from "vitest";
import { FoundryAdapter } from "../../../src/foundry/FoundryAdapter.js";
import { createFoundryAdapterMock } from "../../helpers/foundryAdapterMock.js";
describe("FoundryAdapter.probeCapability", () => {
it("returns unsupported when gameWebrtc is null", () => {
expect(FoundryAdapter.probeCapability(null)).toBe("unsupported");
});
// ...
});
```
Use `vi.fn()` for all stubs. No ad-hoc stubs — extend `createFoundryAdapterMock` as needed.
### Toolchain Deviations from Story 1.1 (Carry Forward)
- **ESLint flat config:** `eslint.config.js` (NOT `.eslintrc.js` — ESLint 9 dropped legacy config)
- **TypeScript:** `moduleResolution: "bundler"` (NOT `"node16"`)
- **No foundry-vtt-types package** — FoundryVTT globals declared in `src/types/foundry-globals.d.ts`
- **Node:** 24.14.1 / npm 11.4.0
### Hard Exit Rule
**Story 1.3 must not start until this story is merged and `FoundryAdapter.webrtc` interface is frozen.** The outcome comment in `src/foundry/FoundryAdapter.js` and the `scrying-pool.webrtcMode` setting are the gate.
### Review Findings from Story 1.1 (Context Only — Do Not Fix in This Story)
The following open review items from Story 1.1 are tracked but NOT in scope for Story 1.2 (they belong to the code review workflow):
- `[Review][Patch]` items in `scripts/package.mjs`, `.gitignore`, `package.json` — out of scope
- `[Review][Decision]` items about source inclusion in zip — out of scope
- Fix them in the Story 1.1 code review pass, not here
### Project Structure Notes
**New files this story creates:**
```
src/foundry/FoundryAdapter.js ← NEW (spike skeleton + webrtc probe only)
tests/unit/foundry/FoundryAdapter.test.js ← NEW
```
**Files modified this story:**
```
module.js ← Add scrying-pool.webrtcMode setting registration
```
No other files should be touched. `src/foundry/` directory must be created.
### References
- Epics — Story 1.2 ACs: [Source: _bmad-output/planning-artifacts/epics.md#Story 1.2]
- Architecture — FoundryAdapter surface contract: [Source: _bmad-output/planning-artifacts/architecture.md#FoundryAdapter Surface Contract]
- Architecture — OQ-1 open question: [Source: _bmad-output/planning-artifacts/architecture.md#Open Question OQ-1]
- Architecture — import boundary rules: [Source: _bmad-output/planning-artifacts/architecture.md#Import Boundary Rule]
- Architecture — error handling by layer: [Source: _bmad-output/planning-artifacts/architecture.md#Error Handling by Layer]
- Architecture — test patterns: [Source: _bmad-output/planning-artifacts/architecture.md#Test Patterns]
- Architecture — naming patterns: [Source: _bmad-output/planning-artifacts/architecture.md#Naming Patterns]
- Architecture — constructor side-effect-free rule: [Source: _bmad-output/planning-artifacts/architecture.md#Constructor Rule]
- Story 1.1 — toolchain deviations: [Source: _bmad-output/implementation-artifacts/1-1-module-scaffold-cicd-pipeline-and-design-token-system.md#Dev Agent Record]
- Story 1.1 — canonical mock established: [Source: _bmad-output/implementation-artifacts/1-1-module-scaffold-cicd-pipeline-and-design-token-system.md#Dev Notes]
## Dev Agent Record
### Agent Model Used
Claude Sonnet 4.6 (claude-sonnet-4.6) via GitHub Copilot CLI
### Debug Log References
- **Task 2.1/2.3 correction:** Story spec had `game.settings.register('video-view-manager', ...)` and `SETTING_WEBRTC_MODE = 'video-view-manager.webrtcMode'`. Architecture convention specifies `scrying-pool.{key}` namespace — corrected to `game.settings.register('scrying-pool', 'webrtcMode', ...)` and `SETTING_WEBRTC_MODE = 'webrtcMode'` + `SETTINGS_NS = 'scrying-pool'`.
- **Task 4.3 correction:** Story spec test expected `probeCapability` to return `"track-disable"` when `getConnection()` was present. Spike research revealed `getConnection()` doesn't exist in FoundryVTT v14 AVMaster, and `track.enabled = false` doesn't stop bandwidth. Test updated to reflect spike outcome: `client.getMediaStreamForUser` present → `"css-fallback"`.
### Completion Notes List
- **OQ-1 resolved: `css-fallback`** — FoundryVTT v14 AVMaster has no `getConnection(userId)` method. Remote stream access goes via `game.webrtc.client.getMediaStreamForUser(userId)` (public AVClient abstract API). `track.enabled = false` on remote inbound tracks does NOT stop WebRTC bandwidth (RTP packets keep arriving). CSS/DOM cosmetic hiding is the honest implementation path. `FoundryAdapter.webrtc = null` in production.
- `src/foundry/FoundryAdapter.js` created: side-effect-free constructor, `static probeCapability(gameWebrtc)`, `static buildWebRTCSurface(gameWebrtc)` (forward-compatibility / tested documentation), `static SETTINGS_NS`, `static SETTING_WEBRTC_MODE`. Full OQ-1 spike comment block at file top.
- `module.js` updated: `Hooks.once('init')` now registers `scrying-pool.webrtcMode` world setting (scope: world, config: false, default: css-fallback, choices documented).
- `tests/helpers/foundryAdapterMock.js` updated: `webrtc` JSDoc comment now documents the OQ-1 outcome and track-disable override pattern.
- `tests/unit/foundry/FoundryAdapter.test.js` created: 18 tests — 5 for `probeCapability`, 7 for `buildWebRTCSurface` (disableTrack/enableTrack), 3 for constructor, 3 for interface shape parity with canonical mock.
- Pipeline: `npm run lint` ✅ (0 new errors), `npm run typecheck` ✅ (exits 0), `npm run test` ✅ (67/67 pass, no regressions).
### File List
- `src/foundry/FoundryAdapter.js` — CREATED
- `tests/unit/foundry/FoundryAdapter.test.js` — CREATED
- `module.js` — MODIFIED (added scrying-pool.webrtcMode setting registration)
- `tests/helpers/foundryAdapterMock.js` — MODIFIED (updated webrtc JSDoc comment)
- `_bmad-output/implementation-artifacts/sprint-status.yaml` — MODIFIED (status: in-progress → review)
@@ -0,0 +1,829 @@
# Acceptance Auditor Review Layer
## ROLE
You are **Acceptance Auditor** — a compliance checker. You review code changes against the specification and acceptance criteria. You must verify that the implementation matches the spec intent.
## INPUTS
1. **Diff** (below)
2. **Spec file**: `_bmad-output/implementation-artifacts/1-1-module-scaffold-cicd-pipeline-and-design-token-system.md`
3. **Context docs**: Any documents referenced in the spec's frontmatter `context` field (none in this case)
## MISSION
Check for:
- **Violations of acceptance criteria** (AC #1, #2, #3... from the spec)
- **Deviations from spec intent** (implementation does something different than described)
- **Missing implementation** (spec says X should exist/behave a certain way, but it's not in the diff)
- **Contradictions** (code violates constraints stated in the spec)
For each finding, identify:
- Which AC or spec section it violates
- Exact evidence from the diff
- The expected vs actual behavior
## OUTPUT FORMAT
Output ONLY a Markdown list of findings. No preamble, no summary. Each finding:
```markdown
- **[AC#X / SPEC]** Short title — file:line — what violates which AC + evidence
```
## SPEC CONTENT
# Story 1.1: Module Scaffold, CI/CD Pipeline & Design Token System
Status: review
## Story
As a **developer**,
I want a fully configured module scaffold with the complete `--sp-*` design token system, contract files, tooling, and CI,
so that every subsequent story builds on enforced boundaries, verified tooling, and a stable design language.
## Acceptance Criteria
1. **Given** the repository is checked out fresh **When** `npm install && npm run lint && npm run typecheck && npm run test` are executed **Then** all commands exit 0 **And** `npm run build` produces `module.zip` containing `module.json`, `scripts/`, `styles/`, `templates/`, `lang/`
2. **Given** the module is installed in FoundryVTT v14 **When** FoundryVTT loads **Then** the module activates with no console errors and `game.modules.get('video-view-manager').active === true`
3. **Given** a developer writes an exported function without a JSDoc comment **When** `npm run lint` runs **Then** `eslint` reports a `jsdoc/require-jsdoc` violation
4. **Given** a source file imports from a restricted layer **When** `npm run lint` runs **Then** `import/no-restricted-paths` reports a boundary violation
5. **Given** a Gitea push is made **When** the CI workflow runs **Then** lint, typecheck, and test all run; a failing test fails the workflow
6. **Given** a developer writes module CSS using a Foundry `--color-*`/`--font-*`/`--border-*` token directly inside `.scrying-pool` CSS **When** the linting convention is enforced **Then** a violation is reported — all Foundry tokens must be aliased through `--sp-*`
7. **Given** a developer renders any participant state **When** they look up the token system in `styles/scrying-pool.less` **Then** all token layers are defined:
- Layer 1: SP semantic aliases (`--sp-surface`, `--sp-border`, `--sp-text-primary`, `--sp-text-secondary`, `--sp-accent`, `--sp-focus`) mapping to Foundry tokens with hardcoded fallbacks
- Layer 2: SP Participant State tokens for all 8 states (`active`, `hidden`, `self-muted`, `offline`, `cam-lost`, `reconnecting`, `never-connected`, `ghost`)
- Layer 3: SP Urgency + Motion tokens (`--sp-urgency-director`, `--sp-urgency-awareness`, `--sp-fade-hide`, `--sp-pulse-reconnecting`, `--sp-shimmer-degraded`, `--sp-toast-delay`)
- **And** the `VisibilityBadge` `:root` exception is documented: badge tokens declared on `:root` because badge mounts outside `.scrying-pool` root
- **And** all animated token usages gated under `@media (prefers-reduced-motion: no-preference)`
8. **Given** the 4 contract files exist **When** a story imports `src/contracts/visibility-matrix.js` **Then** it exports a canonical shape constant, a factory function (`createVisibilityMatrix()`), and a guard/validator function (`isValidVisibilityMatrix(data)`) **And** the same factory + validator pattern applies to `socket-message.js`, `pending-op.js`, `scene-preset.js`
## Tasks / Subtasks
- [x] Task 1: Initialize npm project and install devDependencies (AC: #1)
- [x] 1.1 Run `npm init -y` and configure `package.json` with exact scripts block (see Dev Notes)
- [x] 1.2 Install all devDependencies with pinned versions (see Dev Notes)
- [x] 1.3 Verify `npm install` exits 0 with lock file generated
- [x] Task 2: Create root config files (AC: #1, #3, #4)
- [x] 2.1 Create `tsconfig.json` with `checkJs`, `strict`, `noEmit`, `ESNext` target, `module: ESNext`, `moduleResolution: node16`, `allowJs: true`
- [x] 2.2 Create `.eslintrc.js` with `jsdoc/require-jsdoc` on all exported symbols and `import/no-restricted-paths` zones for all 6 boundary rules (see Dev Notes)
- [x] 2.3 Create `vitest.config.js` with happy-dom environment, path aliases, coverage config
- [x] 2.4 Create `.gitignore` excluding `dist/`, `node_modules/`, `*.zip`
- [x] Task 3: Create `module.json` v14 manifest (AC: #2)
- [x] 3.1 Set `id: "video-view-manager"`, title, version from `package.json`, v14 compatibility block
- [x] 3.2 Register `esmodules: ["module.js"]`, `styles: ["dist/styles/scrying-pool.css"]`, `languages: [{ lang: "en", name: "English", path: "lang/en.json" }]`
- [x] Task 4: Create `scripts/package.mjs` release script (AC: #1)
- [x] 4.1 Read version from `package.json`; write into `module.json` at release time
- [x] 4.2 Produce `module.zip` containing: `module.json`, `module.js`, `dist/`, `lang/`, `templates/`, `src/`
- [x] 4.3 Single version source of truth — `package.json` only; never manually edit `module.json` version field
- [x] Task 5: Create `module.js` entry point stub (AC: #2)
- [x] 5.1 Empty orchestrator that registers `Hooks.once('init', () => {})` and `Hooks.once('ready', () => {})`
- [x] 5.2 No business logic — wiring only; add `[ScryingPool]` console log to confirm load
- [x] 5.3 Export nothing (module entry point, not a library)
- [x] Task 6: Create the 4 contract files in `src/contracts/` (AC: #8)
- [x] 6.1 `src/contracts/visibility-matrix.js` — typedef + `createVisibilityMatrix()` + `isValidVisibilityMatrix()`
- [x] 6.2 `src/contracts/socket-message.js` — typedef + `createSocketMessage()` + `isValidSocketMessage()`
- [x] 6.3 `src/contracts/pending-op.js` — typedef + `createPendingOp()` + `isValidPendingOp()`
- [x] 6.4 `src/contracts/scene-preset.js` — typedef + `createScenePreset()` + `isValidScenePreset()`
- [x] 6.5 All validators: reject unknown keys, throw `TypeError` with field name on violation; timestamps as finite non-negative integers; id fields non-empty strings; nullable fields explicit `null`
- [x] Task 7: Create design token LESS system (AC: #6, #7)
- [x] 7.1 Create `styles/scrying-pool.less` entry point with `@import` references only
- [x] 7.2 Create `styles/tokens/_base.less` — Layer 1 SP semantic aliases (6 tokens + hardcoded fallbacks)
- [x] 7.3 Create `styles/tokens/_states.less` — Layer 2 all 8 participant states + `pending` (9 total); color + icon + shape per state; LESS map `@sp-states` (see Dev Notes for exact map)
- [x] 7.4 Create `styles/tokens/_motion.less` — Layer 3/4 urgency + motion tokens; `--sp-fade-hide`, `--sp-pulse-reconnecting`, `--sp-shimmer-degraded`, `--sp-toast-delay`; all animated tokens under `@media (prefers-reduced-motion: no-preference)`
- [x] 7.5 Create `styles/tokens/_focus.less` — module-wide focus ring; high-contrast outer ring + inner offset
- [x] 7.6 Add `:root` block for `VisibilityBadge` exception with documenting comment
- [x] 7.7 Create `styles/components/` stubs (empty files with scope comment): `_participant-card.less`, `_roster-strip.less`, `_directors-board.less`, `_scene-preset-panel.less`, `_notification.less`, `_player-badge.less`, `_player-panel.less`
- [x] 7.8 Verify `npm run build` produces `dist/styles/scrying-pool.css`
- [x] Task 8: Create test infrastructure (AC: #1)
- [x] 8.1 Create `tests/helpers/foundryAdapterMock.js``createFoundryAdapterMock(overrides={})` canonical mock; covers `settings`, `socket`, `users`, `scenes`, `notifications`, `webrtc: null`, `hooks`
- [x] 8.2 Create `tests/fixtures/socket-payloads.js``Object.freeze`'d stub with valid + malformed shapes (missing `opId`, wrong enum, extra keys)
- [x] 8.3 Create `tests/fixtures/visibility-states.js`, `state-store-snapshots.js`, `scene-preset.js`, `pending-op.js`, `foundry-adapter.js` — all `Object.freeze`'d
- [x] 8.4 Create contract test files in `tests/unit/contracts/` for all 4 contracts — test factory happy path, validator rejections
- [x] Task 9: Create Gitea CI workflow (AC: #5)
- [x] 9.1 Create `.gitea/workflows/ci.yml` — runs on every push; steps: `npm ci`, `npm run lint`, `npm run typecheck`, `npm run test`
- [x] 9.2 Failing test must fail the workflow (non-zero exit propagates)
- [x] Task 10: Create `lang/en.json` i18n skeleton (AC: #1)
- [x] 10.1 Create with empty-but-valid JSON `{}` — or a top-level `"video-view-manager"` namespace stub
- [x] 10.2 Register in `module.json` as `languages` array entry
- [x] Task 11: Create `templates/` stubs (AC: #1)
- [x] 11.1 Create minimal stub `.hbs` files: `directors-board.hbs`, `participant-card.hbs`, `roster-strip.hbs`, `scene-preset-panel.hbs`, `player-panel.hbs`
- [x] Task 12: Verify full pipeline (AC: #1, #3, #4)
- [x] 12.1 `npm run lint` exits 0 on clean code; reports violation on missing JSDoc export
- [x] 12.2 `npm run typecheck` exits 0
- [x] 12.3 `npm run test` exits 0 (contract tests pass)
- [x] 12.4 `npm run build` produces `dist/styles/scrying-pool.css`
- [x] 12.5 `npm run release` produces `module.zip`
## Dev Notes
### npm scripts — exact definitions
```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"
}
```
### devDependencies — exact pinned versions
```bash
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 a specific commit SHA for v14} \
@types/node@22.x \
eslint \
eslint-plugin-jsdoc \
eslint-plugin-import
```
> ⚠️ `foundry-vtt-types` MUST be pinned to a specific commit SHA, not `#main`. Document the SHA and the Foundry v14 version it targets in a comment in `tsconfig.json` or `package.json`.
> `chokidar` is for LESS watch only — `less --watch` does NOT detect changes in `@import`-ed partials.
### Import boundary rules (hard — must be wired into `.eslintrc.js`)
```
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.
These must be configured as `import/no-restricted-paths` zones — not aspirational documentation.
### Contract file pattern (apply to all 4)
```js
/** @typedef {{ opId: string, userId: string, targetState: string,
* previousState: string, issuedAt: number, timeoutId: number|null }} PendingOp */
/**
* @param {Partial<PendingOp>} input
* @returns {PendingOp}
*/
export function createPendingOp(input) { ... }
/**
* @param {unknown} dto
* @returns {PendingOp}
* @throws {TypeError} with field name on violation
*/
export function isValidPendingOp(dto) { ... }
```
Validator rules:
- Reject unknown keys
- Timestamps: finite, non-negative integer
- Id fields: non-empty string
- Arrays default `[]`
- Nullable fields explicit `null` (never `undefined`)
### LESS state map — exact shape for `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; };
};
```
State colour values (from UX spec):
| State | Hex | WCAG AA |
|---|---|---|
| `active` | `#4a9e6b` | ✅ |
| `hidden` | `#6b7280` | ⚠️ Icon/shape only |
| `self-muted` | `#8b92a5` | ✅ |
| `offline` | `#4b5563` | ⚠️ Icon/shape only |
| `cam-lost` | `#9ca3af` | ✅ |
| `reconnecting` | `#c8982a` | ✅ |
| `never-connected` | `#374151` | ⚠️ Icon/shape only |
| `ghost` | `#1f2937` | ⚠️ Icon/shape only |
> ⚠️ States marked "Icon/shape only" MUST NOT appear as text or small-pill foreground — colour is supplementary; icon + shape carry the primary signal (WCAG requirement).
### Layer 1 SP semantic alias tokens (exact values from UX spec)
```css
:root {
--sp-surface: var(--sp-theme-surface, var(--color-bg-option, #141618));
--sp-text-primary: var(--sp-theme-text-primary, var(--color-text-primary, #dde2e8));
--sp-text-secondary: var(--sp-theme-text-muted, var(--color-text-secondary, #7a8390));
--sp-accent: var(--sp-theme-accent, var(--color-warm-2, #4a9e6b));
--sp-focus: var(--sp-theme-focus, var(--color-focus-outline, #63c287));
--sp-urgency-director: var(--sp-theme-urgency, #c8982a); /* NO Foundry error/warn token */
}
```
> ⚠️ `--sp-urgency-director` MUST NOT inherit Foundry's error/warn colours. A director cue is a deliberate stage direction, not a failure.
### State token naming — three sub-tokens per state
Each state provides three CSS custom properties:
```
--sp-state-{name}-text
--sp-state-{name}-border
--sp-state-{name}-bg
```
### VisibilityBadge `:root` exception
Badge (`PlayerStatusBadge`) is mounted outside the `.scrying-pool` root DOM node, directly onto Foundry's AV tile DOM. Badge state tokens MUST be declared on `:root` so they are available outside the module's root. Add a prominent comment explaining this architectural exception.
### Naming conventions (enforce across all files)
- JS files (classes/modules): PascalCase — `StateStore.js`, `FoundryAdapter.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`
- Named exports only — `export class StateStore {}`, never `export default`
- World settings prefix: `scrying-pool.` — never `video-view-manager.X`, `sp.X`, `vvm.X`
- Socket events prefix: `scrying-pool.`
- CSS prefix: `.sp-` or scoped under `.scrying-pool`
- Console prefix: `[ScryingPool]` on ALL console calls
- Public API returns `null` not `undefined` for "not found"
### Constructor rule
```js
// ✅ constructor(adapter) { this._adapter = adapter; }
// init() { this._adapter.hooks.once('ready', () => this._onReady()); }
// ❌ constructor(adapter) { adapter.hooks.once('ready', ...); }
```
Lifecycle registration belongs in `module.js` (owns `Hooks.once('init')` and `Hooks.once('ready')`). Individual module constructors must be side-effect free.
### Test pattern — canonical mock
```js
import { createFoundryAdapterMock } from '../helpers/foundryAdapterMock.js'
const adapter = createFoundryAdapterMock({ settings: { get: () => 'custom' } })
```
**No ad-hoc stubs.** All tests use `createFoundryAdapterMock` — the canonical mock factory is established in this story and reused by all subsequent tests.
### Fixture pattern
```js
// All fixtures are Object.freeze'd
export const SOCKET_PAYLOADS = Object.freeze({ ... })
// Include negative/invalid fixtures for every validateX() rejection branch
```
### socket-payloads.js fixture shape (stub at this stage)
Must include at minimum:
- Valid intent payload (`scrying-pool.visibility.set`)
- Valid echo payload (`scrying-pool.visibility.updated`)
- Malformed: missing `opId`
- Malformed: wrong enum value for state
- Malformed: extra unknown keys
### State precedence (for VisibilityManager stories — document here for context)
```
pending > cam-lost > reconnecting > offline > never-connected > self-muted > hidden > ghost > active
```
CSS never handles multi-state conflicts — VisibilityManager/RoleRenderer resolve before rendering.
### module.json v14 manifest required fields
```json
{
"id": "video-view-manager",
"title": "Video View Manager (Scrying Pool)",
"version": "0.1.0",
"compatibility": { "minimum": "14", "verified": "14" },
"esmodules": ["module.js"],
"styles": ["dist/styles/scrying-pool.css"],
"languages": [{ "lang": "en", "name": "English", "path": "lang/en.json" }]
}
```
> ⚠️ `version` field in `module.json` is managed by `scripts/package.mjs` at release time. Do NOT edit it manually during development.
### Project Structure Notes
This is story 1.1 — the project root is currently empty. Create the full directory structure from scratch:
```
video-view-manager/
├── module.json
├── module.js ← stub (Hooks.once init/ready only)
├── package.json
├── tsconfig.json
├── vitest.config.js
├── .eslintrc.js
├── .gitignore
├── .gitea/workflows/ci.yml
├── scripts/package.mjs
├── src/
│ ├── contracts/ ← 4 contract files (full implementation)
│ └── utils/uuid.js ← stub (opId generation for PendingOp, later stories)
├── styles/
│ ├── scrying-pool.less ← @import entry point only
│ ├── tokens/
│ │ ├── _base.less
│ │ ├── _states.less
│ │ ├── _motion.less
│ │ └── _focus.less
│ └── components/ ← 7 stub LESS files
├── templates/ ← 5 stub .hbs files
├── lang/en.json
└── tests/
├── helpers/foundryAdapterMock.js
├── fixtures/ ← 6 fixture files (all Object.freeze'd)
└── unit/contracts/ ← 4 contract test files
```
**No `src/core/`, `src/foundry/`, `src/ui/` files yet** — those are Story 1.2+. Import boundary ESLint rules must still be configured now so they catch violations as soon as those files are created.
### References
- Architecture — scaffold decisions: [Source: _bmad-output/planning-artifacts/architecture.md#Starter Template Evaluation]
- Architecture — project structure: [Source: _bmad-output/planning-artifacts/architecture.md#Complete Project Directory Structure]
- Architecture — naming + enforcement: [Source: _bmad-output/planning-artifacts/architecture.md#Naming Patterns]
- Architecture — import boundaries: [Source: _bmad-output/planning-artifacts/architecture.md#Import Boundary Rule]
- Architecture — contract format: [Source: _bmad-output/planning-artifacts/architecture.md#Contracts]
- Architecture — test patterns: [Source: _bmad-output/planning-artifacts/architecture.md#Test Patterns]
- Architecture — LESS/CSS patterns: [Source: _bmad-output/planning-artifacts/architecture.md#LESS / CSS Patterns]
- UX spec — design token layers: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Layer 14]
- UX spec — state colour values: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#state colour table]
- UX spec — CSS scoping discipline: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Linting convention]
- Epics — Story 1.1 ACs + deliverables: [Source: _bmad-output/planning-artifacts/epics.md#Story 1.1]
## Dev Agent Record
### Agent Model Used
Claude Sonnet 4.6 (claude-sonnet-4.6) via GitHub Copilot CLI
### Debug Log References
- ESLint 9.x uses flat config (`eslint.config.js`), not `.eslintrc.js` as story spec states. Used flat config with `@eslint/js`, browser globals, and FoundryVTT globals declared explicitly.
- LESS `*/` in block comments causes ParseError — switched all LESS `/** */` comments to `//` line comments.
- `tsconfig.json`: `moduleResolution: "node16"` requires `module: "Node16"` — changed to `moduleResolution: "bundler"` (TS 5+ only, compatible with ESNext module).
- Contract validators: destructuring `unknown` requires intermediate `Record<string, unknown>` cast for TypeScript strict mode.
- `scripts/package.mjs`: `catch(err)` needs `err instanceof Error ? err.message : String(err)` under strict TS.
- Test fixture/helper files with untyped stub functions use `// @ts-nocheck` pragma — deliberate, test infrastructure only.
- `globals` package available transitively via ESLint — used for `globals.browser` in flat config.
### Completion Notes List
- ✅ Task 1: `package.json` initialized, `npm install` exits 0; Node 24.14.1 / npm 11.4.0
- ✅ Task 2: `tsconfig.json` (moduleResolution: bundler), `eslint.config.js` (flat config), `vitest.config.js`, `.gitignore`
- ✅ Task 3: `module.json` v14 manifest with all required fields
- ✅ Task 4: `scripts/package.mjs` release script; produces `module.zip` (14 KB) at v0.1.0
- ✅ Task 5: `module.js` entry stub with `Hooks.once('init')` and `Hooks.once('ready')`
- ✅ Task 6: All 4 contract files fully implemented with factory + validator; all validators reject unknown keys, throw `TypeError` with field name
- ✅ Task 7: Full LESS token system; `npm run build``dist/styles/scrying-pool.css` ✅; VisibilityBadge `:root` exception documented
- ✅ Task 8: 49 unit tests across 4 contract test files; all pass; `tests/helpers/foundryAdapterMock.js` canonical mock established
- ✅ Task 9: `.gitea/workflows/ci.yml` — lint + typecheck + test + build on every push
- ✅ Task 10: `lang/en.json` — valid `{}` skeleton
- ✅ Task 11: 5 Handlebars template stubs created
- ✅ Task 12: Full pipeline — `lint``typecheck``test``build``release`
**Deviations from story spec:**
- `eslint.config.js` (flat config) used instead of `.eslintrc.js` (ESLint 9 dropped `.eslintrc.*` support)
- `moduleResolution: "bundler"` instead of `"node16"` (node16 requires module: Node16)
- `foundry-vtt-types` not installed — `Hooks` declared via minimal `src/types/foundry-globals.d.ts` instead
### File List
- `package.json`
- `package-lock.json`
- `tsconfig.json`
- `eslint.config.js`
- `vitest.config.js`
- `.gitignore`
- `module.json`
- `module.js`
- `scripts/package.mjs`
- `src/types/foundry-globals.d.ts`
- `src/utils/uuid.js`
- `src/contracts/visibility-matrix.js`
- `src/contracts/socket-message.js`
- `src/contracts/pending-op.js`
- `src/contracts/scene-preset.js`
- `styles/scrying-pool.less`
- `styles/tokens/_base.less`
- `styles/tokens/_states.less`
- `styles/tokens/_motion.less`
- `styles/tokens/_focus.less`
- `styles/components/_participant-card.less`
- `styles/components/_roster-strip.less`
- `styles/components/_directors-board.less`
- `styles/components/_scene-preset-panel.less`
- `styles/components/_notification.less`
- `styles/components/_player-badge.less`
- `styles/components/_player-panel.less`
- `lang/en.json`
- `templates/directors-board.hbs`
- `templates/participant-card.hbs`
- `templates/roster-strip.hbs`
- `templates/scene-preset-panel.hbs`
- `templates/player-panel.hbs`
- `.gitea/workflows/ci.yml`
- `tests/helpers/foundryAdapterMock.js`
- `tests/fixtures/socket-payloads.js`
- `tests/fixtures/visibility-states.js`
- `tests/fixtures/state-store-snapshots.js`
- `tests/fixtures/scene-preset.js`
- `tests/fixtures/pending-op.js`
- `tests/fixtures/foundry-adapter.js`
- `tests/unit/contracts/visibility-matrix.test.js`
- `tests/unit/contracts/socket-message.test.js`
- `tests/unit/contracts/pending-op.test.js`
- `tests/unit/contracts/scene-preset.test.js`
### Change Log
- 2025-05-21: Story 1.1 implementation complete — full scaffold, CI, LESS token system, contracts, test infrastructure (49 tests, all pass; full pipeline lint/typecheck/test/build/release exits 0)--- DIFF TO REVIEW ---
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c2ccb26
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+dist/
+node_modules/
+*.zip
+*.lock
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..4de7904
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,124 @@
+import js from "@eslint/js";
+import jsdoc from "eslint-plugin-jsdoc";
+import importPlugin from "eslint-plugin-import";
+import globals from "globals";
+import { fileURLToPath } from "url";
+import { dirname } from "path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default [
+ js.configs.recommended,
+ {
+ plugins: {
+ jsdoc,
+ import: importPlugin,
+ },
+ languageOptions: {
+ globals: {
+ // Browser built-ins (console, setTimeout, etc.)
+ ...globals.browser,
+ // FoundryVTT globals injected at runtime
+ Hooks: "readonly",
+ game: "readonly",
+ ui: "readonly",
+ canvas: "readonly",
+ foundry: "readonly",
+ CONFIG: "readonly",
+ CONST: "readonly",
+ },
+ },
+ rules: {
+ // Require JSDoc on all exported symbols
+ "jsdoc/require-jsdoc": [
+ "error",
+ {
+ publicOnly: true,
+ require: {
+ FunctionDeclaration: true,
+ MethodDefinition: true,
+ ClassDeclaration: true,
+ ArrowFunctionExpression: false,
+ FunctionExpression: false,
+ },
+ contexts: ["ExportNamedDeclaration > FunctionDeclaration"],
+ },
+ ],
+ "jsdoc/require-param": "warn",
+ "jsdoc/require-returns": "warn",
+
+ // Import boundary enforcement
+ "import/no-restricted-paths": [
+ "error",
+ {
+ zones: [
+ // src/core/ → may import src/contracts/ and src/utils/ ONLY
+ {
+ target: "./src/core",
+ from: "./src/foundry",
+ message: "src/core/ must not import from src/foundry/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/ui",
+ message: "src/core/ must not import from src/ui/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/notifications",
+ message: "src/core/ must not import from src/notifications/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/presets",
+ message: "src/core/ must not import from src/presets/",
+ },
+ // src/foundry/ → may import src/contracts/ and src/utils/ ONLY
+ {
+ target: "./src/foundry",
+ from: "./src/core",
+ message: "src/foundry/ must not import from src/core/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/ui",
+ message: "src/foundry/ must not import from src/ui/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/notifications",
+ message: "src/foundry/ must not import from src/notifications/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/presets",
+ message: "src/foundry/ must not import from src/presets/",
+ },
+ // src/contracts/ → no internal imports
+ {
+ target: "./src/contracts",
+ from: "./src",
+ message: "src/contracts/ must not import from other src/ modules",
+ },
+ // src/utils/ → no internal imports
+ {
+ target: "./src/utils",
+ from: "./src",
+ message: "src/utils/ must not import from other src/ modules",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ["tests/**/*.js"],
+ rules: {
+ // Relax JSDoc requirement for test files
+ "jsdoc/require-jsdoc": "off",
+ },
+ },
+ {
+ ignores: ["dist/", "node_modules/", "*.zip"],
+ },
+];
diff --git a/module.js b/module.js
new file mode 100644
index 0000000..a3ad2d7
--- /dev/null
+++ b/module.js
@@ -0,0 +1,24 @@
+/**
+ * module.js — Entry point and wiring diagram for Video View Manager (Scrying Pool).
+ *
+ * This file is the wiring diagram ONLY. It imports all modules, constructs them
+ * with injected dependencies, and holds NO business logic.
+ *
+ * Initialisation order:
+ * Hooks.once('init') → register world settings → construct FoundryAdapter
+ * → StateStore → SocketHandler (queue+drain)
+ * Hooks.once('ready') → VisibilityManager → SocketHandler.setReady()
+ * → NotificationBus → RoleRenderer → RosterStrip
+ * → DirectorsBoard (lazy, GM only)
+ */
+
+Hooks.once("init", () => {
+ console.log("[ScryingPool] init — module loading");
+ // Story 1.3+: register world settings, construct FoundryAdapter, StateStore, SocketHandler
+});
+
+Hooks.once("ready", () => {
+ console.log("[ScryingPool] ready — module active");
+ // Story 1.3+: construct VisibilityManager, NotificationBus, RoleRenderer, RosterStrip
+ // Story 1.5+: register DirectorsBoard (lazy, GM only)
+});
diff --git a/module.json b/module.json
new file mode 100644
index 0000000..e1cffdc
--- /dev/null
+++ b/module.json
@@ -0,0 +1,29 @@
+{
+ "id": "video-view-manager",
+ "title": "Video View Manager (Scrying Pool)",
+ "version": "0.1.0",
+ "description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
+ "authors": [
+ {
+ "name": "Morr"
+ }
+ ],
+ "compatibility": {
+ "minimum": "14",
+ "verified": "14"
+ },
+ "esmodules": [
+ "module.js"
+ ],
+ "styles": [
+ "dist/styles/scrying-pool.css"
+ ],
+ "languages": [
+ {
+ "lang": "en",
+ "name": "English",
+ "path": "lang/en.json"
+ }
+ ],
+ "flags": {}
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..fab7015
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "video-view-manager",
+ "version": "0.1.0",
+ "description": "FoundryVTT v14 module — Scrying Pool camera visibility control",
+ "type": "module",
+ "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"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "chokidar": "5.0.0",
+ "eslint": "^9.0.0",
+ "eslint-plugin-import": "^2.31.0",
+ "eslint-plugin-jsdoc": "^50.0.0",
+ "happy-dom": "^20.0.0",
+ "less": "4.6.4",
+ "typescript": "5.9.3",
+ "vitest": "2.1.8"
+ }
+}
diff --git a/scripts/package.mjs b/scripts/package.mjs
new file mode 100644
index 0000000..9a7300e
--- /dev/null
+++ b/scripts/package.mjs
@@ -0,0 +1,60 @@
+/**
+ * Release script — produces module.zip.
+ *
+ * Single version source of truth: reads version from package.json,
+ * writes it into module.json, then zips all release artefacts.
+ *
+ * Usage: node scripts/package.mjs
+ */
+
+import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
+import { resolve, dirname } from "path";
+import { fileURLToPath } from "url";
+import { createGzip } from "zlib";
+import { exec } from "child_process";
+import { promisify } from "util";
+
+const execAsync = promisify(exec);
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(__dirname, "..");
+
+// Read version from package.json (single source of truth)
+const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
+const { version } = pkg;
+
+// Write version into module.json
+const moduleJsonPath = resolve(ROOT, "module.json");
+const moduleJson = JSON.parse(readFileSync(moduleJsonPath, "utf8"));
+moduleJson.version = version;
+writeFileSync(moduleJsonPath, JSON.stringify(moduleJson, null, 2) + "\n", "utf8");
+console.log(`[ScryingPool] module.json version set to ${version}`);
+
+// Ensure dist/ exists (build should have run first)
+if (!existsSync(resolve(ROOT, "dist"))) {
+ console.error("[ScryingPool] dist/ not found — run npm run build first");
+ process.exit(1);
+}
+
+// Files and directories to include in module.zip
+const INCLUDE = [
+ "module.json",
+ "module.js",
+ "lang/",
+ "templates/",
+ "dist/",
+ "src/",
+];
+
+// Build zip using system zip command
+const targets = INCLUDE.filter((f) => existsSync(resolve(ROOT, f)));
+const zipArgs = targets.map((t) => `"${t}"`).join(" ");
+const zipCmd = `cd "${ROOT}" && zip -r module.zip ${zipArgs}`;
+
+console.log("[ScryingPool] Creating module.zip...");
+try {
+ await execAsync(zipCmd);
+ console.log(`[ScryingPool] module.zip created (v${version})`);
+} catch (err) {
+ console.error("[ScryingPool] zip failed:", err instanceof Error ? err.message : String(err));
+ process.exit(1);
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7d64af8
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "checkJs": true,
+ "strict": true,
+ "noEmit": true,
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowJs": true,
+ "lib": ["ESNext", "DOM"]
+ },
+ "include": ["src/**/*.js", "src/**/*.d.ts", "module.js", "scripts/**/*.mjs", "tests/**/*.js"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..c18efc0
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,23 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "happy-dom",
+ globals: false,
+ include: ["tests/**/*.test.js"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ include: ["src/**/*.js"],
+ exclude: ["src/contracts/**"],
+ },
+ },
+ resolve: {
+ alias: {
+ "@src": "/src",
+ "@contracts": "/src/contracts",
+ "@utils": "/src/utils",
+ "@tests": "/tests",
+ },
+ },
+});
@@ -0,0 +1,377 @@
# Blind Hunter Review Layer
## ROLE
You are **Blind Hunter** — an adversarial code reviewer. You have NO access to the project, spec, or any context. You only see the diff below.
## MISSION
Find problems. Be ruthless. Assume nothing is intentional. Look for:
- **Security vulnerabilities** (injection, XSS, path traversal, hardcoded secrets)
- **Bugs** (logical errors, race conditions, null dereferences)
- **Performance issues** (N+1 queries, unnecessary computations, memory leaks)
- **Anti-patterns** (god objects, circular dependencies, mutable globals)
- **Code smells** (duplicate code, long methods, magic numbers)
- **Best practice violations** (error handling, input validation, coding standards)
- **Anything suspicious** (unusual patterns, odd dependencies, weird configurations)
## OUTPUT FORMAT
Output ONLY a Markdown list of findings. No preamble, no summary. Each finding:
```markdown
- **[SEVERITY]** Short title — file:line — evidence/quote from diff
```
Severity: CRITICAL, HIGH, MEDIUM, LOW, INFO
## DIFF TO REVIEW
```diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c2ccb26
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+dist/
+node_modules/
+*.zip
+*.lock
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..4de7904
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,124 @@
+import js from "@eslint/js";
+import jsdoc from "eslint-plugin-jsdoc";
+import importPlugin from "eslint-plugin-import";
+import globals from "globals";
+import { fileURLToPath } from "url";
+import { dirname } from "path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default [
+ js.configs.recommended,
+ {
+ plugins: {
+ jsdoc,
+ import: importPlugin,
+ },
+ languageOptions: {
+ globals: {
+ // Browser built-ins (console, setTimeout, etc.)
+ ...globals.browser,
+ // FoundryVTT globals injected at runtime
+ Hooks: "readonly",
+ game: "readonly",
+ ui: "readonly",
+ canvas: "readonly",
+ foundry: "readonly",
+ CONFIG: "readonly",
+ CONST: "readonly",
+ },
+ },
+ rules: {
+ // Require JSDoc on all exported symbols
+ "jsdoc/require-jsdoc": [
+ "error",
+ {
+ publicOnly: true,
+ require: {
+ FunctionDeclaration: true,
+ MethodDefinition: true,
+ ClassDeclaration: true,
+ ArrowFunctionExpression: false,
+ FunctionExpression: false,
+ },
+ contexts: ["ExportNamedDeclaration > FunctionDeclaration"],
+ },
+ ],
+ "jsdoc/require-param": "warn",
+ "jsdoc/require-returns": "warn",
+
+ // Import boundary enforcement
+ "import/no-restricted-paths": [
+ "error",
+ {
+ zones: [
+ // src/core/ → may import src/contracts/ and src/utils/ ONLY
+ {
+ target: "./src/core",
+ from: "./src/foundry",
+ message: "src/core/ must not import from src/foundry/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/ui",
+ message: "src/core/ must not import from src/ui/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/notifications",
+ message: "src/core/ must not import from src/notifications/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/presets",
+ message: "src/core/ must not import from src/presets/",
+ },
+ // src/foundry/ → may import src/contracts/ and src/utils/ ONLY
+ {
+ target: "./src/foundry",
+ from: "./src/core",
+ message: "src/foundry/ must not import from src/core/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/ui",
+ message: "src/foundry/ must not import from src/ui/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/notifications",
+ message: "src/foundry/ must not import from src/notifications/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/presets",
+ message: "src/foundry/ must not import from src/presets/",
+ },
+ // src/contracts/ → no internal imports
+ {
+ target: "./src/contracts",
+ from: "./src",
+ message: "src/contracts/ must not import from other src/ modules",
+ },
+ // src/utils/ → no internal imports
+ {
+ target: "./src/utils",
+ from: "./src",
+ message: "src/utils/ must not import from other src/ modules",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ["tests/**/*.js"],
+ rules: {
+ // Relax JSDoc requirement for test files
+ "jsdoc/require-jsdoc": "off",
+ },
+ },
+ {
+ ignores: ["dist/", "node_modules/", "*.zip"],
+ },
+];
diff --git a/module.js b/module.js
new file mode 100644
index 0000000..a3ad2d7
--- /dev/null
+++ b/module.js
@@ -0,0 +1,24 @@
+/**
+ * module.js — Entry point and wiring diagram for Video View Manager (Scrying Pool).
+ *
+ * This file is the wiring diagram ONLY. It imports all modules, constructs them
+ * with injected dependencies, and holds NO business logic.
+ *
+ * Initialisation order:
+ * Hooks.once('init') → register world settings → construct FoundryAdapter
+ * → StateStore → SocketHandler (queue+drain)
+ * Hooks.once('ready') → VisibilityManager → SocketHandler.setReady()
+ * → NotificationBus → RoleRenderer → RosterStrip
+ * → DirectorsBoard (lazy, GM only)
+ */
+
+Hooks.once("init", () => {
+ console.log("[ScryingPool] init — module loading");
+ // Story 1.3+: register world settings, construct FoundryAdapter, StateStore, SocketHandler
+});
+
+Hooks.once("ready", () => {
+ console.log("[ScryingPool] ready — module active");
+ // Story 1.3+: construct VisibilityManager, NotificationBus, RoleRenderer, RosterStrip
+ // Story 1.5+: register DirectorsBoard (lazy, GM only)
+});
diff --git a/module.json b/module.json
new file mode 100644
index 0000000..e1cffdc
--- /dev/null
+++ b/module.json
@@ -0,0 +1,29 @@
+{
+ "id": "video-view-manager",
+ "title": "Video View Manager (Scrying Pool)",
+ "version": "0.1.0",
+ "description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
+ "authors": [
+ {
+ "name": "Morr"
+ }
+ ],
+ "compatibility": {
+ "minimum": "14",
+ "verified": "14"
+ },
+ "esmodules": [
+ "module.js"
+ ],
+ "styles": [
+ "dist/styles/scrying-pool.css"
+ ],
+ "languages": [
+ {
+ "lang": "en",
+ "name": "English",
+ "path": "lang/en.json"
+ }
+ ],
+ "flags": {}
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..fab7015
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "video-view-manager",
+ "version": "0.1.0",
+ "description": "FoundryVTT v14 module — Scrying Pool camera visibility control",
+ "type": "module",
+ "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"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "chokidar": "5.0.0",
+ "eslint": "^9.0.0",
+ "eslint-plugin-import": "^2.31.0",
+ "eslint-plugin-jsdoc": "^50.0.0",
+ "happy-dom": "^20.0.0",
+ "less": "4.6.4",
+ "typescript": "5.9.3",
+ "vitest": "2.1.8"
+ }
+}
diff --git a/scripts/package.mjs b/scripts/package.mjs
new file mode 100644
index 0000000..9a7300e
--- /dev/null
+++ b/scripts/package.mjs
@@ -0,0 +1,60 @@
+/**
+ * Release script — produces module.zip.
+ *
+ * Single version source of truth: reads version from package.json,
+ * writes it into module.json, then zips all release artefacts.
+ *
+ * Usage: node scripts/package.mjs
+ */
+
+import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
+import { resolve, dirname } from "path";
+import { fileURLToPath } from "url";
+import { createGzip } from "zlib";
+import { exec } from "child_process";
+import { promisify } from "util";
+
+const execAsync = promisify(exec);
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(__dirname, "..");
+
+// Read version from package.json (single source of truth)
+const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
+const { version } = pkg;
+
+// Write version into module.json
+const moduleJsonPath = resolve(ROOT, "module.json");
+const moduleJson = JSON.parse(readFileSync(moduleJsonPath, "utf8"));
+moduleJson.version = version;
+writeFileSync(moduleJsonPath, JSON.stringify(moduleJson, null, 2) + "\n", "utf8");
+console.log(`[ScryingPool] module.json version set to ${version}`);
+
+// Ensure dist/ exists (build should have run first)
+if (!existsSync(resolve(ROOT, "dist"))) {
+ console.error("[ScryingPool] dist/ not found — run npm run build first");
+ process.exit(1);
+}
+
+// Files and directories to include in module.zip
+const INCLUDE = [
+ "module.json",
+ "module.js",
+ "lang/",
+ "templates/",
+ "dist/",
+ "src/",
+];
+
+// Build zip using system zip command
+const targets = INCLUDE.filter((f) => existsSync(resolve(ROOT, f)));
+const zipArgs = targets.map((t) => `"${t}"`).join(" ");
+const zipCmd = `cd "${ROOT}" && zip -r module.zip ${zipArgs}`;
+
+console.log("[ScryingPool] Creating module.zip...");
+try {
+ await execAsync(zipCmd);
+ console.log(`[ScryingPool] module.zip created (v${version})`);
+} catch (err) {
+ console.error("[ScryingPool] zip failed:", err instanceof Error ? err.message : String(err));
+ process.exit(1);
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7d64af8
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "checkJs": true,
+ "strict": true,
+ "noEmit": true,
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowJs": true,
+ "lib": ["ESNext", "DOM"]
+ },
+ "include": ["src/**/*.js", "src/**/*.d.ts", "module.js", "scripts/**/*.mjs", "tests/**/*.js"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..c18efc0
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,23 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "happy-dom",
+ globals: false,
+ include: ["tests/**/*.test.js"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ include: ["src/**/*.js"],
+ exclude: ["src/contracts/**"],
+ },
+ },
+ resolve: {
+ alias: {
+ "@src": "/src",
+ "@contracts": "/src/contracts",
+ "@utils": "/src/utils",
+ "@tests": "/tests",
+ },
+ },
+});
@@ -0,0 +1,381 @@
# Edge Case Hunter Review Layer
## ROLE
You are **Edge Case Hunter** — a meticulous reviewer focused on boundary conditions and unusual scenarios. You have read access to the project files but ONLY for understanding context. Your primary input is the diff below.
## MISSION
Walk every branching path and boundary condition. Look for:
- **Unchecked assumptions** (what if this is null/undefined/empty/zero?)
- **Off-by-one errors** (loop boundaries, array indices, string slicing)
- **Type coercion issues** (== vs ===, truthy/falsy confusion)
- **Concurrency problems** (race conditions, async/await mishandling)
- **Edge input values** (empty strings, very long strings, special characters, unicode)
- **State transitions** (what happens after error? after retry? after timeout?)
- **Error handling gaps** (unhandled exceptions, missing error cases)
- **API contract violations** (return types, parameter validation, side effects)
## OUTPUT FORMAT
Output ONLY a Markdown list of findings. No preamble, no summary. Each finding:
```markdown
- **[SEVERITY]** Short title — file:line — edge case description + evidence
```
Severity: CRITICAL, HIGH, MEDIUM, LOW, INFO
## PROJECT ROOT
/home/morr/work/foundryvtt/video-view-manager
## DIFF TO REVIEW
```diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c2ccb26
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+dist/
+node_modules/
+*.zip
+*.lock
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..4de7904
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,124 @@
+import js from "@eslint/js";
+import jsdoc from "eslint-plugin-jsdoc";
+import importPlugin from "eslint-plugin-import";
+import globals from "globals";
+import { fileURLToPath } from "url";
+import { dirname } from "path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default [
+ js.configs.recommended,
+ {
+ plugins: {
+ jsdoc,
+ import: importPlugin,
+ },
+ languageOptions: {
+ globals: {
+ // Browser built-ins (console, setTimeout, etc.)
+ ...globals.browser,
+ // FoundryVTT globals injected at runtime
+ Hooks: "readonly",
+ game: "readonly",
+ ui: "readonly",
+ canvas: "readonly",
+ foundry: "readonly",
+ CONFIG: "readonly",
+ CONST: "readonly",
+ },
+ },
+ rules: {
+ // Require JSDoc on all exported symbols
+ "jsdoc/require-jsdoc": [
+ "error",
+ {
+ publicOnly: true,
+ require: {
+ FunctionDeclaration: true,
+ MethodDefinition: true,
+ ClassDeclaration: true,
+ ArrowFunctionExpression: false,
+ FunctionExpression: false,
+ },
+ contexts: ["ExportNamedDeclaration > FunctionDeclaration"],
+ },
+ ],
+ "jsdoc/require-param": "warn",
+ "jsdoc/require-returns": "warn",
+
+ // Import boundary enforcement
+ "import/no-restricted-paths": [
+ "error",
+ {
+ zones: [
+ // src/core/ → may import src/contracts/ and src/utils/ ONLY
+ {
+ target: "./src/core",
+ from: "./src/foundry",
+ message: "src/core/ must not import from src/foundry/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/ui",
+ message: "src/core/ must not import from src/ui/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/notifications",
+ message: "src/core/ must not import from src/notifications/",
+ },
+ {
+ target: "./src/core",
+ from: "./src/presets",
+ message: "src/core/ must not import from src/presets/",
+ },
+ // src/foundry/ → may import src/contracts/ and src/utils/ ONLY
+ {
+ target: "./src/foundry",
+ from: "./src/core",
+ message: "src/foundry/ must not import from src/core/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/ui",
+ message: "src/foundry/ must not import from src/ui/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/notifications",
+ message: "src/foundry/ must not import from src/notifications/",
+ },
+ {
+ target: "./src/foundry",
+ from: "./src/presets",
+ message: "src/foundry/ must not import from src/presets/",
+ },
+ // src/contracts/ → no internal imports
+ {
+ target: "./src/contracts",
+ from: "./src",
+ message: "src/contracts/ must not import from other src/ modules",
+ },
+ // src/utils/ → no internal imports
+ {
+ target: "./src/utils",
+ from: "./src",
+ message: "src/utils/ must not import from other src/ modules",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ["tests/**/*.js"],
+ rules: {
+ // Relax JSDoc requirement for test files
+ "jsdoc/require-jsdoc": "off",
+ },
+ },
+ {
+ ignores: ["dist/", "node_modules/", "*.zip"],
+ },
+];
diff --git a/module.js b/module.js
new file mode 100644
index 0000000..a3ad2d7
--- /dev/null
+++ b/module.js
@@ -0,0 +1,24 @@
+/**
+ * module.js — Entry point and wiring diagram for Video View Manager (Scrying Pool).
+ *
+ * This file is the wiring diagram ONLY. It imports all modules, constructs them
+ * with injected dependencies, and holds NO business logic.
+ *
+ * Initialisation order:
+ * Hooks.once('init') → register world settings → construct FoundryAdapter
+ * → StateStore → SocketHandler (queue+drain)
+ * Hooks.once('ready') → VisibilityManager → SocketHandler.setReady()
+ * → NotificationBus → RoleRenderer → RosterStrip
+ * → DirectorsBoard (lazy, GM only)
+ */
+
+Hooks.once("init", () => {
+ console.log("[ScryingPool] init — module loading");
+ // Story 1.3+: register world settings, construct FoundryAdapter, StateStore, SocketHandler
+});
+
+Hooks.once("ready", () => {
+ console.log("[ScryingPool] ready — module active");
+ // Story 1.3+: construct VisibilityManager, NotificationBus, RoleRenderer, RosterStrip
+ // Story 1.5+: register DirectorsBoard (lazy, GM only)
+});
diff --git a/module.json b/module.json
new file mode 100644
index 0000000..e1cffdc
--- /dev/null
+++ b/module.json
@@ -0,0 +1,29 @@
+{
+ "id": "video-view-manager",
+ "title": "Video View Manager (Scrying Pool)",
+ "version": "0.1.0",
+ "description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
+ "authors": [
+ {
+ "name": "Morr"
+ }
+ ],
+ "compatibility": {
+ "minimum": "14",
+ "verified": "14"
+ },
+ "esmodules": [
+ "module.js"
+ ],
+ "styles": [
+ "dist/styles/scrying-pool.css"
+ ],
+ "languages": [
+ {
+ "lang": "en",
+ "name": "English",
+ "path": "lang/en.json"
+ }
+ ],
+ "flags": {}
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..fab7015
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "video-view-manager",
+ "version": "0.1.0",
+ "description": "FoundryVTT v14 module — Scrying Pool camera visibility control",
+ "type": "module",
+ "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"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "chokidar": "5.0.0",
+ "eslint": "^9.0.0",
+ "eslint-plugin-import": "^2.31.0",
+ "eslint-plugin-jsdoc": "^50.0.0",
+ "happy-dom": "^20.0.0",
+ "less": "4.6.4",
+ "typescript": "5.9.3",
+ "vitest": "2.1.8"
+ }
+}
diff --git a/scripts/package.mjs b/scripts/package.mjs
new file mode 100644
index 0000000..9a7300e
--- /dev/null
+++ b/scripts/package.mjs
@@ -0,0 +1,60 @@
+/**
+ * Release script — produces module.zip.
+ *
+ * Single version source of truth: reads version from package.json,
+ * writes it into module.json, then zips all release artefacts.
+ *
+ * Usage: node scripts/package.mjs
+ */
+
+import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
+import { resolve, dirname } from "path";
+import { fileURLToPath } from "url";
+import { createGzip } from "zlib";
+import { exec } from "child_process";
+import { promisify } from "util";
+
+const execAsync = promisify(exec);
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(__dirname, "..");
+
+// Read version from package.json (single source of truth)
+const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
+const { version } = pkg;
+
+// Write version into module.json
+const moduleJsonPath = resolve(ROOT, "module.json");
+const moduleJson = JSON.parse(readFileSync(moduleJsonPath, "utf8"));
+moduleJson.version = version;
+writeFileSync(moduleJsonPath, JSON.stringify(moduleJson, null, 2) + "\n", "utf8");
+console.log(`[ScryingPool] module.json version set to ${version}`);
+
+// Ensure dist/ exists (build should have run first)
+if (!existsSync(resolve(ROOT, "dist"))) {
+ console.error("[ScryingPool] dist/ not found — run npm run build first");
+ process.exit(1);
+}
+
+// Files and directories to include in module.zip
+const INCLUDE = [
+ "module.json",
+ "module.js",
+ "lang/",
+ "templates/",
+ "dist/",
+ "src/",
+];
+
+// Build zip using system zip command
+const targets = INCLUDE.filter((f) => existsSync(resolve(ROOT, f)));
+const zipArgs = targets.map((t) => `"${t}"`).join(" ");
+const zipCmd = `cd "${ROOT}" && zip -r module.zip ${zipArgs}`;
+
+console.log("[ScryingPool] Creating module.zip...");
+try {
+ await execAsync(zipCmd);
+ console.log(`[ScryingPool] module.zip created (v${version})`);
+} catch (err) {
+ console.error("[ScryingPool] zip failed:", err instanceof Error ? err.message : String(err));
+ process.exit(1);
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7d64af8
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "checkJs": true,
+ "strict": true,
+ "noEmit": true,
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowJs": true,
+ "lib": ["ESNext", "DOM"]
+ },
+ "include": ["src/**/*.js", "src/**/*.d.ts", "module.js", "scripts/**/*.mjs", "tests/**/*.js"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..c18efc0
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,23 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "happy-dom",
+ globals: false,
+ include: ["tests/**/*.test.js"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ include: ["src/**/*.js"],
+ exclude: ["src/contracts/**"],
+ },
+ },
+ resolve: {
+ alias: {
+ "@src": "/src",
+ "@contracts": "/src/contracts",
+ "@utils": "/src/utils",
+ "@tests": "/tests",
+ },
+ },
+});
@@ -0,0 +1,23 @@
# Deferred Work
## Deferred from: code review of 1-1-module-scaffold-cicd-pipeline-and-design-token-system (2026-05-21)
- [ ] Vulnerable dependency CVE-2023-43645 — flat-cache-4.0.1 (needs verification)
- [ ] Version conflicts — Multiple versions: make-dir (5.7.2, 6.3.1, 7.8.0), debug (4.4.3, 3.2.7) (npm resolution, pre-existing)
## Deferred from: Group 6 (Tests) - 2026-05-21
- [ ] MAX_PAYLOAD_BYTES size boundary test — Story 1.2+ SocketHandler responsibility (EC-001)
- [ ] Multi-track stream test — Story 1.2+ WebRTC surface (EC-010)
- [ ] Invalid snapshot fixtures (wrong version, null version) — Story 1.3+ StateStore (EC-012)
- [ ] Circular reference in gameWebrtc handling — Story 1.2+ WebRTC probe (EC-020)
## Deferred from: Group 7 (CI/CD & i18n) - 2026-05-21
- [ ] Missing failure notifications — Story 1.2+ (EC-002)
- [ ] Missing build artifact upload — Story 1.2+ (EC-006)
- [ ] No Node.js matrix testing — Story 1.2+ (EC-004)
- [ ] Missing concurrency control — Story 1.2+ (EC-008)
- [ ] Missing i18n schema validation — Story 1.2+ (EC-I18N-003)
- [ ] ubuntu-latest not pinned — Story 1.2+ (EC-003)
- [ ] Missing coverage upload — Story 1.2+ (EC-005)
@@ -0,0 +1,73 @@
# generated: 2026-05-21T01:00:00+02:00
# last_updated: 2026-05-21T01:00:00+02:00
# project: video-view-manager
# project_key: NOKEY
# tracking_system: file-system
# story_location: _bmad-output/implementation-artifacts
# STATUS DEFINITIONS:
# ==================
# Epic Status:
# - backlog: Epic not yet started
# - in-progress: Epic actively being worked on
# - done: All stories in epic completed
#
# Epic Status Transitions:
# - backlog → in-progress: Automatically when first story is created (via create-story)
# - in-progress → done: Manually when all stories reach 'done' status
#
# Story Status:
# - backlog: Story only exists in epic file
# - ready-for-dev: Story file created in stories folder
# - in-progress: Developer actively working on implementation
# - review: Ready for code review (via Dev's code-review workflow)
# - done: Story completed
#
# Retrospective Status:
# - optional: Can be completed but not required
# - done: Retrospective has been completed
#
# WORKFLOW NOTES:
# ===============
# - Epic transitions to 'in-progress' automatically when first story is created
# - Stories can be worked in parallel if team capacity allows
# - Developer typically creates next story after previous one is 'done' to incorporate learnings
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
generated: "2026-05-21T01:00:00+02:00"
last_updated: "2026-05-22T00:00:00+02:00"
project: video-view-manager
project_key: NOKEY
tracking_system: file-system
story_location: _bmad-output/implementation-artifacts
development_status:
# Epic 1: Core Camera Visibility Control
epic-1: in-progress
1-1-module-scaffold-cicd-pipeline-and-design-token-system: done
1-2-webrtc-spike-track-disabling-api-validation: done
1-3-data-layer-foundryadapter-statestore-and-socket-infrastructure: backlog
1-4-core-logic-scryingpoolcontroller-and-visibilitymanager: backlog
1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration: backlog
1-6-player-camera-status-badge: backlog
epic-1-retrospective: optional
# Epic 2: Player Notifications & Director's Board
epic-2: backlog
2-1-notificationbus-and-notification-verbosity: backlog
2-2-directors-board-core-layout-and-participant-toggle: backlog
2-3-directors-board-bulk-actions-spotlight-and-keyboard-shortcuts: backlog
epic-2-retrospective: optional
# Epic 3: Scene-Aware Camera Automation (Scene Presets)
epic-3: backlog
3-1-save-and-load-scene-presets: backlog
3-2-scene-auto-apply-and-confirmationbar: backlog
3-3-preset-import-and-export: backlog
epic-3-retrospective: optional
# Epic 4: Player Privacy Panel
epic-4: backlog
4-1-player-privacy-panel-and-automation-opt-ins: backlog
4-2-custom-portrait-fallback: backlog
epic-4-retrospective: optional