Files
scrying-pool/_bmad-output/implementation-artifacts/1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration.md
T
2026-05-23 18:23:48 +02:00

43 KiB
Raw Blame History

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 <dialog> 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

  • Task 1: Create src/ui/shared/AVTileAdapter.js (AC: 8, 9, 10)

    • 1.1: Write failing tests in tests/unit/ui/shared/AVTileAdapter.test.js first (TDD red)
    • 1.2: Implement constructor(adapter) — side-effect free; stores adapter reference; no DOM access in constructor
    • 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
    • 1.4: Implement unmount(userId) — remove all [data-sp-mount] children from tile; no-op if tile not found
    • 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)
    • 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
    • 1.7: Implement disconnect() — disconnect all stored MutationObservers; clear internal observer map; safe to call multiple times
    • 1.8: Confirm tests green, run full suite (no regressions)
  • Task 2: Create src/ui/RoleRenderer.js (AC: 8, 9, 10, 12, 13)

    • 2.1: Write failing tests in tests/unit/ui/RoleRenderer.test.js first (TDD red)
    • 2.2: Implement constructor(stateStore, scryingPoolController, avTileAdapter, adapter) — side-effect free; store all injected deps; no Hooks registration in constructor
    • 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
    • 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
    • 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
    • 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')
    • 2.7: Implement openStrip() / closeStrip() — construct ScryingPoolStrip singleton lazily; open/close it (GM only)
    • 2.8: Confirm tests green, run full suite (no regressions)
  • 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)

    • 3.1: Write failing tests in tests/unit/ui/gm/ScryingPoolStrip.test.js (TDD red — test logic, not ApplicationV2 rendering)
    • 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)
    • 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'
    • 3.4: Implement getData() — build participant list from stateStore; return { participants, isExpanded, isEmpty } — see Dev Notes for participant data shape
    • 3.5: Implement activateListeners(html) — bind click on .sp-participant-avatar_openPopover(participantId, el), right-click → _openContextMenu(participantId, el), expand toggle → _toggleExpanded()
    • 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
    • 3.7: Implement _toggleExpanded() — toggle .is-expanded class on strip element; save expanded to user flag
    • 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
    • 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)
    • 3.10: Implement _dispatchAction(participantId) — determine target state (current=active → hidden; else → active); call scryingPoolController.action('strip', participantId, targetState, generateOpId(), this._getRevision(participantId))
    • 3.11: Update templates/roster-strip.hbs with actual ScryingPoolStrip template markup — see Dev Notes §Template Structure
    • 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
    • 3.13: Confirm tests green, run full suite (no regressions)
  • Task 4: Implement ActionPopover class inside src/ui/gm/ScryingPoolStrip.js (AC: 6, 15)

    • 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)
    • 4.2: Implement constructor(participantId, currentState, anchorRect, stripElement, onAction) — build <dialog> element with h3 name + state label, primary CTA button (data-action="primary-cta"), aria attributes
    • 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)
    • 4.4: Implement close(reason) — call dialog.close(reason); remove click-outside listener; return focus to triggering avatar
    • 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
    • 4.6: Wire Esc via native <dialog> cancel event → call close(); return focus to trigger
  • Task 5: Add CSS — LESS styles for all new components (AC: 2, 3, 4, 16, 17)

    • 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
    • 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)
    • 5.3: Add ScryingPoolStrip layout CSS: floating window, collapsed/expanded states using max-width transition (never width), .is-expanded modifier
    • 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)
    • 5.5: Add EmptyStatePanel CSS: breathing-pulse eye icon (gated under prefers-reduced-motion: no-preference), centred layout, NOT styled as error
    • 5.6: Run npm run build — exits 0
  • Task 6: Update module.js — wire RoleRenderer and ScryingPoolStrip into ready hook (AC: 1, 12, 13)

    • 6.1: Add imports: import { RoleRenderer } from './src/ui/RoleRenderer.js'; + import { AVTileAdapter } from './src/ui/shared/AVTileAdapter.js';
    • 6.2: Add module-level let roleRenderer; let avTileAdapter;
    • 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()
    • 6.4: If adapter.users.isGM(), call roleRenderer.openStrip() to render ScryingPoolStrip
    • 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
    • 6.6: Run full pipeline — lint + typecheck + test (all must pass)
  • Task 7: Pipeline validation (AC: all)

    • 7.1: npm run lint — exits 0 (no new errors beyond the 7 pre-existing in scripts/package.mjs)
    • 7.2: npm run typecheck — exits 0
    • 7.3: npm run test — all tests pass (≥181 baseline + ~40 new = ≥221 expected)
    • 7.4: npm run build — exits 0 (LESS compiles cleanly)

Review Findings

Decision Needed

*(None)

Patch Required

  • [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
  • [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
  • [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
  • [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 (!==)
  • [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
  • [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
  • [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
  • [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
  • [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
  • [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
  • [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

  • [Review][Defer] Echo accepts non-finite revisions [ScryingPoolController.js:164] — No validation that revision is finite; accepts NaN, Infinity — deferred, pre-existing
  • [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.11.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 <dialog> 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:

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())

// 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)

// 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:

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:

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:

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:

{{!-- ScryingPoolStrip — floating GM control strip --}}
<div class="scrying-pool scrying-pool-strip{{#if isExpanded}} is-expanded{{/if}}"
     role="complementary"
     aria-label="Scrying Pool">

  {{!-- Expand/collapse toggle --}}
  <button class="sp-strip__toggle" data-action="toggle-expanded"
          aria-label="{{#if isExpanded}}Collapse Scrying Pool{{else}}Expand Scrying Pool{{/if}}"
          aria-expanded="{{isExpanded}}">
    <i class="fas fa-chevron-{{#if isExpanded}}left{{else}}right{{/if}}"></i>
  </button>

  {{!-- Participant list --}}
  <ul class="sp-strip__participants" role="list">
    {{#if isEmpty}}
      {{!-- EmptyStatePanel --}}
      <li class="sp-strip__empty-state" role="listitem">
        <i class="fas fa-eye sp-empty__icon" aria-hidden="true"></i>
        <span class="sp-empty__text">No participants yet</span>
      </li>
    {{else}}
      {{#each participants}}
        <li class="sp-strip__participant-item" role="listitem">
          {{!-- ParticipantAvatar (44×44px container) --}}
          <button class="sp-participant-avatar sp-state-{{state}}{{#if hasPendingOp}} sp-state-pending{{/if}}"
                  data-user-id="{{userId}}"
                  data-action="open-popover"
                  role="button"
                  aria-label="{{name}}{{stateLabel}}"
                  aria-pressed="false">

            {{!-- Avatar image (32px rounded) --}}
            <img class="sp-avatar__img" src="{{avatarSrc}}" alt="" aria-hidden="true" />

            {{!-- StateRing (applied via CSS on parent button) --}}
            {{!-- Corner badge (12px bottom-right) --}}
            <span class="sp-avatar__corner-badge" aria-hidden="true">
              {{!-- Icon rendered via CSS ::before content --}}
            </span>

            {{!-- Expanded view: name row --}}
            {{#if ../isExpanded}}
              <span class="sp-avatar__name">{{name}}</span>
            {{/if}}
          </button>
        </li>
      {{/each}}
    {{/if}}
  </ul>

  {{!-- StripOverlayLayer — owns ActionPopover + ConfirmationBar --}}
  <div class="sp-strip__overlay-layer"
       aria-hidden="true"
       style="position: absolute; inset: 0; pointer-events: none; overflow: visible;"></div>
</div>

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:

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:

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

// 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

// 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:

  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:

/** 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):

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 = `
    <div class="camera-view" data-user-id="user-1"></div>
  `;
});

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:

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:

// 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 397497)
  • UX components spec: _bmad-output/planning-artifacts/ux-design-specification.md §6.26.9 (lines 11351265)
  • UX action hierarchy: _bmad-output/planning-artifacts/ux-design-specification.md §7.1 (lines 13901411)
  • UX overlay patterns: _bmad-output/planning-artifacts/ux-design-specification.md §7.3 (lines 14521459)
  • StateRing CSS: _bmad-output/planning-artifacts/ux-design-specification.md §6.4 (lines 11641181)
  • Architecture init order: _bmad-output/planning-artifacts/architecture.md §Initialisation Order (lines 303319)
  • Architecture import boundaries: _bmad-output/planning-artifacts/architecture.md (lines 428444)
  • Architecture data flow: _bmad-output/planning-artifacts/architecture.md §Data Flow — GM Visibility Toggle (lines 805826)
  • Architecture error handling by layer: _bmad-output/planning-artifacts/architecture.md (lines 510517)
  • State precedence: _bmad-output/planning-artifacts/architecture.md §State Map (lines 546560)
  • UX design requirements: _bmad-output/planning-artifacts/epics.md UX-DR3UX-DR8, UX-DR18UX-DR21 (lines 108144)
  • 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 465471)
  • 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 <dialog> + 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