# 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 `
` section between the grid and footer with: - "Show All" 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 │ spotlight │ undo() ↓ ↓ null ←──────── null │ spotlight() ────┘ _spotlightSnapshot: null ─── spotlight() ──→ Map │ 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]) => `${adapter.i18n.localize(`video-view-manager.${labelKey}`)} ${getBinding(action)}` ).join(''); new Dialog({ title: adapter.i18n.localize('video-view-manager.directorsBoard.shortcuts.title'), content: `${rows}
`, 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