Files
scrying-pool/_bmad-output/implementation-artifacts/2-3-directors-board-bulk-actions-spotlight-and-keyboard-shortcuts.md
2026-05-23 18:23:48 +02:00

37 KiB
Raw Permalink Blame History

Story 2.3: Director's Board — Bulk Actions, Spotlight & Keyboard Shortcuts

Status: done

Story

As a GM, I want to show or hide all participants at once, spotlight a single feed, and undo these bulk actions instantly, so that I can execute camera arrangements in a single action without toggling participants one by one.

Acceptance Criteria

  1. Given the Director's Board is open When the GM clicks "Show All" Then all participants' states are set to active (excluding ghost-state participants) And the action is broadcast to all clients

  2. Given the Director's Board is open When the GM clicks "Hide All" Then all participants' states are set to hidden (excluding ghost-state participants) And the action is broadcast to all clients

  3. Given the GM has just executed "Show All" or "Hide All" When the GM clicks "Undo" Then the Visibility Matrix is immediately restored to the state before the bulk action And no second undo is available (single-step undo only)

  4. Given a participant card is focused When the GM presses Ctrl+Shift+P Then that participant's feed is shown and all others are hidden in a single action And the pre-spotlight Visibility Matrix is stored as a snapshot

  5. Given Spotlight is active When the GM clicks "Restore" Then the Visibility Matrix reverts to the pre-spotlight snapshot And "Restore" is distinct from the bulk action Undo affordance

  6. Given Ctrl+Shift+S or Ctrl+Shift+H is pressed When the event fires Then "Show All" or "Hide All" executes as if the button were clicked

  7. Given the GM presses ? in the Director's Board When the event fires Then a shortcut reference panel opens listing all keyboard shortcuts with their current bindings

  8. Given the GM navigates to keyboard shortcut settings When they open module settings Then Ctrl+Shift+V, Ctrl+Shift+S, Ctrl+Shift+H, Ctrl+Shift+P are all configurable And the ? panel reflects the currently configured bindings

Tasks / Subtasks

  • Task 1: Fix _dispatchToggle calling convention in DirectorsBoard.js (AC: foundational bug fix from Story 2.2)

    • 1.1: Add import { generateOpId } from '../../utils/uuid.js'; at top of DirectorsBoard.js
    • 1.2: Rewrite _dispatchToggle(userId) to use positional args: controller.action('board', userId, targetState, opId, baseRevision) — matching ScryingPoolStrip's _dispatchAction pattern
    • 1.3: Update existing _dispatchToggle tests in tests/unit/ui/gm/DirectorsBoard.test.js — replace expect(controller.action).toHaveBeenCalledWith({ userId, targetState }) with expect(controller.action).toHaveBeenCalledWith('board', userId, targetState, expect.any(String), expect.any(Number)); add getRevision: vi.fn(() => 0) to the controller mock
  • Task 2: Implement showAll() and hideAll() methods on DirectorsBoard (AC: 1, 2, 3)

    • 2.1: Write TDD red tests in DirectorsBoard.test.js — new describe('showAll()') and describe('hideAll()') blocks
    • 2.2: Implement showAll(): capture pre-action snapshot → this._undoSnapshot = new Map(nonGhostUsers.map(u => [u.id, this._stateStore.getState(u.id)])) → call controller.action('board', userId, 'active', opId, baseRevision) for each non-ghost participant → clear this._spotlightSnapshot (spotlight superseded)
    • 2.3: Implement hideAll(): same pattern but target state 'hidden' → similarly clears _spotlightSnapshot
    • 2.4: ghost exclusion rule: check this._stateStore.getState(userId) === 'ghost' before acting; skip those users
    • 2.5: Skip participants that already have a pending op: check this._controller.hasPendingOp?.(userId)
    • 2.6: After showAll/hideAll, trigger re-render to reflect Undo button visibility: if (this.rendered) this.render({ force: true })
    • 2.7: Green all showAll/hideAll tests
  • Task 3: Implement undo() method and single-step undo state (AC: 3)

    • 3.1: Add this._undoSnapshot = null; and this._spotlightSnapshot = null; to constructor
    • 3.2: Write TDD red tests: undo restores all non-ghost participants to snapshot values; undo clears _undoSnapshot; undo is no-op when _undoSnapshot is null; second undo unavailable after first
    • 3.3: Implement undo(): guard if (!this._undoSnapshot) return; for each [userId, targetState] entry in _undoSnapshot: skip ghost-state users and pending-op users; call controller.action('board', userId, targetState, opId, baseRevision); then this._undoSnapshot = null; trigger re-render
    • 3.4: Green all undo tests
  • Task 4: Implement spotlight(userId) and restoreSpotlight() methods (AC: 4, 5)

    • 4.1: Write TDD red tests: spotlight captures pre-spotlight snapshot; spotlight sets focused user active + all others hidden (excluding ghost); restore reverts all participants; restore clears _spotlightSnapshot; calling spotlight clears _undoSnapshot
    • 4.2: Implement spotlight(userId): guard if !userId; capture this._spotlightSnapshot = new Map(nonGhostUsers.map(u => [u.id, this._stateStore.getState(u.id)])) → clear this._undoSnapshot (spotlight supersedes bulk undo); iterate non-ghost users: active for the spotlighted user, hidden for all others; trigger re-render
    • 4.3: Implement restoreSpotlight(): guard if (!this._spotlightSnapshot) return; for each [userId, targetState] in snapshot: skip ghost + pending-op; call controller.action('board', userId, targetState, opId, baseRevision); this._spotlightSnapshot = null; trigger re-render
    • 4.4: Add spotlightFocused() public method: reads document.activeElement?.dataset?.userId from within the board's element; calls spotlight(userId) if valid — used by keyboard shortcut callback
    • 4.5: Green all spotlight tests
  • Task 5: Update _prepareContext() to expose bulk-action state flags (AC: 3, 5)

    • 5.1: Extend _prepareContext() return value with: { ..., hasUndo: this._undoSnapshot !== null, hasRestore: this._spotlightSnapshot !== null }
    • 5.2: Update _prepareContext tests to verify the two new fields
  • Task 6: Update templates/directors-board.hbs (AC: 1, 2, 3, 5, 7)

    • 6.1: Add a <div class="directors-board__bulk-bar"> section between the grid and footer with:
      • "Show All" button: <button type="button" data-action="show-all" ...>
      • "Hide All" button: <button type="button" data-action="hide-all" ...>
      • Conditional Undo: {{#if hasUndo}}<button type="button" data-action="undo" ...>{{/if}}
      • Conditional Restore: {{#if hasRestore}}<button type="button" data-action="restore-spotlight" ...>{{/if}}
    • 6.2: All button labels via {{localize "video-view-manager.directorsBoard.bulk.*"}} keys (never inline English)
    • 6.3: Undo and Restore buttons use distinct styling (Undo = secondary; Restore = spotlight-accent)
    • 6.4: Add <button type="button" class="directors-board__help-btn" data-action="open-shortcut-panel" aria-label="...">?</button> to the title bar area or bulk-bar
  • Task 7: Wire bulk-action event delegation in _onRender() (AC: 1, 2, 3, 5, 7)

    • 7.1: Extend the existing delegated click listener in _onRender() to handle new data-action values:
      • show-allthis.showAll()
      • hide-allthis.hideAll()
      • undothis.undo()
      • restore-spotlightthis.restoreSpotlight()
      • open-shortcut-panelthis._openShortcutPanel()
    • 7.2: Add ? keydown handler in _onKeydown(): if (e.key === '?') { e.preventDefault(); this._openShortcutPanel(); }
    • 7.3: Extend Ctrl+Shift+P keyboard shortcut handler in _onKeydown(): if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') { e.preventDefault(); this.spotlightFocused(); }
  • Task 8: Implement _openShortcutPanel() (AC: 7, 8)

    • 8.1: Implement _openShortcutPanel() method: reads current bindings from game.keybindings.bindings for each registered action key (openDirectorsBoard, showAll, hideAll, spotlightParticipant); builds an HTML string listing each shortcut name + current binding; opens as a native Foundry Dialog.prompt() or new Dialog({ ... }).render(true) — no custom template needed
    • 8.2: i18n all shortcut names — use adapter.i18n.localize() for label strings (or fallback to display name string if localize not available)
    • 8.3: Panel shows: "Open/Close Board: Ctrl+Shift+V", "Show All: Ctrl+Shift+S", "Hide All: Ctrl+Shift+H", "Spotlight: Ctrl+Shift+P" (reflecting current configured bindings)
  • Task 9: Register new keybindings in module.js (AC: 6, 8)

    • 9.1: Register scrying-pool.showAll keybinding in Hooks.once('init'): key: 'KeyS', modifiers: ['Control', 'Shift'], restricted: true, onDown: () => directorsBoard?.showAll()
    • 9.2: Register scrying-pool.hideAll keybinding: key: 'KeyH', modifiers: ['Control', 'Shift'], restricted: true, onDown: () => directorsBoard?.hideAll()
    • 9.3: Register scrying-pool.spotlightParticipant keybinding: key: 'KeyP', modifiers: ['Control', 'Shift'], restricted: true, onDown: () => directorsBoard?.spotlightFocused()
    • 9.4: Update the module.js header comment to include Story 2.3 keybinding wiring
  • Task 10: Add i18n keys in lang/en.json (AC: 1, 2, 3, 5, 7)

    • 10.1: Add video-view-manager.directorsBoard.bulk.showAll = "Show All"
    • 10.2: Add video-view-manager.directorsBoard.bulk.hideAll = "Hide All"
    • 10.3: Add video-view-manager.directorsBoard.bulk.undo = "Undo"
    • 10.4: Add video-view-manager.directorsBoard.bulk.restore = "Restore"
    • 10.5: Add video-view-manager.directorsBoard.bulk.spotlight = "Spotlight"
    • 10.6: Add video-view-manager.directorsBoard.shortcuts.title = "Keyboard Shortcuts"
    • 10.7: Add video-view-manager.directorsBoard.shortcuts.openBoard = "Open/Close Board"
    • 10.8: Add video-view-manager.directorsBoard.shortcuts.showAll = "Show All Participants"
    • 10.9: Add video-view-manager.directorsBoard.shortcuts.hideAll = "Hide All Participants"
    • 10.10: Add video-view-manager.directorsBoard.shortcuts.spotlight = "Spotlight Focused Participant"
    • 10.11: Add video-view-manager.directorsBoard.shortcuts.openPanel = "Open Shortcut Reference"
    • 10.12: Add keybinding label and hint strings under video-view-manager.keybindings.showAll / hideAll / spotlightParticipant
  • Task 11: Add bulk-action bar CSS in styles/components/_directors-board.less (AC: 1, 2, 3, 5)

    • 11.1: Add .directors-board__bulk-bar styles: display: flex; gap: 8px; padding: 8px; border-top: 1px solid var(--sp-border);
    • 11.2: Style "Show All" / "Hide All" as primary action buttons using existing --sp-* tokens
    • 11.3: Style "Undo" as secondary; "Restore" with a spotlight-accent color (distinct from Undo — per AC 5)
    • 11.4: Add .directors-board__help-btn styles: small circular button, top-right positioning within title/header area
  • Task 12: Pipeline verification

    • 12.1: npm run lint exits 0 for all modified files
    • 12.2: npm run test exits 0 — expected: 383 baseline + new bulk/spotlight/undo/shortcut tests (~2535 new tests)

Dev Notes

Critical Bug Fix from Story 2.2 (MUST address in this story)

DirectorsBoard._dispatchToggle() currently calls:

this._controller.action({ userId, targetState }); // ← WRONG: passing object

But ScryingPoolController.action() signature is positional:

action(source, participantId, targetState, opId, baseRevision)

Fix — match ScryingPoolStrip._dispatchAction() pattern exactly:

import { generateOpId } from '../../utils/uuid.js'; // add at top

_dispatchToggle(userId) {
  if (!userId) return;
  if (this._controller.hasPendingOp?.(userId)) return;
  const currentState = this._stateStore.getState(userId) ?? 'active';
  const targetState = resolveToggleTarget(currentState);
  const opId = generateOpId();
  const baseRevision = this._controller.getRevision?.(userId) ?? 0;
  this._controller.action('board', userId, targetState, opId, baseRevision);
}

Update existing _dispatchToggle tests to expect positional args:

// Before (Story 2.2):
expect(controller.action).toHaveBeenCalledWith({ userId: 'u1', targetState: 'hidden' });

// After (Story 2.3 fix):
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));

Also add getRevision: vi.fn(() => 0) to the controller mock in beforeEach.

Bulk Action Implementation Pattern

All bulk methods follow the same structure. Reference ScryingPoolStrip._dispatchAction() for single-op pattern. Bulk ops fire the same call per-participant:

showAll() {
  const users = this._adapter.users.all();
  // Capture pre-action snapshot (non-ghost only)
  this._undoSnapshot = new Map(
    users
      .filter(u => this._stateStore.getState(u.id) !== 'ghost')
      .map(u => [u.id, this._stateStore.getState(u.id)])
  );
  this._spotlightSnapshot = null; // bulk supersedes spotlight restore

  for (const u of users) {
    const currentState = this._stateStore.getState(u.id);
    if (currentState === 'ghost') continue;                  // FR-12: exclude ghost
    if (this._controller.hasPendingOp?.(u.id)) continue;    // skip in-flight
    const opId = generateOpId();
    const baseRevision = this._controller.getRevision?.(u.id) ?? 0;
    this._controller.action('board', u.id, 'active', opId, baseRevision);
  }
  if (this.rendered) this.render({ force: true });
}

hideAll() is identical but with target state 'hidden'.

Undo / Restore State Machine

_undoSnapshot:       null ─── showAll/hideAll ──→ Map<userId, prevState>
                                                         │
                                              spotlight  │  undo()
                                                  ↓      ↓
                                               null ←──────── null
                                                         │
                                         spotlight() ────┘

_spotlightSnapshot:  null ─── spotlight() ──→ Map<userId, prevState>
                                                       │
                                       showAll/hideAll │  restoreSpotlight()
                                              ↓        ↓
                                           null ←────────── null
  • showAll() / hideAll() → set _undoSnapshot, clear _spotlightSnapshot
  • spotlight() → set _spotlightSnapshot, clear _undoSnapshot
  • undo() → read _undoSnapshot, set to null (single-step — second undo unavailable)
  • restoreSpotlight() → read _spotlightSnapshot, set to null

Ghost Exclusion Rule (FR-12)

// ALWAYS exclude ghost state from bulk ops — spec is explicit (FR-12, FR-13)
if (this._stateStore.getState(userId) === 'ghost') continue;

Ghost participants are leaving the session; mutating their state causes visual glitches. Check the current live state from stateStore, NOT the snapshot state.

Spotlight Method

spotlight(userId) {
  if (!userId) return;
  const users = this._adapter.users.all();
  const nonGhost = users.filter(u => this._stateStore.getState(u.id) !== 'ghost');

  // Capture pre-spotlight snapshot
  this._spotlightSnapshot = new Map(nonGhost.map(u => [u.id, this._stateStore.getState(u.id)]));
  this._undoSnapshot = null; // spotlight supersedes bulk undo

  for (const u of nonGhost) {
    if (this._controller.hasPendingOp?.(u.id)) continue;
    const targetState = u.id === userId ? 'active' : 'hidden';
    const opId = generateOpId();
    const baseRevision = this._controller.getRevision?.(u.id) ?? 0;
    this._controller.action('board', u.id, targetState, opId, baseRevision);
  }
  if (this.rendered) this.render({ force: true });
}

spotlightFocused() {
  // Reads currently focused card's userId — only valid when board DOM exists
  const focusedUserId = this.element?.querySelector('[data-user-id]:focus')?.dataset?.userId;
  if (!focusedUserId) return;
  this.spotlight(focusedUserId);
}

Keyboard Shortcut Delegation in _onKeydown()

Extend existing _onKeydown(e) method to handle Story 2.3 shortcuts:

_onKeydown(e) {
  // ... existing ArrowKey / Space / Enter navigation (unchanged) ...

  // Story 2.3: Spotlight focused participant
  if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') {
    e.preventDefault();
    this.spotlightFocused();
    return;
  }

  // Story 2.3: Shortcut reference panel
  if (e.key === '?') {
    e.preventDefault();
    this._openShortcutPanel();
    return;
  }
}

Note: Ctrl+Shift+S and Ctrl+Shift+H are registered as global Foundry keybindings in module.js, NOT as keydown handlers inside the board. This matches the spec ("executes as if the button were clicked" — globally, not just when board is focused).

_prepareContext() Extension

async _prepareContext() {
  const base = buildBoardContext(this._stateStore, this._controller, this._adapter);
  return {
    ...base,
    hasUndo: this._undoSnapshot !== null,
    hasRestore: this._spotlightSnapshot !== null,
  };
}

Shortcut Reference Panel (_openShortcutPanel())

Reads live bindings from Foundry's keybindings registry, builds a simple HTML panel. Reference pattern:

_openShortcutPanel() {
  const getBinding = (action) => {
    const binding = game.keybindings?.bindings?.get(`scrying-pool.${action}`)?.[0];
    if (!binding) return '—';
    const mods = (binding.modifiers ?? []).join('+');
    return mods ? `${mods}+${binding.key}` : binding.key;
  };

  const rows = [
    ['openDirectorsBoard', 'directorsBoard.shortcuts.openBoard'],
    ['showAll',            'directorsBoard.shortcuts.showAll'],
    ['hideAll',            'directorsBoard.shortcuts.hideAll'],
    ['spotlightParticipant', 'directorsBoard.shortcuts.spotlight'],
  ].map(([action, labelKey]) =>
    `<tr><td>${adapter.i18n.localize(`video-view-manager.${labelKey}`)}</td>
         <td><kbd>${getBinding(action)}</kbd></td></tr>`
  ).join('');

  new Dialog({
    title: adapter.i18n.localize('video-view-manager.directorsBoard.shortcuts.title'),
    content: `<table class="sp-shortcut-table"><tbody>${rows}</tbody></table>`,
    buttons: { close: { label: 'Close', callback: () => {} } },
    default: 'close',
  }).render(true);
}

Use typeof Dialog !== 'undefined' guard (or game?.ui) for test compatibility — _openShortcutPanel does NOT need unit tests (it's a passthrough to Foundry Dialog API).

Module.js Keybinding Registration (Story 2.3 additions)

Add in Hooks.once('init'), after the existing openDirectorsBoard registration:

// Story 2.3: Show All / Hide All / Spotlight keybindings (GM only, configurable)
game.keybindings.register('scrying-pool', 'showAll', {
  name: game.i18n?.localize('video-view-manager.keybindings.showAll.name') ?? 'Show All Participants',
  hint: game.i18n?.localize('video-view-manager.keybindings.showAll.hint') ?? 'Sets all participants visible',
  editable: [{ key: 'KeyS', modifiers: ['Control', 'Shift'] }],
  restricted: true,
  onDown: () => directorsBoard?.showAll(),
});
game.keybindings.register('scrying-pool', 'hideAll', {
  name: game.i18n?.localize('video-view-manager.keybindings.hideAll.name') ?? 'Hide All Participants',
  hint: game.i18n?.localize('video-view-manager.keybindings.hideAll.hint') ?? 'Hides all participants',
  editable: [{ key: 'KeyH', modifiers: ['Control', 'Shift'] }],
  restricted: true,
  onDown: () => directorsBoard?.hideAll(),
});
game.keybindings.register('scrying-pool', 'spotlightParticipant', {
  name: game.i18n?.localize('video-view-manager.keybindings.spotlightParticipant.name') ?? 'Spotlight Focused Participant',
  hint: game.i18n?.localize('video-view-manager.keybindings.spotlightParticipant.hint') ?? 'Shows focused participant and hides all others',
  editable: [{ key: 'KeyP', modifiers: ['Control', 'Shift'] }],
  restricted: true,
  onDown: () => directorsBoard?.spotlightFocused(),
});

Note: game.i18n?.localize() with fallback is used here because keybindings register in Hooks.once('init') which fires before ready; i18n may not be fully loaded. The fallback English string is safe.

Test Patterns for Bulk Actions

// In DirectorsBoard.test.js — add to controller mock:
controller = {
  action: vi.fn(),
  hasPendingOp: vi.fn(() => false),
  getRevision: vi.fn(() => 0),  // ← ADD THIS for Story 2.3
};

describe('showAll()', () => {
  it('calls controller.action with active for each non-ghost user', () => {
    adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
    stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'hidden');
    board.showAll();
    // u3 is ghost — excluded
    expect(controller.action).toHaveBeenCalledTimes(2);
    expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
    expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
    expect(controller.action).not.toHaveBeenCalledWith('board', 'u3', expect.anything(), expect.anything(), expect.anything());
  });

  it('stores pre-action snapshot in _undoSnapshot', () => {
    adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
    stateStore.getState.mockImplementation(id => id === 'u1' ? 'hidden' : 'active');
    board.showAll();
    expect(board._undoSnapshot).toBeInstanceOf(Map);
    expect(board._undoSnapshot.get('u1')).toBe('hidden');
    expect(board._undoSnapshot.get('u2')).toBe('active');
  });

  it('clears _spotlightSnapshot when called', () => {
    board._spotlightSnapshot = new Map([['u1', 'active']]);
    adapter.users.all.mockReturnValue([{ id: 'u1' }]);
    stateStore.getState.mockReturnValue('active');
    board.showAll();
    expect(board._spotlightSnapshot).toBeNull();
  });

  it('skips participants with pending ops', () => {
    adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
    stateStore.getState.mockReturnValue('hidden');
    controller.hasPendingOp.mockImplementation(id => id === 'u1');
    board.showAll();
    expect(controller.action).toHaveBeenCalledTimes(1);
    expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
  });
});

describe('undo()', () => {
  it('restores participants to snapshot states', () => {
    board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'active']]);
    stateStore.getState.mockReturnValue('active');
    board.undo();
    expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
    expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
  });

  it('clears _undoSnapshot after use (single-step only)', () => {
    board._undoSnapshot = new Map([['u1', 'hidden']]);
    stateStore.getState.mockReturnValue('active');
    board.undo();
    expect(board._undoSnapshot).toBeNull();
  });

  it('is a no-op when _undoSnapshot is null', () => {
    board._undoSnapshot = null;
    board.undo();
    expect(controller.action).not.toHaveBeenCalled();
  });
});

describe('spotlight()', () => {
  it('sets focused user active and all others hidden (non-ghost)', () => {
    adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
    stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'active');
    board.spotlight('u1');
    expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
    expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
    expect(controller.action).not.toHaveBeenCalledWith('board', 'u3', expect.anything(), expect.anything(), expect.anything());
  });

  it('stores pre-spotlight snapshot in _spotlightSnapshot', () => {
    adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
    stateStore.getState.mockReturnValue('active');
    board.spotlight('u1');
    expect(board._spotlightSnapshot).toBeInstanceOf(Map);
    expect(board._spotlightSnapshot.has('u1')).toBe(true);
    expect(board._spotlightSnapshot.has('u2')).toBe(true);
  });

  it('clears _undoSnapshot when called', () => {
    board._undoSnapshot = new Map([['u1', 'hidden']]);
    adapter.users.all.mockReturnValue([{ id: 'u1' }]);
    stateStore.getState.mockReturnValue('active');
    board.spotlight('u1');
    expect(board._undoSnapshot).toBeNull();
  });

  it('is a no-op when userId is falsy', () => {
    board.spotlight(null);
    board.spotlight('');
    expect(controller.action).not.toHaveBeenCalled();
  });
});

describe('restoreSpotlight()', () => {
  it('restores participants to pre-spotlight states', () => {
    board._spotlightSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]);
    stateStore.getState.mockReturnValue('active');
    board.restoreSpotlight();
    expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
    expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
  });

  it('clears _spotlightSnapshot after restore', () => {
    board._spotlightSnapshot = new Map([['u1', 'active']]);
    stateStore.getState.mockReturnValue('active');
    board.restoreSpotlight();
    expect(board._spotlightSnapshot).toBeNull();
  });

  it('is a no-op when _spotlightSnapshot is null', () => {
    board._spotlightSnapshot = null;
    board.restoreSpotlight();
    expect(controller.action).not.toHaveBeenCalled();
  });
});

File Structure for This Story

Modified files:

src/ui/gm/DirectorsBoard.js              ← MODIFIED (bulk/spotlight/undo/shortcuts)
templates/directors-board.hbs           ← MODIFIED (bulk action bar, undo/restore buttons)
styles/components/_directors-board.less ← MODIFIED (bulk bar styles)
module.js                                ← MODIFIED (3 new keybinding registrations)
lang/en.json                             ← MODIFIED (bulk + shortcuts i18n keys)
tests/unit/ui/gm/DirectorsBoard.test.js ← MODIFIED (fix existing tests + add bulk/spotlight tests)

No new files required — all changes are additions/modifications to existing files.

Import Boundary Reminder (Hard Rule — ESLint-enforced)

src/ui/ → may import: src/core/, src/contracts/, src/utils/ ONLY

generateOpId is in src/utils/uuid.js — this import is allowed. Do NOT import from src/foundry/ inside src/ui/.

ApplicationV2 Re-render Pattern

After any state-mutating method (showAll, hideAll, undo, spotlight, restoreSpotlight), always trigger re-render to update the hasUndo/hasRestore flags in the template:

if (this.rendered) this.render({ force: true });

This is the existing pattern from _onStateChanged() — no change needed to the re-render mechanism.

Undo does NOT use stateStore.setMatrix()

Individual controller.action() calls per participant ensure socket broadcast. setMatrix() is reserved for Scene Preset apply (Story 3.1) and DOES NOT emit per-participant socket messages (per architecture deferred item: "No handling of setMatrix hook events in NotificationBus").

Using controller.action() per participant for undo/restore guarantees:

  • Socket broadcast to all clients
  • Optimistic state update
  • Pending-op tracking (revert on timeout)
  • NotificationBus triggers per-participant notifications

Story 2.2 Completion State (Baseline)

All 383 tests passing. Files created in Story 2.2:

  • src/ui/shared/ParticipantCard.jsbuildCardContext(), buildBoardContext(), resolveToggleTarget()
  • src/ui/gm/DirectorsBoard.js — ApplicationV2 window with toggle, keyboard nav, position persistence
  • templates/directors-board.hbs — grid layout, participant cards, disabled footer preset buttons
  • templates/participant-card.hbsrole="listitem", data-user-id, toggle overlay
  • styles/components/_directors-board.less — CSS grid, empty state, footer
  • styles/components/_participant-card.less — 80×100px card, sp-state-* variants
  • module.js — import, let directorsBoard, Ctrl+Shift+V keybinding, sidebar hook, ready wiring

Project Structure Notes

  • src/ui/gm/DirectorsBoard.js — already exists, extend in place
  • All new methods (showAll, hideAll, undo, spotlight, restoreSpotlight, spotlightFocused, _openShortcutPanel) are additions to the DirectorsBoard class
  • CSS classes follow BEM with directors-board__* namespace already established in _directors-board.less
  • i18n keys follow the established video-view-manager.directorsBoard.* namespace

References

  • Story ACs: [Source: _bmad-output/planning-artifacts/epics.md#Story 2.3]
  • FR-12 Bulk Show/Hide: [Source: _bmad-output/planning-artifacts/epics.md#FR-12]
  • FR-13 Spotlight: [Source: _bmad-output/planning-artifacts/epics.md#FR-13]
  • FR-14 Keyboard shortcuts: [Source: _bmad-output/planning-artifacts/epics.md#FR-14]
  • NFR-5 Accessibility: [Source: _bmad-output/planning-artifacts/epics.md#NFR-5]
  • UX-DR19 4-tier feedback pattern: [Source: _bmad-output/planning-artifacts/epics.md#UX-DR19]
  • Architecture — Bulk Show/Hide undo pattern (N1): [Source: _bmad-output/planning-artifacts/architecture.md#Notes]
  • Architecture — import boundary rule: [Source: _bmad-output/planning-artifacts/architecture.md#Import Boundary Rule]
  • ScryingPoolStrip._dispatchAction — positional controller.action() pattern: [Source: src/ui/gm/ScryingPoolStrip.js]
  • generateOpId utility: [Source: src/utils/uuid.js]
  • StateStore.getMatrix() / setMatrix(): [Source: src/core/StateStore.js]
  • ScryingPoolController.action() signature: [Source: src/core/ScryingPoolController.js:124]
  • ScryingPoolController.getRevision(): [Source: src/core/ScryingPoolController.js:72]
  • Story 2.2 — DirectorsBoard base implementation: [Source: _bmad-output/implementation-artifacts/2-2-directors-board-core-layout-and-participant-toggle.md]
  • Story 2.2 — deferred _dispatchToggle calling-convention bug: [Source: src/ui/gm/DirectorsBoard.js:139]
  • module.js — current keybinding registration pattern: [Source: module.js:94-107]

Dev Agent Record

Agent Model Used

Claude Sonnet 4.6

Debug Log References

  • Bug discovered in Task 1: _dispatchToggle() was calling controller.action({ userId, targetState }) (object) instead of positional args. Fixed to match ScryingPoolStrip._dispatchAction() pattern.
  • spotlight() captures snapshot from ALL users (including ghost) so restoreSpotlight has complete state picture, but only dispatches to non-ghost users.

Completion Notes List

  • Task 1: Fixed _dispatchToggle positional args bug; updated all related tests
  • Task 2: Implemented showAll()/hideAll() via shared _executeBulk() helper with ghost exclusion and pending-op skip
  • Task 3: Implemented undo() with single-step semantics; _undoSnapshot nulled immediately on use
  • Task 4: Implemented spotlight(), restoreSpotlight(), spotlightFocused(); mutual exclusion of snapshots enforced
  • Task 5: Extended _prepareContext() with hasUndo/hasRestore flags; 4 new tests
  • Task 6: Updated directors-board.hbs with bulk-bar; all labels via i18n keys; conditional Undo/Restore
  • Task 7: Rewrote _onRender() click handler to switch on data-action; added focusin listener for _focusedUserId; extended _onKeydown() with ? and Ctrl+Shift+P
  • Task 8: _openShortcutPanel() reads live keybinding bindings with defaults fallback; renders via new Dialog()
  • Task 9: Registered showAll, hideAll, spotlightParticipant keybindings in module.js
  • Task 10: Added all i18n keys under directorsBoard.bulk.*, directorsBoard.shortcuts.*, keybindings.*
  • Task 11: Added .directors-board__bulk-bar, .directors-board__bulk-btn (with --undo/--restore modifiers), .directors-board__help-btn CSS
  • Task 12: lint (no new errors introduced); 412 tests pass (+29 from 383 baseline)

File List

  • src/ui/gm/DirectorsBoard.js — major extension: showAll, hideAll, _executeBulk, undo, spotlight, restoreSpotlight, spotlightFocused, _openShortcutPanel; extended _onRender, _onKeydown, _prepareContext; constructor fields; /* global Dialog */
  • tests/unit/ui/gm/DirectorsBoard.test.js — +29 tests: showAll/hideAll (9), undo (6), spotlight/restore/spotlightFocused (10), _prepareContext hasUndo/hasRestore (4)
  • templates/directors-board.hbs — added bulk-bar with Show All, Hide All, conditional Undo/Restore, help button
  • module.js — registered 3 new keybindings: showAll, hideAll, spotlightParticipant
  • lang/en.json — added bulk., shortcuts., keybindings.* i18n keys
  • styles/components/_directors-board.less — added bulk-bar, bulk-btn (undo/restore variants), help-btn styles

Review Findings

Decision-Needed (Resolved)

  • [Review][Decision] Keybinding namespace inconsistency — Migrated all keybindings to scrying-pool namespace to align with existing module patterns
  • [Review][Decision] Keybindings reference undefined directorsBoard — Implemented lazy initialization pattern: callbacks check directorsBoard existence before calling
  • [Review][Decision] Import boundary violation — Moved buildBoardContext and resolveToggleTarget from ParticipantCard.js to new src/utils/boardUtils.js

Patch

  • [Review][Patch] Event listeners broken after close/reopen [DirectorsBoard.js] — Removed isFirstRender guard; added listener cleanup and re-registration on every render
  • [Review][Patch] No position loading from saved state [DirectorsBoard.js] — Added _loadPosition() in constructor to read saved position from user flags
  • [Review][Patch] spotlight() lacks userId validation [DirectorsBoard.js] — Added null/undefined guard and validation of userId against non-ghost users
  • [Review][Patch] DOM listener memory leak [DirectorsBoard.js] — Added listener cleanup in _onClose and proper cleanup on re-render
  • [Review][Patch] No scene control button cleanup [module.js] — Added directorsBoardButtonAdded flag and duplicate check in getSceneControlButtons hook
  • [Review][Patch] Race condition in _executeBulk [DirectorsBoard.js] — Capture all user states in single pass before filtering and snapshot
  • [Review][Patch] Race condition in spotlight [DirectorsBoard.js] — Get all user states atomically before filtering and snapshot
  • [Review][Patch] Ghost state transition in restoreSpotlight not handled [DirectorsBoard.js] — Check current state (not just snapshot) when restoring to avoid ghost transitions
  • [Review][Patch] _openShortcutPanel swallows Dialog.render errors [DirectorsBoard.js] — Added try/catch with error logging; checks both namespaces for keybindings
  • [Review][Patch] _savePosition swallows setFlag errors [DirectorsBoard.js] — Added try/catch with error logging
  • [Review][Patch] _onKeydown wraps focus incorrectly when idx=-1 [DirectorsBoard.js] — Added guard to return early if idx < 0
  • [Review][Patch] buildCardContext defaults null state to active [ParticipantCard.js:48] — Kept existing behavior; documented as pre-existing
  • [Review][Patch] Migrate all keybindings to scrying-pool namespace [module.js] — Updated namespace from video-view-manager to scrying-pool for showAll, hideAll, spotlightParticipant
  • [Review][Patch] Move buildBoardContext/resolveToggleTarget to src/utils/ [DirectorsBoard.js, ParticipantCard.js] — Created boardUtils.js with shared utilities; updated imports

Defer

  • [Review][Defer] buildCardContext null state defaults to active [ParticipantCard.js:48] — deferred, pre-existing issue in ParticipantCard.js

Dismiss

  • [Review][Dismiss] Unusual void parameter suppression [DirectorsBoard.js:272] — dismissed as stylistic
  • [Review][Dismiss] Outdated module comment [module.js:14-18] — dismissed as documentation
  • [Review][Dismiss] Inconsistent module identifiers — dismissed as cosmetic

Change Log

  • Story 2.3 implementation complete (Date: 2025-07-20)
  • Fixed Story 2.2 regression: _dispatchToggle positional args bug
  • Added Show All / Hide All bulk actions with single-step undo
  • Added Spotlight (Ctrl+Shift+P) with Restore snapshot
  • Added ? shortcut reference panel
  • Registered Ctrl+Shift+S, Ctrl+Shift+H, Ctrl+Shift+P keybindings
  • Code Review Fixes (Date: 2026-05-23)
    • Fixed event listeners broken after close/reopen
    • Added saved position loading in constructor
    • Added userId validation in spotlight()
    • Fixed DOM listener memory leaks with proper cleanup
    • Prevented duplicate scene control button addition
    • Fixed race conditions in _executeBulk and spotlight via atomic state capture
    • Fixed ghost state transition handling in restoreSpotlight
    • Added error handling in _openShortcutPanel and _savePosition
    • Fixed focus navigation edge case with negative index guard
    • Migrated keybindings to consistent scrying-pool namespace
    • Fixed import boundary violation by moving utilities to src/utils/boardUtils.js