# Story 1.5: GM Control UI — ScryingPoolStrip, ActionPopover & AV Tile Integration Status: done ## Story As a **GM**, I want to right-click any participant's AV tile to show or hide their camera feed, and see all feed states at a glance in the ScryingPoolStrip, So that I can control what the table sees in a single interaction without disrupting the session. ## Acceptance Criteria **AC-1 — ScryingPoolStrip appears on ready:** **Given** the module is active and the user is GM **When** FoundryVTT's `ready` hook completes **Then** `ScryingPoolStrip` appears as a floating `ApplicationV2` window showing all connected participants **And** its position (`left`, `top`), open state, and expanded state persist to the GM's user flag `{ left, top, open, expanded }` **AC-2 — Collapsed/expanded toggle:** **Given** the ScryingPoolStrip is in collapsed state **When** the GM clicks the expand toggle **Then** the strip transitions via `max-width` CSS transition (never `width` animation): collapsed = 44px avatar-only rail; expanded = 240px rich rows **AC-3 — ParticipantAvatar rendering:** **Given** the strip renders participants **When** it displays each `ParticipantAvatar` **Then** each avatar is a 44×44px container with a 32px rounded avatar + `StateRing` + 12px corner badge at bottom-right **And** `StateRing` uses the correct variant per state: `--solid` (active/self-muted), `--dashed` (hidden/cam-lost), `--pending` (animated pulse), `--revert` (amber flash 200ms on revert) **And** all `StateRing` animations are gated under `@media (prefers-reduced-motion: no-preference)` **AC-4 — Pending op ring:** **Given** a PendingOp is in-flight for a participant **When** the strip renders **Then** that participant's `StateRing` shows the `--pending` animated pulse **And** NO `ui.notifications` toast fires on successful state change (success uses ambient ring only — tier-1/2 feedback) **AC-5 — Right-click context menu:** **Given** a GM right-clicks a participant's avatar in the ScryingPoolStrip **When** the context menu appears **Then** the option reads exactly **"Hide from table"** (never a synonym) **And** selecting it calls `ScryingPoolController.action()` and transitions state to `hidden` **AC-6 — ActionPopover on click:** **Given** a GM clicks a participant in the ScryingPoolStrip **When** the `ActionPopover` opens **Then** it is a native `` anchored via `StripOverlayLayer.getBoundingClientRect()` relative to the strip **And** the primary CTA reads exactly **"Hide from table"** or **"Show to table"** **And** the primary CTA is `disabled` + `aria-disabled="true"` while a `PendingOp` is in-flight **And** Esc / click-outside dismiss the popover and return focus to the triggering avatar **And** only one `ActionPopover` is open at a time (supersede pattern) **AC-7 — StripOverlayLayer overlay container:** **Given** `StripOverlayLayer` is the parent for all positioned overlays **When** any overlay is positioned **Then** it is a child of the single `StripOverlayLayer` (`position: absolute; inset: 0; pointer-events: none; overflow: visible`); children restore `pointer-events: auto` **AC-8 — AV tile state indicators:** **Given** a visibility change is dispatched **When** the socket broadcast completes **Then** all clients' AV tiles update state indicators within 500ms **And** no AV tile layout shift or reflow occurs for any of the 8 participant states **And** `AVTileAdapter.mount(userId, element)` is idempotent — calling it twice does not duplicate elements **AC-9 — Hidden state on GM tile view:** **Given** a participant is `hidden` **When** the GM views their AV tile **Then** it renders at reduced opacity with a lock overlay and "Camera hidden by GM" tooltip **And** the GM still hears that participant's audio **AC-10 — Portrait Fallback:** **Given** a participant has no camera (`never-connected` or `cam-lost`) **When** their tile renders **Then** Portrait Fallback (FoundryVTT user avatar → system placeholder) displays at AV tile dimensions with no layout shift **AC-11 — EmptyStatePanel:** **Given** no participants are connected **When** the ScryingPoolStrip renders **Then** `EmptyStatePanel` shows "No participants yet" with a slow breathing-pulse eye icon (static under `prefers-reduced-motion`) **And** the panel is NOT styled as an error state **AC-12 — GM self-feed setting:** **Given** the GM opens module settings **When** they locate "Show my own feed to myself" (default ON) **Then** toggling it hides/shows the GM's self-view immediately without errors **AC-13 — Null webrtc guard:** **Given** `game.webrtc` is null (AV disabled) **When** the module loads **Then** `ScryingPoolStrip` is not rendered and no console errors appear **Accessibility:** **AC-14 — ParticipantAvatar accessibility:** **Given** a screen reader user navigates to a `ParticipantAvatar` **When** focus lands **Then** `role="button"`, `aria-label="[Name] — [state label]"` is announced **And** `aria-pressed` reflects popover-open state **AC-15 — ActionPopover keyboard navigation:** **Given** a keyboard user opens an `ActionPopover` **When** it opens **Then** focus moves to the primary CTA **And** Tab/Shift+Tab cycles through popover controls only **And** Esc closes it and returns focus to the triggering avatar **AC-16 — Reduced motion:** **Given** `prefers-reduced-motion: reduce` is active **When** any animated state occurs **Then** all `StateRing` animations are fully suppressed; static icons provide state information **AC-17 — Second-signal rule:** **Given** any participant state is rendered **When** it is visually displayed **Then** colour is never the only signal: each state also has a distinct icon, shape, or motion indicator **And** all state colour tokens meet WCAG AA contrast against both Foundry dark and light themes **AC-18 — Canonical action label:** **Given** a canonical action label appears on any surface **When** it is displayed **Then** it reads exactly "Hide from table" or "Show to table" (never synonyms) **And** on first hover a tooltip variant sets `firstHideTooltip` flag; subsequent hovers show only the canonical label --- ## Tasks / Subtasks - [x] Task 1: Create `src/ui/shared/AVTileAdapter.js` (AC: 8, 9, 10) - [x] 1.1: Write failing tests in `tests/unit/ui/shared/AVTileAdapter.test.js` first (TDD red) - [x] 1.2: Implement `constructor(adapter)` — side-effect free; stores adapter reference; no DOM access in constructor - [x] 1.3: Implement `mount(userId, element)` — idempotent: query tile by `[data-user-id="${userId}"]`; append element with `data-sp-mount` attribute; no-op + `console.warn('[ScryingPool]', ...)` if tile not found (fail-open); no duplicate if element already present - [x] 1.4: Implement `unmount(userId)` — remove all `[data-sp-mount]` children from tile; no-op if tile not found - [x] 1.5: Implement `setStateClass(userId, stateName)` — remove all `sp-state-*` classes from tile; add `sp-state-${stateName}` (no-op if tile not found, with console.warn) - [x] 1.6: Implement `onTileRerender(userId, callback)` — attach scoped `MutationObserver` (`childList: true, subtree: false`) to the tile element; call `callback(tileElement)` when DOM changes detected; store observer by userId for cleanup; no-op if tile not found - [x] 1.7: Implement `disconnect()` — disconnect all stored MutationObservers; clear internal observer map; safe to call multiple times - [x] 1.8: Confirm tests green, run full suite (no regressions) - [x] Task 2: Create `src/ui/RoleRenderer.js` (AC: 8, 9, 10, 12, 13) - [x] 2.1: Write failing tests in `tests/unit/ui/RoleRenderer.test.js` first (TDD red) - [x] 2.2: Implement `constructor(stateStore, scryingPoolController, avTileAdapter, adapter)` — side-effect free; store all injected deps; no Hooks registration in constructor - [x] 2.3: Implement `init()` — register `Hooks.on('scrying-pool:stateChanged', ...)` to call `_applyAVTileState(userId, state)`; register `Hooks.on('scrying-pool:controllerAction', ...)` to call `_onControllerAction(data)` for pending ring updates; register `Hooks.on('updateUser', ...)` for mid-session role-change rebuilds - [x] 2.4: Implement `_applyAVTileState(userId, state)` — resolve state precedence (see architecture precedence table), call `avTileAdapter.setStateClass(userId, resolvedState)`, mount/unmount lock overlay for `hidden`, mount/unmount portrait fallback for `never-connected`/`cam-lost` - [x] 2.5: Implement `_onControllerAction({ participantId, targetState, source })` — for `pending` ops in-flight: add `sp-state-pending` class via `avTileAdapter.setStateClass(participantId, 'pending')`; on echo/confirmation, restore actual state - [x] 2.6: Implement null webrtc guard: check `adapter.users.isGM()` and `game.webrtc` (via adapter); if AV disabled, do NOT construct ScryingPoolStrip; log `console.log('[ScryingPool] AV disabled — ScryingPoolStrip not rendered')` - [x] 2.7: Implement `openStrip()` / `closeStrip()` — construct `ScryingPoolStrip` singleton lazily; open/close it (GM only) - [x] 2.8: Confirm tests green, run full suite (no regressions) - [x] Task 3: Create `src/ui/gm/ScryingPoolStrip.js` + update `templates/roster-strip.hbs` (AC: 1, 2, 3, 4, 5, 6, 7, 11, 13, 14, 15, 16, 17, 18) - [x] 3.1: Write failing tests in `tests/unit/ui/gm/ScryingPoolStrip.test.js` (TDD red — test logic, not ApplicationV2 rendering) - [x] 3.2: Implement `ScryingPoolStrip extends Application` (using `Application` class for simpler FoundryVTT v14 compatibility; reference Architecture §Initialisation Order; see Dev Notes for ApplicationV2 vs Application guidance) - [x] 3.3: Implement `static get defaultOptions()` — set `id: 'scrying-pool-strip'`, `template: 'modules/video-view-manager/templates/roster-strip.hbs'`, `popOut: true`, `resizable: false`, `title: 'Scrying Pool'` - [x] 3.4: Implement `getData()` — build participant list from `stateStore`; return `{ participants, isExpanded, isEmpty }` — see Dev Notes for participant data shape - [x] 3.5: Implement `activateListeners(html)` — bind click on `.sp-participant-avatar` → `_openPopover(participantId, el)`, right-click → `_openContextMenu(participantId, el)`, expand toggle → `_toggleExpanded()` - [x] 3.6: Implement position persistence — on `close`: save `{ left, top, open: false, expanded }` to `game.user.setFlag('video-view-manager', 'stripState', {...})`; on `render`: restore from flag or use default position - [x] 3.7: Implement `_toggleExpanded()` — toggle `.is-expanded` class on strip element; save `expanded` to user flag - [x] 3.8: Implement `_openPopover(participantId, anchorEl)` — supersede existing popover (call `close('superseded')` on `this._activePopover`), create new `ActionPopover`, anchor via `getBoundingClientRect()` relative to strip, store ref in `this._activePopover` - [x] 3.9: Implement `_openContextMenu(participantId, anchorEl)` — build Foundry-style context menu with single entry: `{ name: 'Hide from table', icon: 'fas fa-eye-slash', callback: () => this._dispatchAction(participantId) }`; use canonical label constant (see Dev Notes) - [x] 3.10: Implement `_dispatchAction(participantId)` — determine target state (current=active → hidden; else → active); call `scryingPoolController.action('strip', participantId, targetState, generateOpId(), this._getRevision(participantId))` - [x] 3.11: Update `templates/roster-strip.hbs` with actual ScryingPoolStrip template markup — see Dev Notes §Template Structure - [x] 3.12: Implement `firstStripOpen` tip — on first open (flag unset): show right-click affordance tip in strip header; set `game.user.setFlag('video-view-manager', 'firstStripOpen', true)`; never show again - [x] 3.13: Confirm tests green, run full suite (no regressions) - [x] Task 4: Implement `ActionPopover` class inside `src/ui/gm/ScryingPoolStrip.js` (AC: 6, 15) - [x] 4.1: Implement `ActionPopover` class (not exported; internal to the gm/ layer; or extract to `src/ui/gm/ActionPopover.js` if file grows unwieldy — dev agent's call) - [x] 4.2: Implement `constructor(participantId, currentState, anchorRect, stripElement, onAction)` — build `` element with `h3` name + state label, primary CTA button (`data-action="primary-cta"`), aria attributes - [x] 4.3: Implement `open(anchorEl)` — call `dialog.showModal()`; position via `anchorRect.getBoundingClientRect()` relative to strip; focus primary CTA; attach click-outside listener (click on backdrop area dismisses) - [x] 4.4: Implement `close(reason)` — call `dialog.close(reason)`; remove click-outside listener; return focus to triggering avatar - [x] 4.5: Implement disabled state during PendingOp — primary CTA gets `disabled` + `aria-disabled="true"` attribute when `ScryingPoolController` has a pending op for this participant; listen to `scrying-pool:controllerAction` hook to update - [x] 4.6: Wire Esc via native `` cancel event → call `close()`; return focus to trigger - [x] Task 5: Add CSS — LESS styles for all new components (AC: 2, 3, 4, 16, 17) - [x] 5.1: Add `StateRing` CSS variants to `styles/components/_roster-strip.less` (or extract to `styles/components/_state-ring.less` and `@import` it): `.sp-state-ring--solid`, `--dashed`, `--pending`, `--revert` — see Dev Notes §StateRing CSS spec - [x] 5.2: Add `ParticipantAvatar` layout CSS: 44×44px container, 32px rounded avatar, 12px corner badge bottom-right; hover action rail (fixed-width, reveal via `opacity/visibility/pointer-events`, never `display:none`) - [x] 5.3: Add ScryingPoolStrip layout CSS: floating window, collapsed/expanded states using `max-width` transition (never `width`), `.is-expanded` modifier - [x] 5.4: Add AV tile overlay styles in `styles/components/_roster-strip.less` (scoped to `.scrying-pool` for strip, on `:root` for AV tile tokens): `sp-state-hidden` → reduced opacity + lock-overlay icon; portrait fallback sizing (AV tile dimensions, no layout shift) - [x] 5.5: Add `EmptyStatePanel` CSS: breathing-pulse eye icon (gated under `prefers-reduced-motion: no-preference`), centred layout, NOT styled as error - [x] 5.6: Run `npm run build` — exits 0 - [x] Task 6: Update `module.js` — wire RoleRenderer and ScryingPoolStrip into ready hook (AC: 1, 12, 13) - [x] 6.1: Add imports: `import { RoleRenderer } from './src/ui/RoleRenderer.js';` + `import { AVTileAdapter } from './src/ui/shared/AVTileAdapter.js';` - [x] 6.2: Add module-level `let roleRenderer; let avTileAdapter;` - [x] 6.3: In `Hooks.once('ready')`: after `scryingPoolController.init()`, construct `avTileAdapter = new AVTileAdapter(adapter)` then `roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter)` then `roleRenderer.init()` - [x] 6.4: If `adapter.users.isGM()`, call `roleRenderer.openStrip()` to render ScryingPoolStrip - [x] 6.5: Update init order comment in module.js: remove `// Story 1.5: NotificationBus → RoleRenderer → RosterStrip` placeholder; document actual current order; add `// Story 2.1: NotificationBus` placeholder for next story - [x] 6.6: Run full pipeline — lint + typecheck + test (all must pass) - [x] Task 7: Pipeline validation (AC: all) - [x] 7.1: `npm run lint` — exits 0 (no new errors beyond the 7 pre-existing in scripts/package.mjs) - [x] 7.2: `npm run typecheck` — exits 0 - [x] 7.3: `npm run test` — all tests pass (≥181 baseline + ~40 new = ≥221 expected) - [x] 7.4: `npm run build` — exits 0 (LESS compiles cleanly) --- ### Review Findings #### Decision Needed *(None) #### Patch Required - [x] [Review][Patch] Race condition: non-atomic pending op check [ScryingPoolController.js:119] — `hasPendingOp()` check is non-atomic; concurrent calls can bypass guard, creating multiple pending ops for same participant — **Fixed: Added atomic check before registering pending op** - [x] [Review][Patch] Echo doesn't verify pending op exists [ScryingPoolController.js:164] — Confirms any opId without checking `_pendingOps.has()` or opId match; can confirm stale/nonexistent ops — **Fixed: Verify pending op exists and opId matches before confirming** - [x] [Review][Patch] Pending op key mismatch [ScryingPoolController.js:123,167] — `action()` stores by `participantId`, `_onEcho` deletes by `userId`; if mismatch, pending op leaks and never cleaned up — **Fixed: Consistent use of userId/participantId; verify opId matches** - [x] [Review][Patch] Future revisions silently allowed [ScryingPoolController.js:119] — Latest-revision-wins guard only rejects `baseRevision < currentRevision`; allows `baseRevision > currentRevision` which may overwrite newer state — **Fixed: Changed to strict equality check (`!==`)** - [x] [Review][Patch] No targetState validation [ScryingPoolController.js:117] — Accepts any string for `targetState`; optimistic update and socket emit happen before StateStore rejection — **Fixed: Validate against VISIBILITY_STATES** - [x] [Review][Patch] Memory leak: unbounded maps [ScryingPoolController.js:28-30] — `_pendingOps` and `_revisions` maps have no cleanup on participant disconnect; grow unbounded over time — **Fixed: Added cleanupParticipant() and cleanupAll() methods; cleanupPendingOp now also cleans revisions** - [x] [Review][Patch] Uncaught stateStore exceptions [ScryingPoolController.js:117-121] — `getState()` and `setVisibility()` calls not wrapped in try-catch; pending op registered but state may be inconsistent if they throw — **Fixed: Wrapped in try-catch blocks** - [x] [Review][Patch] Concurrent actions overwrite pending op [ScryingPoolController.js:123] — If `action()` called twice for same participantId before first echo, second overwrites first's PendingOp; first echo fails to find its op — **Fixed: Check for existing pending op before overwriting** - [x] [Review][Patch] Binary state assumption [VisibilityManager.js:59-63] — Only checks `state === 'hidden'` to disable track; other states ('offline', 'cam-lost', 'ghost') incorrectly treated as enableTrack — **Fixed: Handle all hidden-like states** - [x] [Review][Patch] No webrtc method validation [VisibilityManager.js:61-63] — Checks `mode !== 'track-disable' || !webrtc` but assumes webrtc has `disableTrack`/`enableTrack` if non-null — **Fixed: Validate methods exist before calling** - [x] [Review][Patch] Mode type validation missing [VisibilityManager.js:53] — `mode !== 'track-disable'` compares against potentially non-string value from settings.get() — **Fixed: Validate mode is string before comparison** #### Deferred - [x] [Review][Defer] Echo accepts non-finite revisions [ScryingPoolController.js:164] — No validation that `revision` is finite; accepts `NaN`, `Infinity` — deferred, pre-existing - [x] [Review][Defer] No validation revision is number [ScryingPoolController.js:164] — `revision ?? 0` doesn't validate `revision` is a number type — deferred, pre-existing ## Dev Notes ### Architecture Context This story builds the first UI layer of the module. All previous stories (1.1–1.4) were headless infrastructure. Story 1.5 introduces: 1. `AVTileAdapter` — isolates all Foundry AV tile DOM interactions 2. `RoleRenderer` — reactive dispatcher subscribing to state change hooks; applies CSS to AV tiles; constructs GM UI 3. `ScryingPoolStrip` — ApplicationV2-style floating window (the GM's primary control surface) 4. `ActionPopover` — native `` for per-participant hide/show actions **Naming clarification (architecture doc vs story):** The architecture doc calls the L1 GM strip `RosterStrip.js` (in `src/ui/gm/`). This story uses `ScryingPoolStrip` (which appears in all UX spec and epics references). Use `ScryingPoolStrip` as both the class name and filename: `src/ui/gm/ScryingPoolStrip.js`. The architecture file-level name is just an approximation — story spec takes precedence. **RoleRenderer vs VisibilityManager:** `VisibilityManager` (Story 1.4) applies WebRTC track logic (hidden → disableTrack). `RoleRenderer` (Story 1.5) applies CSS/DOM visual state to AV tiles — different concern. Do NOT conflate them. **ScryingPoolController is the source of truth for actions:** `ScryingPoolStrip` is a dumb view. It NEVER calls `stateStore.setState()` directly. All mutations go through `ScryingPoolController.action(source, participantId, targetState, opId, baseRevision)`. The strip reads state from `stateStore.getState(userId)`. ### Init Order (EXACT — do not deviate) ``` Hooks.once('ready') → stateStore.init() // Story 1.3 → FoundryAdapter.probeCapability() + webrtcMode // Story 1.3 → visibilityManager = new VisibilityManager(...) // Story 1.4 → visibilityManager.init() // Story 1.4 → socketHandler.setReady(...) // Story 1.4 → scryingPoolController = new ScryingPoolController(...) // Story 1.4 → scryingPoolController.init() // Story 1.4 → avTileAdapter = new AVTileAdapter(adapter) // Story 1.5 (NEW) → roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter) // Story 1.5 (NEW) → roleRenderer.init() // Story 1.5 (NEW) → if isGM: roleRenderer.openStrip() // Story 1.5 (NEW) // Story 2.1: NotificationBus // Story 2.2: DirectorsBoard (lazy, GM only) ``` **Why AVTileAdapter before RoleRenderer:** `RoleRenderer` receives `avTileAdapter` via constructor injection. It needs the adapter ready before `init()` wires hooks that call through to it. ### Import Boundaries (HARD — enforced by ESLint) ``` src/ui/ → may import: src/core/, src/contracts/, src/utils/ src/ui/gm/ → may import: src/core/, src/contracts/, src/utils/, src/ui/shared/ src/ui/shared/ → may import: src/contracts/, src/utils/ ``` ❌ `src/ui/` importing `src/foundry/` is a hard violation (FoundryAdapter comes in via constructor injection). ❌ `src/core/` importing `src/ui/` is a hard violation. ### Dependency Injection — Zero Direct game.* Access `RoleRenderer`, `AVTileAdapter`, and `ScryingPoolStrip` MUST have zero direct `game.*` access for testability. All Foundry API dependencies come through the injected `adapter`. **Exception for AVTileAdapter:** DOM access via `document.querySelector()` is permissible — it cannot be avoided for AV tile DOM manipulation. Wrap in try/catch; never throw on missing tile. `happy-dom` (Vitest environment) provides `document` in tests. **Exception for ScryingPoolStrip:** `Application` / `ApplicationV2` extend from Foundry's global. In tests, mock at the class level (see §Test Patterns below). Business logic that can be extracted into pure functions should be. ### Canonical Label Constants Create a constants object at the top of `ScryingPoolStrip.js`: ```js const LABELS = Object.freeze({ HIDE_FROM_TABLE: 'Hide from table', SHOW_TO_TABLE: 'Show to table', FIRST_TOOLTIP: 'Hide this participant from other players.', }); ``` All surfaces MUST reference these constants — never inline string literals for action labels. ### Participant Data Shape (for getData()) ```js // Shape returned by ScryingPoolStrip.getData() { participants: [ { userId: 'user-abc', name: 'Alice', // from adapter.users.get(userId).name avatarSrc: '...', // from adapter.users.get(userId).avatar state: 'active', // from stateStore.getState(userId) stateLabel: 'Active', // human-readable label (not player vocabulary partition — GM sees state names) hasPendingOp: false, // check scryingPoolController._pendingOps.has(userId) isCurrentUser: false, // adapter.users.current()?.id === userId } ], isExpanded: true, // from user flag or default true on firstStripOpen isEmpty: false, } ``` **Portrait Fallback resolution:** 1. `user.avatar` if set and not default placeholder 2. `game.settings.get('core', 'defaultToken')` (system default) 3. `'icons/svg/mystery-man.svg'` (Foundry built-in fallback) Access via adapter: `adapter.users.get(userId)?.avatar`. ### StateRing CSS Spec (from UX spec §6.4) ```less // In styles/components/_roster-strip.less (or a new _state-ring.less) .sp-state-ring--solid { box-shadow: 0 0 0 2px var(--sp-state-color); } .sp-state-ring--dashed { outline: 2px dashed var(--sp-state-color); outline-offset: 2px; } .sp-state-ring--pending { box-shadow: 0 0 0 2px var(--sp-state-color); // animation added only under no-preference: } .sp-state-ring--revert { box-shadow: 0 0 0 2px var(--sp-urgency-director); } @media (prefers-reduced-motion: no-preference) { .sp-state-ring--pending { animation: sp-pulse 2s ease-in-out infinite; } @keyframes sp-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } } ``` **Ring variant per state:** | State | Ring class | |---|---| | `active` | `--solid` | | `hidden` | `--dashed` | | `self-muted` | `--solid` | | `offline` | (no ring) | | `cam-lost` | `--dashed` | | `reconnecting` | `--solid` + pulse | | `never-connected` | (no ring) | | `ghost` | `--solid` dotted variant | | `pending` | `--pending` (animated pulse) | | revert flash | `--revert` (200ms amber, then restore) | ### AV Tile DOM Integration (AVTileAdapter) **Tile selector:** Foundry AV tiles have `data-user-id` attribute. Stable selector: ```js document.querySelector(`.camera-view[data-user-id="${userId}"]`) // or: .user-camera[data-user-id="${userId}"] — check actual Foundry v14 DOM // Test with real Foundry to confirm stable selector — use console.log to inspect ui.webrtc.element in dev ``` **mount() idempotency pattern:** ```js mount(userId, element) { const tile = this._findTile(userId); if (!tile) { console.warn('[ScryingPool] AVTileAdapter.mount: tile not found for', userId); return; } // Idempotency: check for existing element with same data-sp-role const role = element.dataset.spRole; const existing = tile.querySelector(`[data-sp-role="${role}"]`); if (existing) { existing.replaceWith(element); // update in place return; } tile.appendChild(element); } ``` **State class isolation:** use `setStateClass()` to ensure only one `sp-state-*` class is ever present: ```js setStateClass(userId, stateName) { const tile = this._findTile(userId); if (!tile) { console.warn('[ScryingPool] AVTileAdapter.setStateClass: tile not found for', userId); return; } // Remove all sp-state-* classes, add new one const existing = [...tile.classList].filter(c => c.startsWith('sp-state-')); existing.forEach(c => tile.classList.remove(c)); if (stateName) tile.classList.add(`sp-state-${stateName}`); } ``` ### Template Structure (roster-strip.hbs) Replace the placeholder with actual ApplicationV2 template structure. The template is rendered inside the Foundry Application shell: ```hbs {{!-- ScryingPoolStrip — floating GM control strip --}} ``` ### ScryingPoolStrip — Application vs ApplicationV2 FoundryVTT v14 introduces `ApplicationV2` with PARTS, but the simpler `Application` base class still works and is more straightforward for this pattern. Use `Application` for Story 1.5 to avoid ApplicationV2 PARTS complexity: ```js export class ScryingPoolStrip extends Application { static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'scrying-pool-strip', template: 'modules/video-view-manager/templates/roster-strip.hbs', popOut: true, resizable: false, title: 'Scrying Pool', classes: ['scrying-pool-strip'], }); } } ``` If `ApplicationV2` is strongly preferred (e.g., for future PARTS-based rendering), the pattern changes to: ```js export class ScryingPoolStrip extends foundry.applications.api.ApplicationV2 { static PARTS = { strip: { template: '...' } }; } ``` **Dev agent's call:** Use `Application` for simplicity unless you have a specific reason to use `ApplicationV2`. Document the choice in the class JSDoc. ### Position Persistence Pattern User flag key: `video-view-manager.stripState` (note: world settings use `scrying-pool.` prefix but user flags use module ID `video-view-manager`). ```js // Save on close game.user.setFlag('video-view-manager', 'stripState', { left: this.position.left, top: this.position.top, open: false, expanded: this._isExpanded, }); // Load on open const saved = game.user.getFlag('video-view-manager', 'stripState'); if (saved?.left !== undefined) { options.left = saved.left; options.top = saved.top; } this._isExpanded = saved?.expanded ?? true; // default expanded on first open ``` ### OpId and Revision for Action Dispatch `ScryingPoolStrip._dispatchAction(participantId)` needs to call `scryingPoolController.action(source, participantId, targetState, opId, baseRevision)`. - **opId:** generate via `import { generateOpId } from '../../utils/uuid.js'` then `const opId = generateOpId()` - **baseRevision:** `scryingPoolController._revisions.get(participantId) ?? 0` — BUT this accesses a private field. Better pattern: expose a public `getRevision(participantId)` method on `ScryingPoolController`. This is a Story 1.5 addition to the Story 1.4 class. - ADD `getRevision(participantId)` to `src/core/ScryingPoolController.js`: `return this._revisions.get(participantId) ?? 0;` - This is a minor non-breaking addition to the Story 1.4 file. - **targetState:** `stateStore.getState(participantId) === 'hidden' ? 'active' : 'hidden'` — toggle logic. If current state is NOT hidden → hide; if hidden → show. ### First-Encounter Tooltip (firstHideTooltip flag) On first hover over the primary CTA button in `ActionPopover` (`firstHideTooltip` flag not set): - Set `data-tooltip` to `"Hide this participant from other players."` - On mouseenter: check `localStorage.getItem('scrying-pool.firstHideTooltip')` — if unset, show extended tooltip and set flag via `localStorage.setItem('scrying-pool.firstHideTooltip', '1')` - Subsequent hovers: canonical label only Note: `firstHideTooltip` is stored in `localStorage` (client-side, session-local) per the architecture decision for v1.0. See architecture §Data Architecture. ### EmptyStatePanel Animation ```less // In _roster-strip.less .sp-empty__icon { display: block; // Static by default; animation only under no-preference } @media (prefers-reduced-motion: no-preference) { .sp-empty__icon { animation: sp-breathe 3s ease-in-out infinite; } @keyframes sp-breathe { 0%, 100% { opacity: 0.6; transform: scale(1); } 50% { opacity: 1.0; transform: scale(1.05); } } } ``` ### Existing Files Being Modified **`module.js`** — current ready hook ends with: ```js try { visibilityManager.init(); scryingPoolController.init(); } catch (err) { console.error('[ScryingPool] Module initialization failed:', err); throw err; } ``` After `scryingPoolController.init()`, add the Story 1.5 wiring block inside the same try/catch. **`src/core/ScryingPoolController.js`** — add public `getRevision(participantId)` method: ```js /** Returns the last confirmed revision for a participant (0 if unknown). */ getRevision(participantId) { return this._revisions.get(participantId) ?? 0; } ``` ### Hooks Used in This Story | Hook | Direction | Who calls | Who listens | |------|-----------|-----------|-------------| | `scrying-pool:stateChanged` | Hooks.callAll | StateStore | RoleRenderer (applies CSS to AV tiles) | | `scrying-pool:controllerAction` | Hooks.callAll | ScryingPoolController | ScryingPoolStrip (re-render), ActionPopover (disable during pending) | | `updateUser` | Hooks.on | Foundry core | RoleRenderer (mid-session role change rebuild) | ### OQ-1 Reminder `adapter.webrtc` is ALWAYS `null` in production (CSS fallback path confirmed by Story 1.2 spike). The webrtcMode will be `'css-fallback'`. `VisibilityManager._onStateChanged()` is already a no-op when `adapter.webrtc` is null. `RoleRenderer` applies CSS/DOM state — no webrtc dependency. ### Test Patterns **Testing AVTileAdapter (happy-dom):** ```js import { AVTileAdapter } from '../../../src/ui/shared/AVTileAdapter.js'; import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js'; // happy-dom provides document — set up tile DOM beforeEach(() => { document.body.innerHTML = `
`; }); test('mount() is idempotent', () => { const adapter = createFoundryAdapterMock(); const avAdapter = new AVTileAdapter(adapter); const el = document.createElement('div'); el.dataset.spRole = 'lock-overlay'; avAdapter.mount('user-1', el); avAdapter.mount('user-1', el); // second call — must not duplicate const tile = document.querySelector('[data-user-id="user-1"]'); expect(tile.querySelectorAll('[data-sp-role="lock-overlay"]').length).toBe(1); }); ``` **Testing RoleRenderer:** ```js import { vi } from 'vitest'; import { RoleRenderer } from '../../../src/ui/RoleRenderer.js'; import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js'; beforeEach(() => { vi.stubGlobal('Hooks', { on: vi.fn(), once: vi.fn(), off: vi.fn(), callAll: vi.fn() }); }); afterEach(() => { vi.unstubAllGlobals(); }); function makeAVTileAdapter() { return { mount: vi.fn(), unmount: vi.fn(), setStateClass: vi.fn(), disconnect: vi.fn(), onTileRerender: vi.fn() }; } ``` **Testing ScryingPoolStrip (logic isolation):** Extract business logic into pure functions where possible (e.g., `resolveTargetState(currentState)`, `buildParticipantData(users, stateStore)`) and test those directly. For the Application class itself: ```js // Stub Application globally vi.stubGlobal('Application', class { static get defaultOptions() { return {}; } }); ``` **General rules (same as Story 1.4):** - `createFoundryAdapterMock()` — canonical mock, no ad-hoc stubs - Named exports only - JSDoc `/** ... */` above every exported class - `async/await` not `.then()` - Guard clauses with early return - `console.warn('[ScryingPool]', ...)` prefix on all console calls ### ESLint / TypeScript Notes (Learnings from Stories 1.3 + 1.4) - Add JSDoc class comment (`/** ... */`) above EVERY exported class — `jsdoc/require-jsdoc` rule - Use `// eslint-disable-next-line no-unused-vars` (line comment) on the line ABOVE a `catch (_)` binding - `Application`/`Hooks`/`game`/`ui` globals are declared in `src/types/foundry-globals.d.ts` — do NOT add new declarations for already-declared globals - `foundry.utils.mergeObject` is the v14 way to extend `defaultOptions` - If adding `game.user.getFlag(...)` calls, check that `game.user` is declared in `foundry-globals.d.ts`; if not, add the `setFlag`/`getFlag` surface to it (use `declare const game: { user: { setFlag: ..., getFlag: ..., ... } }`) - `localStorage` is a browser global — no declaration needed - Pre-existing lint errors in `scripts/package.mjs` (7 errors) are not this story's scope — do NOT fix them ### Project Structure Notes **Files to create:** ``` src/ui/RoleRenderer.js ← NEW (Story 1.5) src/ui/gm/ScryingPoolStrip.js ← NEW (Story 1.5); ActionPopover lives here or in adjacent file src/ui/shared/AVTileAdapter.js ← NEW (Story 1.5); also used by Story 1.6 tests/unit/ui/RoleRenderer.test.js ← NEW (Story 1.5) tests/unit/ui/gm/ScryingPoolStrip.test.js ← NEW (Story 1.5) tests/unit/ui/shared/AVTileAdapter.test.js ← NEW (Story 1.5) ``` **Files to update:** ``` module.js ← UPDATE: imports + ready hook wiring (Story 1.5 block) src/core/ScryingPoolController.js ← UPDATE: add getRevision(participantId) public method templates/roster-strip.hbs ← UPDATE: replace placeholder with actual template styles/components/_roster-strip.less ← UPDATE: add StateRing + ParticipantAvatar + strip layout CSS ``` **Files NOT changed:** - `src/contracts/` — all contracts already complete; no changes needed - `src/core/StateStore.js`, `SocketHandler.js`, `VisibilityManager.js` — no changes - `src/foundry/FoundryAdapter.js` — no changes (all deps come through existing adapter surface) - `tests/fixtures/` — no new fixtures needed; use inline DOM/objects in UI tests **Import boundary check for new files:** ``` src/ui/RoleRenderer.js → imports: src/core/ ✅, src/utils/ ✅, src/ui/shared/ ✅ src/ui/gm/ScryingPoolStrip.js → imports: src/core/ ✅, src/utils/ ✅, src/ui/shared/ ✅ src/ui/shared/AVTileAdapter.js → imports: (nothing internal) ✅ ``` ### References - Story 1.5 spec: `_bmad-output/planning-artifacts/epics.md` §Story 1.5 (lines 397–497) - UX components spec: `_bmad-output/planning-artifacts/ux-design-specification.md` §6.2–6.9 (lines 1135–1265) - UX action hierarchy: `_bmad-output/planning-artifacts/ux-design-specification.md` §7.1 (lines 1390–1411) - UX overlay patterns: `_bmad-output/planning-artifacts/ux-design-specification.md` §7.3 (lines 1452–1459) - StateRing CSS: `_bmad-output/planning-artifacts/ux-design-specification.md` §6.4 (lines 1164–1181) - Architecture init order: `_bmad-output/planning-artifacts/architecture.md` §Initialisation Order (lines 303–319) - Architecture import boundaries: `_bmad-output/planning-artifacts/architecture.md` (lines 428–444) - Architecture data flow: `_bmad-output/planning-artifacts/architecture.md` §Data Flow — GM Visibility Toggle (lines 805–826) - Architecture error handling by layer: `_bmad-output/planning-artifacts/architecture.md` (lines 510–517) - State precedence: `_bmad-output/planning-artifacts/architecture.md` §State Map (lines 546–560) - UX design requirements: `_bmad-output/planning-artifacts/epics.md` UX-DR3–UX-DR8, UX-DR18–UX-DR21 (lines 108–144) - Story 1.4 dev notes (init order, ScryingPoolController API): `_bmad-output/implementation-artifacts/1-4-core-logic-scryingpoolcontroller-and-visibilitymanager.md` - firstHideTooltip + firstStripOpen flags: `_bmad-output/planning-artifacts/ux-design-specification.md` (lines 571, 1091) - AV tile selector / VisibilityBadge injection pattern: `_bmad-output/planning-artifacts/ux-design-specification.md` §VisibilityBadge Injection Pattern (lines 465–471) - Canonical adapter mock: `tests/helpers/foundryAdapterMock.js` - ScryingPoolController implementation: `src/core/ScryingPoolController.js` - StateStore implementation: `src/core/StateStore.js` - Current module.js: `module.js` - Deferred work (do not fix in 1.5): `_bmad-output/implementation-artifacts/deferred-work.md` --- ## Dev Agent Record ### Agent Model Used Claude Sonnet 4.6 (claude-sonnet-4.6) ### Debug Log References - ESLint no-undef: `Application` in ScryingPoolStrip.js — fixed with `/* global Application */` comment. `typeof Application` is exempt from no-undef but direct reference in ternary is not. - ESLint no-unused-vars: `spy` in RoleRenderer.test.js bulk-payload test — removed. - TypeScript TS2488: `[...tile.classList]` spread on DOMTokenList — replaced with `Array.from(tile.classList)`. - `showFirstOpenTip` undefined in `activateListeners` — was referencing a variable from `getData()` scope; fixed to re-evaluate from `game.user.getFlag()` directly. ### Completion Notes List - AVTileAdapter (24 tests): Full TDD red→green. `mount()` idempotent via data-sp-role key, `unmount()` removes [data-sp-mount] children, `setStateClass()` swaps sp-state-* classes, `onTileRerender()` uses MutationObserver per userId, `disconnect()` cleans all observers. - RoleRenderer (20 tests): TDD red→green with `vi.mock(ScryingPoolStrip)` + `vi.stubGlobal(Hooks)`. Registers 3 hooks in `init()`, handles stateChanged/controllerAction/updateUser. `openStrip()` lazily constructs ScryingPoolStrip singleton. - ScryingPoolStrip (23 tests): TDD red→green. Tests cover LABELS immutability, `resolveTargetState()`, `buildParticipantList()`, `getData()`, `defaultOptions`. ActionPopover implemented as internal (non-exported) class with `` + Esc cancel + backdrop click dismiss + firstHideTooltip localStorage. - ScryingPoolController: Added `getRevision()` and `hasPendingOp()` public methods (non-breaking). - module.js: Wired AVTileAdapter + RoleRenderer with `game.webrtc !== null` guard for AC-13. - templates/roster-strip.hbs: Full HBS template with participants list, EmptyStatePanel, expand toggle, overlay layer, firstStripOpen tip. - styles/components/_roster-strip.less: Full CSS — StateRing variants, ParticipantAvatar layout, strip collapsed/expanded via max-width transition, AV tile overlays, EmptyStatePanel breathing animation, context menu, ActionPopover — all animations gated under `prefers-reduced-motion: no-preference`. - Pipeline: 248 tests (181 baseline + 67 new), 0 lint errors (pre-existing 7 in scripts/package.mjs untouched), 0 typecheck errors, build passes. ### File List - `src/ui/shared/AVTileAdapter.js` — NEW - `src/ui/RoleRenderer.js` — NEW - `src/ui/gm/ScryingPoolStrip.js` — NEW (includes ActionPopover class) - `tests/unit/ui/shared/AVTileAdapter.test.js` — NEW (24 tests) - `tests/unit/ui/RoleRenderer.test.js` — NEW (20 tests) - `tests/unit/ui/gm/ScryingPoolStrip.test.js` — NEW (23 tests) - `src/core/ScryingPoolController.js` — UPDATED (added getRevision, hasPendingOp) - `module.js` — UPDATED (imports + ready hook wiring + webrtc null guard) - `templates/roster-strip.hbs` — UPDATED (full HBS template) - `styles/components/_roster-strip.less` — UPDATED (full LESS styles) ### Change Log - Story 1.5 implementation complete (Date: 2026-05-22) - Added AVTileAdapter, RoleRenderer, ScryingPoolStrip, ActionPopover - Added getRevision() + hasPendingOp() to ScryingPoolController - Wired GM UI into module.js ready hook with game.webrtc null guard - 248 tests passing (67 new), lint/typecheck/build all clean