# 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

- [x] Task 1: Fix `_dispatchToggle` calling convention in `DirectorsBoard.js` (AC: foundational bug fix from Story 2.2)
  - [x] 1.1: Add `import { generateOpId } from '../../utils/uuid.js';` at top of `DirectorsBoard.js`
  - [x] 1.2: Rewrite `_dispatchToggle(userId)` to use positional args: `controller.action('board', userId, targetState, opId, baseRevision)` — matching ScryingPoolStrip's `_dispatchAction` pattern
  - [x] 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

- [x] Task 2: Implement `showAll()` and `hideAll()` methods on `DirectorsBoard` (AC: 1, 2, 3)
  - [x] 2.1: Write TDD red tests in `DirectorsBoard.test.js` — new `describe('showAll()')` and `describe('hideAll()')` blocks
  - [x] 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)
  - [x] 2.3: Implement `hideAll()`: same pattern but target state `'hidden'` → similarly clears `_spotlightSnapshot`
  - [x] 2.4: `ghost` exclusion rule: check `this._stateStore.getState(userId) === 'ghost'` before acting; skip those users
  - [x] 2.5: Skip participants that already have a pending op: check `this._controller.hasPendingOp?.(userId)`
  - [x] 2.6: After showAll/hideAll, trigger re-render to reflect Undo button visibility: `if (this.rendered) this.render({ force: true })`
  - [x] 2.7: Green all showAll/hideAll tests

- [x] Task 3: Implement `undo()` method and single-step undo state (AC: 3)
  - [x] 3.1: Add `this._undoSnapshot = null;` and `this._spotlightSnapshot = null;` to constructor
  - [x] 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
  - [x] 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
  - [x] 3.4: Green all undo tests

- [x] Task 4: Implement `spotlight(userId)` and `restoreSpotlight()` methods (AC: 4, 5)
  - [x] 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`
  - [x] 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
  - [x] 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
  - [x] 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
  - [x] 4.5: Green all spotlight tests

- [x] Task 5: Update `_prepareContext()` to expose bulk-action state flags (AC: 3, 5)
  - [x] 5.1: Extend `_prepareContext()` return value with: `{ ..., hasUndo: this._undoSnapshot !== null, hasRestore: this._spotlightSnapshot !== null }`
  - [x] 5.2: Update `_prepareContext` tests to verify the two new fields

- [x] Task 6: Update `templates/directors-board.hbs` (AC: 1, 2, 3, 5, 7)
  - [x] 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}}`
  - [x] 6.2: All button labels via `{{localize "video-view-manager.directorsBoard.bulk.*"}}` keys (never inline English)
  - [x] 6.3: Undo and Restore buttons use distinct styling (Undo = secondary; Restore = spotlight-accent)
  - [x] 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

- [x] Task 7: Wire bulk-action event delegation in `_onRender()` (AC: 1, 2, 3, 5, 7)
  - [x] 7.1: Extend the existing delegated `click` listener in `_onRender()` to handle new `data-action` values:
    - `show-all` → `this.showAll()`
    - `hide-all` → `this.hideAll()`
    - `undo` → `this.undo()`
    - `restore-spotlight` → `this.restoreSpotlight()`
    - `open-shortcut-panel` → `this._openShortcutPanel()`
  - [x] 7.2: Add `?` keydown handler in `_onKeydown()`: `if (e.key === '?') { e.preventDefault(); this._openShortcutPanel(); }`
  - [x] 7.3: Extend `Ctrl+Shift+P` keyboard shortcut handler in `_onKeydown()`: `if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') { e.preventDefault(); this.spotlightFocused(); }`

- [x] Task 8: Implement `_openShortcutPanel()` (AC: 7, 8)
  - [x] 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
  - [x] 8.2: i18n all shortcut names — use `adapter.i18n.localize()` for label strings (or fallback to display name string if localize not available)
  - [x] 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)

- [x] Task 9: Register new keybindings in `module.js` (AC: 6, 8)
  - [x] 9.1: Register `scrying-pool.showAll` keybinding in `Hooks.once('init')`: `key: 'KeyS', modifiers: ['Control', 'Shift']`, `restricted: true`, `onDown: () => directorsBoard?.showAll()`
  - [x] 9.2: Register `scrying-pool.hideAll` keybinding: `key: 'KeyH', modifiers: ['Control', 'Shift']`, `restricted: true`, `onDown: () => directorsBoard?.hideAll()`
  - [x] 9.3: Register `scrying-pool.spotlightParticipant` keybinding: `key: 'KeyP', modifiers: ['Control', 'Shift']`, `restricted: true`, `onDown: () => directorsBoard?.spotlightFocused()`
  - [x] 9.4: Update the module.js header comment to include Story 2.3 keybinding wiring

- [x] Task 10: Add i18n keys in `lang/en.json` (AC: 1, 2, 3, 5, 7)
  - [x] 10.1: Add `video-view-manager.directorsBoard.bulk.showAll` = `"Show All"`
  - [x] 10.2: Add `video-view-manager.directorsBoard.bulk.hideAll` = `"Hide All"`
  - [x] 10.3: Add `video-view-manager.directorsBoard.bulk.undo` = `"Undo"`
  - [x] 10.4: Add `video-view-manager.directorsBoard.bulk.restore` = `"Restore"`
  - [x] 10.5: Add `video-view-manager.directorsBoard.bulk.spotlight` = `"Spotlight"`
  - [x] 10.6: Add `video-view-manager.directorsBoard.shortcuts.title` = `"Keyboard Shortcuts"`
  - [x] 10.7: Add `video-view-manager.directorsBoard.shortcuts.openBoard` = `"Open/Close Board"`
  - [x] 10.8: Add `video-view-manager.directorsBoard.shortcuts.showAll` = `"Show All Participants"`
  - [x] 10.9: Add `video-view-manager.directorsBoard.shortcuts.hideAll` = `"Hide All Participants"`
  - [x] 10.10: Add `video-view-manager.directorsBoard.shortcuts.spotlight` = `"Spotlight Focused Participant"`
  - [x] 10.11: Add `video-view-manager.directorsBoard.shortcuts.openPanel` = `"Open Shortcut Reference"`
  - [x] 10.12: Add keybinding label and hint strings under `video-view-manager.keybindings.showAll` / `hideAll` / `spotlightParticipant`

- [x] Task 11: Add bulk-action bar CSS in `styles/components/_directors-board.less` (AC: 1, 2, 3, 5)
  - [x] 11.1: Add `.directors-board__bulk-bar` styles: `display: flex; gap: 8px; padding: 8px; border-top: 1px solid var(--sp-border);`
  - [x] 11.2: Style "Show All" / "Hide All" as primary action buttons using existing `--sp-*` tokens
  - [x] 11.3: Style "Undo" as secondary; "Restore" with a spotlight-accent color (distinct from Undo — per AC 5)
  - [x] 11.4: Add `.directors-board__help-btn` styles: small circular button, top-right positioning within title/header area

- [x] Task 12: Pipeline verification
  - [x] 12.1: `npm run lint` exits 0 for all modified files
  - [x] 12.2: `npm run test` exits 0 — expected: 383 baseline + new bulk/spotlight/undo/shortcut tests (~25–35 new tests)

## Dev Notes

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

`DirectorsBoard._dispatchToggle()` currently calls:
```js
this._controller.action({ userId, targetState }); // ← WRONG: passing object
```

But `ScryingPoolController.action()` signature is **positional**:
```js
action(source, participantId, targetState, opId, baseRevision)
```

**Fix** — match `ScryingPoolStrip._dispatchAction()` pattern exactly:
```js
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:
```js
// 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:

```js
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)

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

```js
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:

```js
_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

```js
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:

```js
_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:

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

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

```js
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.js` — `buildCardContext()`, `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.hbs` — `role="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)
- [x] [Review][Decision] Keybinding namespace inconsistency — Migrated all keybindings to `scrying-pool` namespace to align with existing module patterns
- [x] [Review][Decision] Keybindings reference undefined directorsBoard — Implemented lazy initialization pattern: callbacks check directorsBoard existence before calling
- [x] [Review][Decision] Import boundary violation — Moved `buildBoardContext` and `resolveToggleTarget` from `ParticipantCard.js` to new `src/utils/boardUtils.js`

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

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

#### Dismiss
- [x] [Review][Dismiss] Unusual void parameter suppression [DirectorsBoard.js:272] — dismissed as stylistic
- [x] [Review][Dismiss] Outdated module comment [module.js:14-18] — dismissed as documentation
- [x] [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
