669 lines
37 KiB
Markdown
669 lines
37 KiB
Markdown
# 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
|