# Story 2.2: Director's Board — Core Layout & Participant Toggle Status: done ## Story As a **GM**, I want a dedicated floating board showing all participants in a seating-chart layout with per-participant visibility toggle, so that I can manage all camera states at a glance without right-clicking individual AV tiles. ## Acceptance Criteria 1. **Given** the module is active and the user is GM **When** the GM presses `Ctrl+Shift+V` or clicks the dedicated sidebar button **Then** the Director's Board opens as a resizable, draggable `ApplicationV2` window 2. **Given** the Director's Board is open **When** it renders **Then** every connected participant has a `ParticipantCard` (80×100px: 48px avatar + `StateRing` + name 12px 2-line truncate + hover toggle-icon overlay) **And** cards are laid out in a CSS grid: `auto-fill, minmax(80px, 1fr)` 3. **Given** a participant's state changes **When** the socket broadcast completes **Then** the Director's Board updates that participant's card within 500ms **And** the board is a dumb view — subscribes to `scrying-pool:stateChanged` Hook with no local state cache 4. **Given** the GM clicks a participant card **When** the click is processed **Then** the participant's visibility toggles between `active` and `hidden` **And** the behaviour and persistence match FR-1 (same as AV tile right-click — goes through `controller.action()`) 5. **Given** the GM uses keyboard navigation **When** arrow keys are pressed in the board **Then** focus moves between participant cards **And** `Space` or `Enter` toggles the focused participant's visibility 6. **Given** `Ctrl+Shift+V` is pressed while the board is already open **When** the event fires **Then** the board closes (singleton toggle behaviour) 7. **Given** the user is not GM **When** they attempt to open the Director's Board **Then** the sidebar button is not shown and the keyboard shortcut has no effect 8. **Given** a screen reader user navigates to a `ParticipantCard` **When** focus lands **Then** `role="listitem"`, `aria-label="[Name] — [state label]"` is announced **And** the hover toggle icon is independently keyboard-focusable with `role="button"` and a descriptive `aria-label` ("Hide [Name] from table" or "Show [Name] to table") ## Tasks / Subtasks - [x] Task 1: Create `src/ui/shared/ParticipantCard.js` (AC: 2, 3, 4, 5, 8) - [x] 1.1: Write failing tests in `tests/unit/ui/shared/ParticipantCard.test.js` (TDD red) — test `buildCardContext()` and `resolveToggleTarget()` - [x] 1.2: Export `function buildCardContext(userId, stateStore, controller, adapter)` — returns `{ userId, name, avatarSrc, state, stateLabel, hasPendingOp, isHidden, toggleAriaLabel, cardAriaLabel }` (no side effects) - [x] 1.3: Export `function resolveToggleTarget(currentState)` — returns `'hidden'` when `currentState !== 'hidden'`, else `'active'` - [x] 1.4: Export `function buildBoardContext(stateStore, controller, adapter)` — calls `adapter.users.all()`, maps each user id through `buildCardContext()`, returns `{ participants, isEmpty }` - [x] 1.5: Green all ParticipantCard tests - [x] Task 2: Create `src/ui/gm/DirectorsBoard.js` (AC: 1, 2, 3, 4, 5, 6, 7, 8) - [x] 2.1: Write failing tests in `tests/unit/ui/gm/DirectorsBoard.test.js` (TDD red) - [x] 2.2: Implement conditional base-class pattern for test compatibility (see Dev Notes → ApplicationV2 Conditional Pattern) - [x] 2.3: Implement `static DEFAULT_OPTIONS` and `static PARTS` (one part: `board`) - [x] 2.4: Implement `async _prepareContext(options)` — calls `buildBoardContext(stateStore, controller, adapter)`; reads position from GM User flag; returns context - [x] 2.5: Implement event delegation: single `click` listener on app root dispatching via `data-action="toggle-participant"` and `data-user-id`; call `_dispatchToggle(userId)` - [x] 2.6: Implement keyboard navigation: `keydown` listener on board root; `ArrowLeft/Right/Up/Down` moves focus between `[data-user-id]` cards; `Space/Enter` dispatches toggle on focused card - [x] 2.7: Implement `_dispatchToggle(userId)` — reads current state from `stateStore.getState(userId)`, resolves `resolveToggleTarget(state)`, calls `controller.action({ userId, targetState })` - [x] 2.8: Implement `_onStateChanged(data)` — hook handler; calls `this.render({ force: true })` if board is rendered - [x] 2.9: Implement `init()` — registers `Hooks.on('scrying-pool:stateChanged', ...)`, stores `_hookId` - [x] 2.10: Implement `teardown()` — `Hooks.off('scrying-pool:stateChanged', this._hookId)`, `this._hookId = null` - [x] 2.11: Implement position persistence: on `_onClose()` and `_onPosition()`, save `{left, top, width, height, open}` to `game.user.setFlag('video-view-manager', 'directorsBoardState', ...)` - [x] 2.12: Implement `toggle()` public method — if `this.rendered` → `this.close()`; else → `this.render({ force: true })` - [x] 2.13: Green all DirectorsBoard tests - [x] Task 3: Complete `templates/directors-board.hbs` (AC: 2, 5, 8) - [x] 3.1: Replace stub with full board layout: `
` wrapping cards grid - [x] 3.2: Render each participant via `{{> participant-card}}` partial (or inline using card context) - [x] 3.3: Add empty state: `{{#unless participants.length}}

...

{{/unless}}` - [x] 3.4: Add footer stub for future Preset actions (disabled): `` - [x] Task 4: Complete `templates/participant-card.hbs` (AC: 2, 8) - [x] 4.1: Replace stub with: `
` - [x] 4.2: Add avatar: `
{{name}}
` - [x] 4.3: Add name: `

{{name}}

` - [x] 4.4: Add toggle button overlay: `` - [x] Task 5: Style `styles/components/_participant-card.less` and `_directors-board.less` (AC: 2) - [x] 5.1: `_participant-card.less` — card 80×100px, avatar 48px, 12px name with 2-line truncate, hover reveals toggle overlay; `sp-state-*` classes apply border ring color+shape per token system; `sp-state-pending` uses spinner icon - [x] 5.2: `_directors-board.less` — CSS grid `display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px;`; board scoped to `.scrying-pool.directors-board`; footer layout; empty-state styles - [x] Task 6: Wire keyboard shortcut + sidebar button in `module.js` (AC: 1, 6, 7) - [x] 6.1: Import `DirectorsBoard` in `module.js` - [x] 6.2: In `Hooks.once('init')`: register keybinding `scrying-pool.openDirectorsBoard` (`Ctrl+Shift+V`, `restricted: true`, singleton-guarded via `if (adapter.users.isGM())`) - [x] 6.3: In `Hooks.once('init')`: register `Hooks.on('getSceneControlButtons', controls => { ... })` to inject GM-only sidebar icon (see Dev Notes) - [x] 6.4: In `Hooks.once('ready')`, after `notificationBus.init()`: `if (adapter.users.isGM()) { directorsBoard = new DirectorsBoard(stateStore, scryingPoolController, adapter); directorsBoard.init(); }` - [x] 6.5: Keyboard shortcut callback calls `directorsBoard?.toggle()` — no-op if `directorsBoard` is null - [x] Task 7: Add i18n keys in `lang/en.json` (AC: 1, 2, 8) - [x] 7.1: Add `video-view-manager.directorsBoard.title` = `"Director's Board"` - [x] 7.2: Add `video-view-manager.directorsBoard.empty` = `"No participants connected."` - [x] 7.3: Add `video-view-manager.directorsBoard.openButton` = `"Open Director's Board"` - [x] 7.4: Add `video-view-manager.directorsBoard.footer.savePreset` = `"Save Preset…"` - [x] 7.5: Add `video-view-manager.directorsBoard.footer.loadPreset` = `"Load Preset…"` - [x] Task 8: Pipeline verification - [x] 8.1: `npm run lint` exits 0 for all modified files - [x] 8.2: `npm run test` exits 0 — expected: 335 baseline + new DirectorsBoard + ParticipantCard tests ## Dev Notes ### New File Locations ``` src/ui/gm/DirectorsBoard.js ← NEW (same directory as ScryingPoolStrip.js) src/ui/shared/ParticipantCard.js ← NEW (same directory as AVTileAdapter.js) tests/unit/ui/gm/DirectorsBoard.test.js ← NEW tests/unit/ui/shared/ParticipantCard.test.js ← NEW ``` ### Import Boundary (Hard Rule — ESLint-enforced) ``` src/ui/ → may import: src/core/, src/contracts/, src/utils/ ONLY ``` ❌ Do NOT import from `src/foundry/`, `src/notifications/`, or `src/presets/` inside `src/ui/`. ESLint `import/no-restricted-paths` will catch violations at lint time. ### ApplicationV2 Conditional Pattern for Test Compatibility The project does NOT have ApplicationV2 available in the test environment. Use the same conditional-base-class pattern as `ScryingPoolStrip.js`: ```js // @ts-nocheck /* global foundry */ import { buildBoardContext, resolveToggleTarget } from '../shared/ParticipantCard.js'; // Conditional base class — test environment lacks foundry globals const _AppBase = typeof foundry !== 'undefined' && foundry.applications?.api?.HandlebarsApplicationMixin && foundry.applications?.api?.ApplicationV2 ? foundry.applications.api.HandlebarsApplicationMixin( foundry.applications.api.ApplicationV2 ) : class _FallbackApp { static DEFAULT_OPTIONS = {}; static PARTS = {}; get rendered() { return false; } async render(_opts) {} async close(_opts) {} async _prepareContext(_opts) { return {}; } }; export class DirectorsBoard extends _AppBase { static DEFAULT_OPTIONS = { id: 'scrying-pool-directors-board', classes: ['scrying-pool', 'directors-board'], window: { title: "Director's Board", resizable: true }, position: { width: 400, height: 300 }, }; static PARTS = { board: { template: 'modules/video-view-manager/templates/directors-board.hbs', }, }; constructor(stateStore, controller, adapter, options = {}) { super(options); this._stateStore = stateStore; this._controller = controller; this._adapter = adapter; this._hookId = null; } // ... } ``` ### ApplicationV2 API Differences from Application | `Application` (old — ScryingPoolStrip) | `ApplicationV2` (new — DirectorsBoard) | |---|---| | `static get defaultOptions()` | `static DEFAULT_OPTIONS = {}` | | `defaultOptions.template` | `static PARTS = { key: { template: '...' } }` | | `getData()` | `async _prepareContext(options)` | | `activateListeners(html)` | Event delegation on app root (do NOT add per-child listeners) | | `this.render(true)` | `this.render({ force: true })` | | `super.defaultOptions` merge | No super merge needed — set directly in DEFAULT_OPTIONS | ⚠️ **Critical:** ApplicationV2 rerenders replace inner DOM. Never attach event listeners to child nodes inside `PARTS` templates. Use event delegation on the application root element (`.element` or the outermost container) in `_onRender()`. ### Event Delegation Pattern ```js // Override in DirectorsBoard: _onRender(context, options) { super._onRender?.(context, options); const root = this.element; // ApplicationV2: this.element is the outermost DOM node // Single delegated listener — survives re-renders because root persists root.addEventListener('click', (e) => { const btn = e.target.closest('[data-action="toggle-participant"]'); if (!btn) return; e.stopPropagation(); this._dispatchToggle(btn.dataset.userId); }); // Keyboard navigation on the cards list const list = root.querySelector('[role="list"]'); list?.addEventListener('keydown', (e) => this._onKeydown(e)); } ``` ### Keyboard Navigation Implementation ```js _onKeydown(e) { const cards = [...this.element.querySelectorAll('[data-user-id]')]; const current = document.activeElement; const idx = cards.indexOf(current); if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); cards[(idx + 1) % cards.length]?.focus(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); cards[(idx - 1 + cards.length) % cards.length]?.focus(); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (current?.dataset?.userId) this._dispatchToggle(current.dataset.userId); } } ``` ### Toggle Dispatch — Must Match FR-1 Behaviour Use `controller.action()` exactly as `ScryingPoolStrip._dispatchAction()` does: ```js _dispatchToggle(userId) { if (!userId) return; const currentState = this._stateStore.getState(userId) ?? 'active'; const targetState = resolveToggleTarget(currentState); // Pending op guard (same pattern as ScryingPoolStrip) if (this._controller.hasPendingOp?.(userId)) return; this._controller.action({ userId, targetState }); } ``` ⚠️ Do NOT call `stateStore.setState()` or `adapter.socket.emit()` directly. Always go through `controller.action()`. ### Dumb View Rule (AC-3) DirectorsBoard reads state on every render. No local state cache: ```js async _prepareContext(options) { return buildBoardContext(this._stateStore, this._controller, this._adapter); } ``` `buildBoardContext()` calls `this._stateStore.getState(userId)` fresh each time. ### State Update ≤500ms (AC-3) `_onStateChanged(data)` must call `this.render({ force: true })`. The socket broadcast → `StateStore` update → `Hooks.callAll('scrying-pool:stateChanged')` pipeline is already sub-100ms. Re-rendering on every hook event is correct and sufficient. ### Singleton Guard + Position Persistence ```js // In module.js Hooks.once('ready'): directorsBoard = new DirectorsBoard(stateStore, scryingPoolController, adapter); directorsBoard.init(); // Keyboard shortcut callback (Hooks.once('init')): game.keybindings.register('scrying-pool', 'openDirectorsBoard', { name: 'Open/Close Director\'s Board', hint: 'Toggles the Director\'s Board window', editable: [{ key: 'KeyV', modifiers: ['Control', 'Shift'] }], restricted: true, // GM only onDown: () => directorsBoard?.toggle(), }); ``` Position saved to GM User flag `'video-view-manager', 'directorsBoardState'` as `{left, top, width, height, open}` — same flag namespace pattern as `stripState`. ### Sidebar Button — `getSceneControlButtons` Hook ```js // Hooks.once('init') in module.js: Hooks.on('getSceneControlButtons', (controls) => { if (!game.user?.isGM) return; const avControls = controls.find(c => c.name === 'token'); // or 'lighting' // Add to the tools of an existing group, or append a standalone button // The exact hook signature changed in v12+; use the 'notes' or 'basic' group as fallback // See Dev Notes → Sidebar Button Approach }); ``` ⚠️ **Sidebar button implementation note:** Foundry v14's `getSceneControlButtons` hook provides an array of control groups. The safest approach is to add a tool button to the existing AV/token controls group rather than creating a new top-level group. The exact API depends on the Foundry v14 hook signature. Research during implementation with `Hooks.once('ready', () => console.log(ui.controls?.controls))` to inspect available control groups. If the hook API is uncertain, fall back to a simple `