37 KiB
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
-
Given the Director's Board is open When the GM clicks "Show All" Then all participants' states are set to
active(excludingghost-state participants) And the action is broadcast to all clients -
Given the Director's Board is open When the GM clicks "Hide All" Then all participants' states are set to
hidden(excludingghost-state participants) And the action is broadcast to all clients -
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)
-
Given a participant card is focused When the GM presses
Ctrl+Shift+PThen 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 -
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
-
Given
Ctrl+Shift+SorCtrl+Shift+His pressed When the event fires Then "Show All" or "Hide All" executes as if the button were clicked -
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 -
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+Pare all configurable And the?panel reflects the currently configured bindings
Tasks / Subtasks
-
Task 1: Fix
_dispatchTogglecalling convention inDirectorsBoard.js(AC: foundational bug fix from Story 2.2)- 1.1: Add
import { generateOpId } from '../../utils/uuid.js';at top ofDirectorsBoard.js - 1.2: Rewrite
_dispatchToggle(userId)to use positional args:controller.action('board', userId, targetState, opId, baseRevision)— matching ScryingPoolStrip's_dispatchActionpattern - 1.3: Update existing
_dispatchToggletests intests/unit/ui/gm/DirectorsBoard.test.js— replaceexpect(controller.action).toHaveBeenCalledWith({ userId, targetState })withexpect(controller.action).toHaveBeenCalledWith('board', userId, targetState, expect.any(String), expect.any(Number)); addgetRevision: vi.fn(() => 0)to the controller mock
- 1.1: Add
-
Task 2: Implement
showAll()andhideAll()methods onDirectorsBoard(AC: 1, 2, 3)- 2.1: Write TDD red tests in
DirectorsBoard.test.js— newdescribe('showAll()')anddescribe('hideAll()')blocks - 2.2: Implement
showAll(): capture pre-action snapshot →this._undoSnapshot = new Map(nonGhostUsers.map(u => [u.id, this._stateStore.getState(u.id)]))→ callcontroller.action('board', userId, 'active', opId, baseRevision)for each non-ghost participant → clearthis._spotlightSnapshot(spotlight superseded) - 2.3: Implement
hideAll(): same pattern but target state'hidden'→ similarly clears_spotlightSnapshot - 2.4:
ghostexclusion rule: checkthis._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
- 2.1: Write TDD red tests in
-
Task 3: Implement
undo()method and single-step undo state (AC: 3)- 3.1: Add
this._undoSnapshot = null;andthis._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_undoSnapshotis null; second undo unavailable after first - 3.3: Implement
undo(): guardif (!this._undoSnapshot) return; for each[userId, targetState]entry in_undoSnapshot: skip ghost-state users and pending-op users; callcontroller.action('board', userId, targetState, opId, baseRevision); thenthis._undoSnapshot = null; trigger re-render - 3.4: Green all undo tests
- 3.1: Add
-
Task 4: Implement
spotlight(userId)andrestoreSpotlight()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; capturethis._spotlightSnapshot = new Map(nonGhostUsers.map(u => [u.id, this._stateStore.getState(u.id)]))→ clearthis._undoSnapshot(spotlight supersedes bulk undo); iterate non-ghost users:activefor the spotlighted user,hiddenfor all others; trigger re-render - 4.3: Implement
restoreSpotlight(): guardif (!this._spotlightSnapshot) return; for each[userId, targetState]in snapshot: skip ghost + pending-op; callcontroller.action('board', userId, targetState, opId, baseRevision);this._spotlightSnapshot = null; trigger re-render - 4.4: Add
spotlightFocused()public method: readsdocument.activeElement?.dataset?.userIdfrom within the board's element; callsspotlight(userId)if valid — used by keyboard shortcut callback - 4.5: Green all spotlight tests
- 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
-
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
_prepareContexttests to verify the two new fields
- 5.1: Extend
-
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}}
- "Show All" button:
- 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
- 6.1: Add a
-
Task 7: Wire bulk-action event delegation in
_onRender()(AC: 1, 2, 3, 5, 7)- 7.1: Extend the existing delegated
clicklistener in_onRender()to handle newdata-actionvalues:show-all→this.showAll()hide-all→this.hideAll()undo→this.undo()restore-spotlight→this.restoreSpotlight()open-shortcut-panel→this._openShortcutPanel()
- 7.2: Add
?keydown handler in_onKeydown():if (e.key === '?') { e.preventDefault(); this._openShortcutPanel(); } - 7.3: Extend
Ctrl+Shift+Pkeyboard shortcut handler in_onKeydown():if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') { e.preventDefault(); this.spotlightFocused(); }
- 7.1: Extend the existing delegated
-
Task 8: Implement
_openShortcutPanel()(AC: 7, 8)- 8.1: Implement
_openShortcutPanel()method: reads current bindings fromgame.keybindings.bindingsfor each registered action key (openDirectorsBoard,showAll,hideAll,spotlightParticipant); builds an HTML string listing each shortcut name + current binding; opens as a native FoundryDialog.prompt()ornew 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)
- 8.1: Implement
-
Task 9: Register new keybindings in
module.js(AC: 6, 8)- 9.1: Register
scrying-pool.showAllkeybinding inHooks.once('init'):key: 'KeyS', modifiers: ['Control', 'Shift'],restricted: true,onDown: () => directorsBoard?.showAll() - 9.2: Register
scrying-pool.hideAllkeybinding:key: 'KeyH', modifiers: ['Control', 'Shift'],restricted: true,onDown: () => directorsBoard?.hideAll() - 9.3: Register
scrying-pool.spotlightParticipantkeybinding: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
- 9.1: Register
-
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
- 10.1: Add
-
Task 11: Add bulk-action bar CSS in
styles/components/_directors-board.less(AC: 1, 2, 3, 5)- 11.1: Add
.directors-board__bulk-barstyles: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-btnstyles: small circular button, top-right positioning within title/header area
- 11.1: Add
-
Task 12: Pipeline verification
- 12.1:
npm run lintexits 0 for all modified files - 12.2:
npm run testexits 0 — expected: 383 baseline + new bulk/spotlight/undo/shortcut tests (~25–35 new tests)
- 12.1:
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_spotlightSnapshotspotlight()→ set_spotlightSnapshot, clear_undoSnapshotundo()→ 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) ✅
NotificationBustriggers 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 persistencetemplates/directors-board.hbs— grid layout, participant cards, disabled footer preset buttonstemplates/participant-card.hbs—role="listitem",data-user-id, toggle overlaystyles/components/_directors-board.less— CSS grid, empty state, footerstyles/components/_participant-card.less— 80×100px card, sp-state-* variantsmodule.js— import,let directorsBoard,Ctrl+Shift+Vkeybinding, 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 theDirectorsBoardclass - 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 callingcontroller.action({ userId, targetState })(object) instead of positional args. Fixed to matchScryingPoolStrip._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
_dispatchTogglepositional 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;_undoSnapshotnulled immediately on use - ✅ Task 4: Implemented
spotlight(),restoreSpotlight(),spotlightFocused(); mutual exclusion of snapshots enforced - ✅ Task 5: Extended
_prepareContext()withhasUndo/hasRestoreflags; 4 new tests - ✅ Task 6: Updated
directors-board.hbswith bulk-bar; all labels via i18n keys; conditional Undo/Restore - ✅ Task 7: Rewrote
_onRender()click handler to switch ondata-action; added focusin listener for_focusedUserId; extended_onKeydown()with?andCtrl+Shift+P - ✅ Task 8:
_openShortcutPanel()reads live keybinding bindings with defaults fallback; renders vianew Dialog() - ✅ Task 9: Registered
showAll,hideAll,spotlightParticipantkeybindings inmodule.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/--restoremodifiers),.directors-board__help-btnCSS - ✅ 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 buttonmodule.js— registered 3 new keybindings: showAll, hideAll, spotlightParticipantlang/en.json— added bulk., shortcuts., keybindings.* i18n keysstyles/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-poolnamespace 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
buildBoardContextandresolveToggleTargetfromParticipantCard.jsto newsrc/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:
_dispatchTogglepositional 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-poolnamespace - Fixed import boundary violation by moving utilities to src/utils/boardUtils.js