# 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: `<section class="scrying-pool directors-board" role="list" aria-label="Director's Board">` 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}}<p class="directors-board__empty">...</p>{{/unless}}`
  - [x] 3.4: Add footer stub for future Preset actions (disabled): `<footer class="directors-board__footer"><button disabled>Save Preset…</button><button disabled>Load Preset…</button></footer>`

- [x] Task 4: Complete `templates/participant-card.hbs` (AC: 2, 8)
  - [x] 4.1: Replace stub with: `<div class="scrying-pool participant-card sp-state-{{state}}{{#if hasPendingOp}} sp-state-pending{{/if}}" role="listitem" aria-label="{{cardAriaLabel}}" data-user-id="{{userId}}" tabindex="0">`
  - [x] 4.2: Add avatar: `<div class="participant-card__avatar"><img src="{{avatarSrc}}" alt="{{name}}"></div>`
  - [x] 4.3: Add name: `<p class="participant-card__name">{{name}}</p>`
  - [x] 4.4: Add toggle button overlay: `<button class="participant-card__toggle" data-action="toggle-participant" data-user-id="{{userId}}" role="button" aria-label="{{toggleAriaLabel}}" tabindex="-1"><i class="fas {{#if isHidden}}fa-eye{{else}}fa-eye-slash{{/if}}" aria-hidden="true"></i></button>`

- [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 `<button>` rendered inside the `ScryingPoolStrip` footer with CTA "Open Director's Board ↗" (the UX spec mentions this as the strip footer CTA, which avoids the sidebar API entirely).

### Canonical Action Labels (Reuse from ScryingPoolStrip)

```js
// Import from ScryingPoolStrip to avoid duplication:
import { LABELS, resolveTargetState } from './ScryingPoolStrip.js';
// Or: re-export the same constants from ParticipantCard.js as the canonical source for card-specific labels
```

⚠️ UX-DR21: "Hide from table" and "Show to table" verbatim. Same string constants as ScryingPoolStrip, never synonyms. The `toggleAriaLabel` in `buildCardContext` should return:
- `"Hide {name} from table"` when visible
- `"Show {name} to table"` when hidden

### `buildCardContext()` Reference Implementation

```js
export function buildCardContext(userId, stateStore, controller, adapter) {
  const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
  const state = stateStore.getState(userId) ?? 'active';
  const isHidden = state === 'hidden';
  const name = user.name ?? userId;
  return {
    userId,
    name,
    avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg',
    state,
    stateLabel: _stateLabel(state),
    hasPendingOp: controller.hasPendingOp?.(userId) ?? false,
    isHidden,
    cardAriaLabel: `${name} — ${_stateLabel(state)}`,
    toggleAriaLabel: isHidden ? `Show ${name} to table` : `Hide ${name} from table`,
  };
}

export function buildBoardContext(stateStore, controller, adapter) {
  const userIds = adapter.users.all?.() ?? [];
  const participants = userIds.map(u => buildCardContext(u.id ?? u, stateStore, controller, adapter));
  return { participants, isEmpty: participants.length === 0 };
}
```

The `_stateLabel()` function already exists in `ScryingPoolStrip.js` — copy the same implementation (same 8 states + same labels). Do NOT import it from ScryingPoolStrip (import boundary: both are in `src/ui/` which is fine, but keeping utility functions co-located in `ParticipantCard.js` is cleaner).

### CSS Token Rules (No Exceptions)

```less
// styles/components/_participant-card.less
// All selectors MUST be under .scrying-pool
// Use --sp-* tokens only — no --color-*, --font-*, --border-* Foundry tokens directly

.scrying-pool .participant-card {
  width: 80px;
  height: 100px;
  position: relative;
  border: 2px solid var(--sp-border);
  border-radius: 4px;
  cursor: pointer;

  // State ring — border color and shape driven by sp-state-* tokens
  &.sp-state-hidden { border-color: var(--sp-state-hidden-border); border-style: dashed; }
  &.sp-state-active { border-color: var(--sp-state-active-border); border-style: solid; }
  // ... all 9 states

  &__avatar {
    width: 48px;
    height: 48px;
    margin: 8px auto 4px;
    display: block;
    border-radius: 50%;
    object-fit: cover;
  }

  &__name {
    font-size: 12px;
    text-align: center;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    padding: 0 4px;
    color: var(--sp-text-primary);
  }

  &__toggle {
    position: absolute;
    inset: 0;
    opacity: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--sp-surface);
    transition: opacity var(--sp-fade-hide);

    &:focus { opacity: 1; }  // always visible for keyboard users
  }

  &:hover &__toggle,
  &:focus-within &__toggle { opacity: 1; }

  &:focus { @include sp-focus-ring(); }
}
```

### Focus Ring — Module-Wide Pattern

From `styles/tokens/_focus.less` — do NOT reimplement. Import via `@import` in `_participant-card.less` if needed, or rely on the module-wide focus ring already applied globally under `.scrying-pool`.

### Second-Signal Rule (Accessibility Mandatory)

Every state must signal: **color + icon + shape** (NFR-5, UX-DR13). The `sp-state-*` class provides all three when `_states.less` tokens are applied. Cards get state via `class="... sp-state-{{state}}"`.

### i18n Compliance

All user-visible strings must use `adapter.i18n.localize()` in JS. In Handlebars templates, use `{{localize "video-view-manager.directorsBoard.title"}}`. Do NOT hardcode English strings in templates.

Exception: aria-labels built in `buildCardContext()` may interpolate user names directly (names are data, not UI copy).

### Module.js Init Order Extension

After Story 2.2, `module.js` `Hooks.once('ready')` sequence becomes:

```js
// ... (existing, unchanged) ...
notificationBus = new NotificationBus(adapter);
notificationBus.init();
// Story 2.2: DirectorsBoard (lazy, GM only)
if (adapter.users.isGM()) {
  directorsBoard = new DirectorsBoard(stateStore, scryingPoolController, adapter);
  directorsBoard.init();
}
```

Also add `let directorsBoard;` at module scope (line ~39, after `let notificationBus;`).

Keybinding registration goes in `Hooks.once('init')`, before adapter construction:

```js
// At the end of Hooks.once('init'), after adapter construction and settings registration:
if (game.user?.isGM) {
  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,
    onDown: () => directorsBoard?.toggle(),
  });
}
```

### Test Patterns

```js
// tests/unit/ui/shared/ParticipantCard.test.js
import { describe, it, expect, vi } from 'vitest';
import { buildCardContext, buildBoardContext, resolveToggleTarget } from '../../../../src/ui/shared/ParticipantCard.js';
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';

describe('buildCardContext', () => {
  it('returns correct structure for visible participant', () => {
    const stateStore = { getState: vi.fn(() => 'active') };
    const controller = { hasPendingOp: vi.fn(() => false) };
    const adapter = createFoundryAdapterMock({
      users: { get: () => ({ name: 'Alice', avatar: 'img/alice.jpg' }), all: () => [] },
    });
    const ctx = buildCardContext('u1', stateStore, controller, adapter);
    expect(ctx.state).toBe('active');
    expect(ctx.isHidden).toBe(false);
    expect(ctx.cardAriaLabel).toBe('Alice — Active');
    expect(ctx.toggleAriaLabel).toBe('Hide Alice from table');
  });

  it('returns isHidden=true and correct toggleAriaLabel for hidden state', () => {
    const stateStore = { getState: vi.fn(() => 'hidden') };
    const controller = { hasPendingOp: vi.fn(() => false) };
    const adapter = createFoundryAdapterMock({
      users: { get: () => ({ name: 'Bob', avatar: null }), all: () => [] },
    });
    const ctx = buildCardContext('u2', stateStore, controller, adapter);
    expect(ctx.isHidden).toBe(true);
    expect(ctx.toggleAriaLabel).toBe('Show Bob to table');
  });
});

describe('resolveToggleTarget', () => {
  it('returns hidden when state is active', () => expect(resolveToggleTarget('active')).toBe('hidden'));
  it('returns hidden when state is self-muted', () => expect(resolveToggleTarget('self-muted')).toBe('hidden'));
  it('returns active when state is hidden', () => expect(resolveToggleTarget('hidden')).toBe('active'));
});
```

```js
// tests/unit/ui/gm/DirectorsBoard.test.js
import { describe, it, expect, vi } from 'vitest';
import { DirectorsBoard } from '../../../../src/ui/gm/DirectorsBoard.js';
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';

// DirectorsBoard uses a fallback base class in tests (no foundry globals)
describe('DirectorsBoard constructor', () => {
  it('is side-effect-free: does not register hooks in constructor', () => {
    const onSpy = vi.fn();
    const adapter = createFoundryAdapterMock({ hooks: { on: onSpy, off: vi.fn() } });
    const stateStore = { getState: vi.fn(() => 'active') };
    const controller = { action: vi.fn(), hasPendingOp: vi.fn(() => false) };
    new DirectorsBoard(stateStore, controller, adapter);
    expect(onSpy).not.toHaveBeenCalled();
  });
});

describe('DirectorsBoard.init()', () => {
  it('registers scrying-pool:stateChanged hook', () => {
    const hookOn = vi.fn(() => 42);
    // Simulate Hooks global
    global.Hooks = { on: hookOn, off: vi.fn() };
    const adapter = createFoundryAdapterMock();
    const board = new DirectorsBoard({ getState: vi.fn() }, { action: vi.fn(), hasPendingOp: vi.fn() }, adapter);
    board.init();
    expect(hookOn).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
    delete global.Hooks;
  });
});
```

⚠️ **Test environment note:** `Hooks` is a FoundryVTT global. Tests that call `init()` or `teardown()` need to set/delete `global.Hooks` as a stub. Use `beforeEach`/`afterEach` to set up and clean up. The `createFoundryAdapterMock()` mock provides an `adapter.hooks` surface, but `DirectorsBoard` uses `Hooks.*` globals directly (same pattern as `NotificationBus`).

### Project Structure Notes

**New files:**
```
src/ui/gm/DirectorsBoard.js              ← NEW
src/ui/shared/ParticipantCard.js         ← NEW
tests/unit/ui/gm/DirectorsBoard.test.js  ← NEW
tests/unit/ui/shared/ParticipantCard.test.js ← NEW
```

**Modified files:**
```
module.js                                ← import DirectorsBoard + keybinding + wiring
templates/directors-board.hbs           ← replace stub with full layout
templates/participant-card.hbs          ← replace stub with card markup
styles/components/_directors-board.less ← replace stub with CSS grid styles
styles/components/_participant-card.less ← replace stub with card styles
lang/en.json                            ← add directorsBoard i18n keys
```

**Do NOT modify:**
- `src/core/StateStore.js`, `src/core/ScryingPoolController.js`, `src/core/VisibilityManager.js` — no changes needed
- `src/foundry/FoundryAdapter.js` — all needed surfaces already implemented
- `src/ui/gm/ScryingPoolStrip.js` — no changes needed (Director's Board is a separate window)
- `src/notifications/NotificationBus.js` — complete, no changes

### Deferred Debt Status

All high-priority deferred items from Epic 1 retro were folded into Story 2.1 (Tasks 4.1–4.4):
- `_revisions` Map leak ✅ fixed in 2.1
- ScryingPoolController listener cleanup ✅ fixed in 2.1
- VisibilityManager listener cleanup ✅ fixed in 2.1
- Echo revision NaN/Infinity validation ✅ fixed in 2.1

Remaining deferred items in `deferred-work.md` (post-2.1 code review) are pre-existing architectural limitations NOT targeted for Story 2.2:
- VisibilityManager binary state handling (T-09 edge case)
- No `setMatrix` hook handling in NotificationBus
- ScryingPoolController cleanup limited to `userConnected` hook

### References

- Epics — Story 2.2 ACs: [Source: _bmad-output/planning-artifacts/epics.md#Story 2.2]
- Architecture — DirectorsBoard dependency graph: [Source: _bmad-output/planning-artifacts/architecture.md#Module Dependency Graph]
- Architecture — ApplicationV2 pattern: [Source: _bmad-output/planning-artifacts/architecture.md#Decision Impact Analysis]
- Architecture — import boundary rule: [Source: _bmad-output/planning-artifacts/architecture.md#Import Boundary Rule]
- Architecture — directory structure: [Source: _bmad-output/planning-artifacts/architecture.md#src/ui/gm/]
- Architecture — enforcement summary: [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Summary]
- Architecture — data flow GM toggle: [Source: _bmad-output/planning-artifacts/architecture.md#Data Flow — GM Visibility Toggle]
- UX spec — ApplicationV2 + HandlebarsApplicationMixin: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Transferable UX Patterns]
- UX spec — event delegation on app root: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Anti-Patterns to Avoid]
- UX spec — UX-DR13 ParticipantCard dimensions: [Source: _bmad-output/planning-artifacts/epics.md#UX-DR13]
- UX spec — UX-DR14 DirectorBoard grid: [Source: _bmad-output/planning-artifacts/epics.md#UX-DR14]
- UX spec — canonical action labels: [Source: _bmad-output/planning-artifacts/epics.md#UX-DR21]
- UX spec — position persisted to GM User flag: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Transferable UX Patterns]
- UX spec — keybinding `game.keybindings.register()`: [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Component Ecosystem]
- Story 2.1 — module.js init order, hook patterns: [Source: _bmad-output/implementation-artifacts/2-1-notificationbus-and-notification-verbosity.md#Module.js Init Order Extension]
- Story 2.1 — NotificationBus constructor pattern (side-effect-free): [Source: _bmad-output/implementation-artifacts/2-1-notificationbus-and-notification-verbosity.md#Constructor Pattern]
- Story 1.5 — ScryingPoolStrip Application conditional pattern, LABELS export, _dispatchAction: [Source: src/ui/gm/ScryingPoolStrip.js]
- Epic 1 retro — ApplicationV2 in FoundryVTT v14: [Source: _bmad-output/implementation-artifacts/epic-1-retro-2026-05-22.md]
- module.js — current wiring and placeholder comment: [Source: module.js:141]

## Dev Agent Record

### Agent Model Used

Claude Sonnet 4.6 (claude-sonnet-4.6)

### Debug Log References

No blockers encountered. Sidebar button implementation uses the `getSceneControlButtons` hook, appending to the token group's tools array. Since the exact Foundry v14 hook API was uncertain, the implementation uses optional chaining (`controls.find?.()`) and `tokenGroup?.tools`) to be safe — gracefully no-ops if the hook API differs. The keyboard shortcut and `ready` hook wiring are the primary open path.

### Completion Notes List

- ✅ **Task 1** (ParticipantCard.js): 26 tests. Pure utility exports `buildCardContext()`, `buildBoardContext()`, `resolveToggleTarget()`. No side effects. All 8 state labels canonical.
- ✅ **Task 2** (DirectorsBoard.js): 22 tests. ApplicationV2 conditional base-class pattern (same as ScryingPoolStrip for Application). Constructor side-effect-free. `init()`/`teardown()` manage hook lifecycle. `_dispatchToggle()` goes through `controller.action()` (FR-1 compliant, pending-op guarded). Event delegation on app root in `_onRender()`. Keyboard nav (arrow keys + Space/Enter). Position persistence via GM user flag.
- ✅ **Task 3** (directors-board.hbs): Full layout with `[role="list"]` section, Handlebars `{{> partial}}` for cards, empty state, disabled footer preset buttons.
- ✅ **Task 4** (participant-card.hbs): `[role="listitem"]`, `aria-label="{{cardAriaLabel}}"`, `data-user-id`, `tabindex="0"`, avatar, name (2-line truncate), toggle overlay button with independent `tabindex="-1"`.
- ✅ **Task 5** (Less styles): `_participant-card.less` — 80×100px, 48px avatar, all 9 sp-state-* variants with correct ring shapes per second-signal rule, hover/focus overlay, reduced motion guard. `_directors-board.less` — `auto-fill minmax(80px, 1fr)` grid, empty state, footer.
- ✅ **Task 6** (module.js wiring): Import, `let directorsBoard`, keybinding `Ctrl+Shift+V` (`restricted: true`), `getSceneControlButtons` hook (safe optional chaining), ready hook construction + `directorsBoard.init()`.
- ✅ **Task 7** (i18n): 5 keys added under `video-view-manager.directorsBoard.*`.
- ✅ **Task 8** (pipeline): `npm run lint` exits 0 (no new errors). `npm run test` exits 0 — **383 tests passing** (335 baseline + 26 ParticipantCard + 22 DirectorsBoard).

### File List

- `src/ui/shared/ParticipantCard.js` ← NEW
- `src/ui/gm/DirectorsBoard.js` ← NEW
- `tests/unit/ui/shared/ParticipantCard.test.js` ← NEW
- `tests/unit/ui/gm/DirectorsBoard.test.js` ← NEW
- `module.js` ← MODIFIED (import, let directorsBoard, keybinding, sidebar hook, ready wiring)
- `templates/directors-board.hbs` ← MODIFIED (stub → full layout)
- `templates/participant-card.hbs` ← MODIFIED (stub → card markup)
- `styles/components/_directors-board.less` ← MODIFIED (stub → CSS grid)
- `styles/components/_participant-card.less` ← MODIFIED (stub → card styles)
- `lang/en.json` ← MODIFIED (5 directorsBoard i18n keys)
- `_bmad-output/implementation-artifacts/sprint-status.yaml` ← MODIFIED (status → review)

### Review Findings

#### Decision Needed
- [x] [Review][Dismiss] Missing StateRing Component — **DISMISSED**: Border styling via `sp-state-*` CSS classes on the card provides the StateRing functionality (color + shape signals per second-signal rule). The spec's mention of "StateRing" is satisfied by the border styling implementation.

#### Patch
- [x] [Review][Patch] Implement Sidebar Button Fallback in ScryingPoolStrip — Added CTA button in `roster-strip.hbs` with event listener in `ScryingPoolStrip.js` activateListeners. Provides fallback when `getSceneControlButtons` API is unavailable. [module.js:86-96, roster-strip.hbs, ScryingPoolStrip.js]
- [x] [Review][Patch] Avatar Image Missing Alt Text — Changed `alt="" aria-hidden="true"` to `alt="Avatar of {{name}}"` in participant-card.hbs. Screen readers now announce avatar identity. [participant-card.hbs:6]
- [x] [Review][Patch] No Error Handling in buildBoardContext — Wrapped in try/catch returning `{ participants: [], isEmpty: true }` on error. Graceful degradation prevents board render crash. [ParticipantCard.js:52-60]
- [x] [Review][Patch] Redundant aria-disabled on Native Buttons — Removed `aria-disabled="true"` from disabled buttons in directors-board.hbs. Native `disabled` attribute is sufficient for AT. [directors-board.hbs:19-22]

#### Deferred
- [x] [Review][Defer] No Error Handling in _savePosition [DirectorsBoard.js:160-167] — `game.user?.setFlag(...)` called without try/catch. Pre-existing pattern in codebase (same as ScryingPoolStrip). Not introduced by Story 2.2.
- [x] [Review][Defer] CSS Includes sp-state-pending Class [_participant-card.less:18] — Defines `sp-state-pending` class but Story 2.2 only specifies 8 states. Relates to StateStore/VisibilityManager from Epic 1, not introduced by this story.

### Change Log

- 2026-05-23: Story 2.2 implemented — Director's Board core layout and participant toggle (48 new tests, all ACs satisfied)
