526 lines
27 KiB
Markdown
526 lines
27 KiB
Markdown
# Story 1.6: Player Camera Status Badge
|
||
|
||
Status: done
|
||
|
||
## Story
|
||
|
||
As a **player**,
|
||
I want to always see whether my own camera feed is visible to the table, and understand what it means on first encounter,
|
||
So that I'm never confused or surveilled without knowing it.
|
||
|
||
## Acceptance Criteria
|
||
|
||
1. **Given** a player is connected with AV enabled
|
||
**When** the module is active
|
||
**Then** a persistent `VisibilityBadge` appears on their own AV tile
|
||
**And** the badge is visible only to the owning player (not to other players or the GM)
|
||
**And** `role="status"`, `aria-live="polite"`, `aria-label="Camera visibility: [state label]"` are set
|
||
**And** badge tokens are declared on `:root` (badge mounted outside `.scrying-pool` root, using `AVTileAdapter` from Story 1.5)
|
||
|
||
2. **Given** a player's state is anything other than `active`
|
||
**When** the badge renders
|
||
**Then** it shows the correct vocabulary-partition label:
|
||
- `hidden` → "Hidden from table"
|
||
- `self-muted` → "Camera paused"
|
||
- `offline` → "Not connected"
|
||
- `cam-lost` → "Camera unavailable"
|
||
- `reconnecting` → "Rejoining view"
|
||
- `never-connected` → "Not yet connected"
|
||
- `ghost` → "Leaving"
|
||
- `active` → no label shown (null)
|
||
|
||
3. **Given** the GM changes a player's visibility state
|
||
**When** the socket broadcast completes
|
||
**Then** the player's `VisibilityBadge` updates within 500ms
|
||
|
||
4. **Given** `firstBadgeEncounter` user flag is not set and a state change occurs
|
||
**When** the badge updates
|
||
**Then** `FirstEncounterPanel` appears with a plain-language explanation
|
||
**And** a 10s auto-collapse timer starts
|
||
**And** `mouseenter` or `:focus-within` on the panel pauses the timer (resumes on leave/blur)
|
||
**And** "Got it" sets `firstBadgeEncounter` and immediately closes the panel
|
||
**And** the panel is `aria-modal="false"`, `role="dialog"`, and is NOT a focus trap
|
||
|
||
5. **Given** the 10s timer expires without interaction
|
||
**When** auto-collapse fires
|
||
**Then** the panel collapses via `max-height` fold animation (300ms ease-out) into a persistent chip
|
||
**And** the chip is focusable and keyboard-activatable, re-opening `VisibilityDetailsPanel` on activation
|
||
**And** if focus is inside the panel when collapse fires, focus is moved to the chip
|
||
**And** subsequent state changes do NOT re-show the panel (flag is permanently set)
|
||
**And** `clearTimeout` is called on "Got it" click and on `_onClose()` teardown to prevent ghost timers
|
||
|
||
6. **Given** a player clicks their `VisibilityBadge` or the collapsed chip
|
||
**When** `VisibilityDetailsPanel` opens
|
||
**Then** it shows: who changed the state ("Hidden by: [GM name]" / "Connection issue" / "Scene preset: [name]"), what the state means in plain language, and a reassurance note
|
||
**And** when state is `hidden`, the audience list is suppressed and replaced with reassurance copy: "Other players cannot see your feed"
|
||
**And** a stale-data indicator appears when `ScryingPoolController` is unavailable
|
||
**And** the panel is a focus-trapped `<dialog>` with `aria-modal="true"`
|
||
**And** Esc, click-outside, or "Close" button dismisses it and returns focus to the triggering element
|
||
|
||
7. **Given** `AVTileAdapter.mount(userId, badgeElement)` is called and the AV tile DOM node is not found
|
||
**When** the call executes
|
||
**Then** the adapter no-ops and logs `console.warn` without throwing (fail-open)
|
||
|
||
8. **Given** Foundry re-renders the AV tile (detected via `MutationObserver`)
|
||
**When** the re-render is detected
|
||
**Then** the badge is updated in-place if possible; remove-and-reinsert only if structure requires full rebuild
|
||
**And** `AVTileAdapter.disconnect()` is called on module teardown
|
||
|
||
## Tasks / Subtasks
|
||
|
||
- [x] Task 1: Create `src/ui/player/VisibilityBadge.js` (AC: 1, 2, 3, 4, 5, 6, 7, 8)
|
||
- [x] 1.1: Write failing tests in `tests/unit/ui/player/VisibilityBadge.test.js` first (TDD red)
|
||
- [x] 1.2: Implement `VisibilityBadge` class — constructor receives `(stateStore, controller, avTileAdapter, adapter)`; side-effect-free; store all deps
|
||
- [x] 1.3: Implement `init()` — resolve `currentUserId` from `adapter.users.current()?.id`; subscribe to `scrying-pool:stateChanged` hook; mount initial badge at current state; register `avTileAdapter.onTileRerender()` callback for re-mount resilience; no-op if no `currentUserId`
|
||
- [x] 1.4: Implement `_createBadgeElement(state)` — creates `<div class="sp-visibility-badge" data-sp-role="visibility-badge">` with correct ARIA attributes; applies label from `PLAYER_STATE_LABELS[state]`
|
||
- [x] 1.5: Implement `_onStateChanged(data)` — guard: only process `data.userId === this._currentUserId`; update badge element label + aria-label; call `avTileAdapter.mount(userId, badgeEl)` (idempotent); trigger `FirstEncounterPanel` if `_getFirstBadgeEncountered()` returns falsy
|
||
- [x] 1.6: Implement `_getFirstBadgeEncountered()` — `return adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false`
|
||
- [x] 1.7: Implement `_setFirstBadgeEncountered()` — `await adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true)`
|
||
- [x] 1.8: Implement badge click handler → instantiate and open `VisibilityDetailsPanel` with current state + actor info
|
||
- [x] 1.9: Implement `teardown()` — call `avTileAdapter.disconnect()` and clean up hook listeners
|
||
- [x] 1.10: Green all VisibilityBadge tests
|
||
|
||
- [x] Task 2: Implement `FirstEncounterPanel` class (inside `VisibilityBadge.js`) (AC: 4, 5)
|
||
- [x] 2.1: Write failing tests for `FirstEncounterPanel` (TDD red) — timer-based tests use `vi.useFakeTimers()`
|
||
- [x] 2.2: Implement `show(anchorEl)` — create and append panel element; `role="dialog"`, `aria-modal="false"`; start 10s `#collapseTimer`
|
||
- [x] 2.3: Implement timer pause: `mouseenter` / `mouseleave` on panel element; `focusin` / `focusout` events (not `:focus-within` directly — use event listeners)
|
||
- [x] 2.4: Implement "Got it" button handler — `clearTimeout(this.#collapseTimer)`, `this.#collapseTimer = null`, call `setFirstBadgeEncountered()`, then `_dismiss()` (removes panel from DOM)
|
||
- [x] 2.5: Implement `_collapse()` — set `max-height` animation via CSS class; after `transitionend` (or timeout fallback), replace panel with chip element; if active focus is inside panel, move focus to chip
|
||
- [x] 2.6: Implement chip element — `role="button"`, `tabindex="0"`; `click` and `keydown Enter/Space` → open `VisibilityDetailsPanel`
|
||
- [x] 2.7: Implement `_onClose()` — `clearTimeout(this.#collapseTimer)`, `this.#collapseTimer = null` (must be called in teardown to prevent ghost timers)
|
||
- [x] 2.8: Green all FirstEncounterPanel tests
|
||
|
||
- [x] Task 3: Implement `VisibilityDetailsPanel` class (inside `VisibilityBadge.js`) (AC: 6)
|
||
- [x] 3.1: Write failing tests for `VisibilityDetailsPanel` (TDD red)
|
||
- [x] 3.2: Implement using native `<dialog>` element + `showModal()` — built-in focus trap + backdrop in modern browsers; `aria-modal="true"` attribute
|
||
- [x] 3.3: Implement `show(state, actor, triggerEl)` — create `<dialog>`, populate content, `document.body.appendChild(dialog)`, `dialog.showModal()`, store `triggerEl` for focus return
|
||
- [x] 3.4: Implement close handlers:
|
||
- Esc: native `<dialog>` handles; listen to `close` event → `_onClose()`
|
||
- Backdrop click: `dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); })`
|
||
- "Close" button: `dialog.close()`
|
||
- [x] 3.5: Implement `_onClose()` — `dialog.remove()`, `this._triggerEl?.focus()` (return focus)
|
||
- [x] 3.6: Populate content per state — actor line, state explanation, audience (suppress + reassurance when `state === 'hidden'`), reassurance ("Your audio is active for all participants.")
|
||
- [x] 3.7: Handle stale data — show "Data may be outdated" note if controller is not available; for v1 this can check `controller != null`
|
||
- [x] 3.8: Green all VisibilityDetailsPanel tests
|
||
|
||
- [x] Task 4: Implement `styles/components/_player-badge.less` (AC: 1, 2, 4, 5, 6)
|
||
- [x] 4.1: Replace stub content with full badge + panel CSS; remove incorrect scoping comment (see §CSS Exception note below)
|
||
- [x] 4.2: `.sp-visibility-badge` — `position: absolute; top: 0; left: 50%; transform: translateX(-50%);` for top-center tile injection; anatomy: mini StateRing (16px) + state icon + label text
|
||
- [x] 4.3: State label typography: `font-size: 0.6875rem` (11px), `letter-spacing: 0.02em`
|
||
- [x] 4.4: `FirstEncounterPanel` styles — `max-height` transition `300ms ease-out` for collapse; panel positioning relative to badge/tile
|
||
- [x] 4.5: Chip styles — small, focusable, matches badge visual language
|
||
- [x] 4.6: `VisibilityDetailsPanel` (`<dialog>`) styles — content layout; "Close" button; backdrop
|
||
- [x] 4.7: Gate ALL animations under `@media (prefers-reduced-motion: no-preference)`; add `.sp-visibility-badge { transition: none; animation: none; }` at top level before the media query
|
||
|
||
- [x] Task 5: Update `module.js` (AC: 1)
|
||
- [x] 5.1: Add `import { VisibilityBadge } from './src/ui/player/VisibilityBadge.js';` at top of file
|
||
- [x] 5.2: Add `let visibilityBadge;` with other module-level variables
|
||
- [x] 5.3: In `Hooks.once('ready')` after `roleRenderer.init()` + `openStrip()` block:
|
||
```js
|
||
if (!adapter.users.isGM()) {
|
||
visibilityBadge = new VisibilityBadge(stateStore, scryingPoolController, avTileAdapter, adapter);
|
||
visibilityBadge.init();
|
||
}
|
||
```
|
||
- [x] 5.4: Update the init comment block at top of `module.js` to mention Story 1.6
|
||
|
||
- [x] Task 6: Update `lang/en.json` with i18n keys
|
||
- [x] 6.1: Add badge state labels (hidden, self-muted, offline, cam-lost, reconnecting, never-connected, ghost)
|
||
- [x] 6.2: Add `FirstEncounterPanel` copy (title: "Your camera visibility changed.", body: "Audio continues normally.", "Got it" button label)
|
||
- [x] 6.3: Add `VisibilityDetailsPanel` copy ("Close", audience suppression text, stale data indicator text, reassurance text)
|
||
|
||
- [x] Task 7: Update `src/types/foundry-globals.d.ts`
|
||
- [x] 7.1: Verify if `game.user.setFlag / getFlag` is declared; if not, add to existing `game` declaration (do NOT duplicate the `game` declaration — extend the `user` sub-object)
|
||
|
||
- [x] Task 8: Verify full pipeline
|
||
- [x] 8.1: `npm run test` — all tests pass (expect ~20–30 new tests)
|
||
- [x] 8.2: `npm run lint` — 0 new lint errors
|
||
- [x] 8.3: `npm run typecheck` — 0 new typecheck errors
|
||
- [x] 8.4: `npm run build` — clean build
|
||
|
||
## Dev Notes
|
||
|
||
### Architecture Context
|
||
|
||
Story 1.6 is the final story of Epic 1. It builds the player-facing visibility badge, completing the core visibility loop: GM hides/shows feeds (Story 1.5), and each player always sees their own camera state.
|
||
|
||
**Component naming clarification (architecture doc vs story/UX spec):**
|
||
The architecture doc places this in `src/ui/shared/PlayerStatusBadge.js`. The UX spec and story consistently use `VisibilityBadge`. Per the Story 1.5 precedent ("story spec takes precedence over architecture file-level names"), use:
|
||
- Class: `VisibilityBadge` (+ `FirstEncounterPanel`, `VisibilityDetailsPanel`)
|
||
- File: `src/ui/player/VisibilityBadge.js` (player-subtree — badge is never shown to GM)
|
||
|
||
**Player-only enforcement:** The badge is instantiated in `module.js` only when `!adapter.users.isGM()`. Inside `init()`, it is mounted only on `adapter.users.current()?.id` — the player's OWN tile.
|
||
|
||
### Init Order (EXACT — do not deviate)
|
||
|
||
```
|
||
Hooks.once('ready')
|
||
→ stateStore.init() // Story 1.3
|
||
→ FoundryAdapter.probeCapability() + webrtcMode // Story 1.3
|
||
→ visibilityManager = new VisibilityManager(...) // Story 1.4
|
||
→ visibilityManager.init() // Story 1.4
|
||
→ socketHandler.setReady(...) // Story 1.4
|
||
→ scryingPoolController = new ScryingPoolController(...) // Story 1.4
|
||
→ scryingPoolController.init() // Story 1.4
|
||
→ avTileAdapter = new AVTileAdapter(adapter) // Story 1.5
|
||
→ roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter) // Story 1.5
|
||
→ roleRenderer.init() // Story 1.5
|
||
→ if isGM: roleRenderer.openStrip() // Story 1.5
|
||
→ if !isGM: visibilityBadge = new VisibilityBadge(stateStore, scryingPoolController, avTileAdapter, adapter) // Story 1.6 (NEW)
|
||
visibilityBadge.init() // Story 1.6 (NEW)
|
||
// Story 2.1: NotificationBus
|
||
// Story 2.2: DirectorsBoard (lazy, GM only)
|
||
```
|
||
|
||
`avTileAdapter` is **shared** between `RoleRenderer` (GM strip CSS) and `VisibilityBadge` (player badge injection) — one instance, injected into both.
|
||
|
||
### Player State Vocabulary (CANONICAL — use exactly these strings)
|
||
|
||
Source: epics.md Story 1.6 AC. This takes precedence over the UX spec §3.1 table.
|
||
|
||
```js
|
||
const PLAYER_STATE_LABELS = Object.freeze({
|
||
hidden: 'Hidden from table',
|
||
'self-muted': 'Camera paused',
|
||
offline: 'Not connected',
|
||
'cam-lost': 'Camera unavailable',
|
||
reconnecting: 'Rejoining view',
|
||
'never-connected': 'Not yet connected',
|
||
ghost: 'Leaving',
|
||
active: null, // no label displayed for active state
|
||
});
|
||
```
|
||
|
||
❌ Do NOT use UX spec §3.1 alternatives: "Not visible to others", "Disconnected", "Rejoining" — wrong.
|
||
|
||
### `firstBadgeEncounter` Storage — Architecture Decision
|
||
|
||
**Use `game.user.setFlag` (Foundry user flag), NOT localStorage.**
|
||
|
||
Access via `adapter.users.current()` (the Foundry User document returned by `FoundryAdapter.users.current()`):
|
||
```js
|
||
// Read flag:
|
||
const encountered = adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false;
|
||
|
||
// Write flag:
|
||
await adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true);
|
||
```
|
||
|
||
⚠️ UX spec §6.9 mentions `localStorage` — **ignore this.** Architecture decision (line 250) + story AC both mandate the user flag. The `localStorage` option was explicitly marked as a "v2 migration path."
|
||
|
||
**Type declarations:** `foundry-globals.d.ts` does not currently declare `setFlag/getFlag` on `game.user`. Add them to the existing `game` declaration when encountered.
|
||
|
||
### `AVTileAdapter` Integration (REUSE as-is from Story 1.5)
|
||
|
||
`AVTileAdapter` is fully implemented (24 tests). Do NOT modify it.
|
||
|
||
Badge element shape for idempotent mounting:
|
||
```js
|
||
const badgeEl = document.createElement('div');
|
||
badgeEl.className = 'sp-visibility-badge';
|
||
badgeEl.dataset.spRole = 'visibility-badge'; // ← key for AVTileAdapter idempotency
|
||
badgeEl.setAttribute('role', 'status');
|
||
badgeEl.setAttribute('aria-live', 'polite');
|
||
badgeEl.setAttribute('aria-label', `Camera visibility: ${stateLabel ?? 'Active'}`);
|
||
```
|
||
|
||
Re-render resilience (Foundry AV tile DOM changes post-render):
|
||
```js
|
||
avTileAdapter.onTileRerender(currentUserId, () => {
|
||
this._mountBadge(this._currentState); // re-mount after tile re-render
|
||
});
|
||
```
|
||
|
||
### Internal Component Structure (one file, two inner classes)
|
||
|
||
Mirror the ActionPopover pattern from Story 1.5 (ActionPopover is an internal class inside ScryingPoolStrip.js):
|
||
|
||
```
|
||
src/ui/player/VisibilityBadge.js
|
||
export class VisibilityBadge ← wired into module.js; manages badge DOM + subscriptions
|
||
class FirstEncounterPanel ← internal; created/owned by VisibilityBadge
|
||
class VisibilityDetailsPanel ← internal; created/owned by VisibilityBadge
|
||
```
|
||
|
||
**`VisibilityBadge` responsibilities:**
|
||
- Create + update badge DOM element; mount via `avTileAdapter`
|
||
- Subscribe to `scrying-pool:stateChanged` (current user only)
|
||
- Instantiate `FirstEncounterPanel` on first encounter
|
||
- Instantiate `VisibilityDetailsPanel` on badge/chip click
|
||
|
||
**`FirstEncounterPanel` responsibilities:**
|
||
- Non-modal explanatory panel; 10s collapse timer
|
||
- Pause timer on `mouseenter`/`focusin`; resume on `mouseleave`/`focusout`
|
||
- "Got it" → set flag + dismiss; `clearTimeout` always
|
||
- `max-height` fold animation → chip after collapse
|
||
- `_onClose()` MUST `clearTimeout` (ghost timer prevention)
|
||
|
||
**`VisibilityDetailsPanel` responsibilities:**
|
||
- Native `<dialog>` + `showModal()` — built-in focus trap
|
||
- 3-question content: actor, state meaning, audience
|
||
- Dismiss: Esc (native) / backdrop click / "Close" button
|
||
- Return focus to trigger element on close
|
||
|
||
### CSS — `.sp-visibility-badge` Is the Documented `:root` Exception
|
||
|
||
⚠️ **`styles/components/_player-badge.less` has an incorrect stub comment: "All selectors MUST be scoped under .scrying-pool."** This is wrong for badge styles — it's the documented exception.
|
||
|
||
From Story 1.1 AC (line 258 epics.md): *"the VisibilityBadge :root exception is documented: badge tokens are declared on :root because the badge is mounted outside the .scrying-pool root"*
|
||
|
||
**Correct approach:**
|
||
```less
|
||
// VisibilityBadge — this file is the DOCUMENTED EXCEPTION to .scrying-pool scoping.
|
||
// The badge is injected into the AV tile DOM via AVTileAdapter — outside any .scrying-pool root.
|
||
// Selectors here are top-level (not nested under .scrying-pool).
|
||
// Badge-specific tokens are declared on :root so they are reachable from tile-adjacent DOM.
|
||
// Source: Architecture §Token System + Story 1.1 AC (VisibilityBadge :root exception).
|
||
|
||
.sp-visibility-badge { transition: none; animation: none; }
|
||
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
.sp-visibility-badge {
|
||
// badge-specific motion if any
|
||
}
|
||
}
|
||
|
||
:root {
|
||
--sp-badge-bg: hsl(220, 15%, 10%);
|
||
--sp-badge-text: hsl(0, 0%, 85%);
|
||
}
|
||
|
||
.sp-visibility-badge {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
// ...
|
||
}
|
||
```
|
||
|
||
Note: All existing `--sp-state-*` tokens are ALREADY on `:root` via `_roster-strip.less` and `_base.less` — no need to re-declare them.
|
||
|
||
### `FirstEncounterPanel` — Timer Ghost Prevention
|
||
|
||
```js
|
||
class FirstEncounterPanel {
|
||
#collapseTimer = null;
|
||
|
||
show(anchorEl) {
|
||
// ... create panel DOM, attach ...
|
||
this.#collapseTimer = setTimeout(() => this._collapse(), 10_000);
|
||
panel.addEventListener('mouseenter', () => this._pauseTimer());
|
||
panel.addEventListener('mouseleave', () => this._resumeTimer());
|
||
panel.addEventListener('focusin', () => this._pauseTimer());
|
||
panel.addEventListener('focusout', () => this._resumeTimer());
|
||
}
|
||
|
||
_pauseTimer() { clearTimeout(this.#collapseTimer); this.#collapseTimer = null; }
|
||
_resumeTimer() { this.#collapseTimer = setTimeout(() => this._collapse(), 10_000); }
|
||
|
||
_onGotIt() {
|
||
clearTimeout(this.#collapseTimer); // ← REQUIRED: ghost prevention
|
||
this.#collapseTimer = null;
|
||
this._setFirstBadgeEncountered();
|
||
this._dismiss();
|
||
}
|
||
|
||
_onClose() {
|
||
clearTimeout(this.#collapseTimer); // ← REQUIRED: ghost prevention on teardown
|
||
this.#collapseTimer = null;
|
||
}
|
||
}
|
||
```
|
||
|
||
⚠️ Missing either `clearTimeout` creates ghost timers — they fire after the panel is gone and can cause null-pointer errors or re-render glitches.
|
||
|
||
### `VisibilityDetailsPanel` — Native `<dialog>` Focus Trap
|
||
|
||
`<dialog>` with `showModal()` provides native focus trapping in happy-dom and modern browsers:
|
||
```js
|
||
const dialog = document.createElement('dialog');
|
||
dialog.setAttribute('aria-modal', 'true');
|
||
// ... populate content ...
|
||
document.body.appendChild(dialog);
|
||
dialog.showModal(); // native focus trap + Esc handling + backdrop
|
||
```
|
||
|
||
- **Esc key:** `<dialog>` handles natively → dispatches `cancel` + `close` events; listen to `close` for cleanup
|
||
- **Backdrop click:** `dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); })`
|
||
- **"Close" button:** calls `dialog.close()`
|
||
- **Focus return:** `dialog.addEventListener('close', () => { dialog.remove(); this._triggerEl?.focus(); })`
|
||
|
||
Do NOT build a manual focus trap.
|
||
|
||
### Hooks Used by This Story
|
||
|
||
```
|
||
Hooks.on('scrying-pool:stateChanged', data => { /* update badge for currentUserId only */ })
|
||
```
|
||
|
||
No new Foundry hooks introduced. `scrying-pool:stateChanged` was established in Story 1.3 and is emitted by `StateStore` on every mutation (via `Hooks.callAll`).
|
||
|
||
### Import Boundaries (HARD — ESLint enforced)
|
||
|
||
```
|
||
src/ui/player/VisibilityBadge.js → may import: src/core/, src/contracts/, src/utils/
|
||
```
|
||
❌ Do NOT import `src/foundry/FoundryAdapter` — FoundryAdapter comes through constructor injection.
|
||
❌ `src/core/` must NOT import `src/ui/` — no circular dependencies.
|
||
|
||
### Test Patterns
|
||
|
||
**Setup (happy-dom provides document):**
|
||
```js
|
||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||
import { VisibilityBadge } from '../../../../src/ui/player/VisibilityBadge.js';
|
||
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';
|
||
|
||
beforeEach(() => {
|
||
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||
vi.stubGlobal('Hooks', { on: vi.fn(), once: vi.fn(), off: vi.fn(), callAll: vi.fn() });
|
||
});
|
||
afterEach(() => { vi.unstubAllGlobals(); });
|
||
```
|
||
|
||
**Adapter mock with user flag support:**
|
||
```js
|
||
function makeAdapter({ userId = 'user-player', isGM = false, firstBadgeEncountered = false } = {}) {
|
||
const mockUser = {
|
||
id: userId,
|
||
getFlag: vi.fn().mockReturnValue(firstBadgeEncountered),
|
||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||
};
|
||
return createFoundryAdapterMock({
|
||
users: {
|
||
current: () => mockUser,
|
||
isGM: () => isGM,
|
||
get: () => mockUser,
|
||
all: () => [mockUser],
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
**AVTileAdapter mock:**
|
||
```js
|
||
function makeAVTileAdapter() {
|
||
return {
|
||
mount: vi.fn(),
|
||
unmount: vi.fn(),
|
||
setStateClass: vi.fn(),
|
||
onTileRerender: vi.fn(),
|
||
disconnect: vi.fn(),
|
||
};
|
||
}
|
||
```
|
||
|
||
**Fake timers for FirstEncounterPanel:**
|
||
```js
|
||
it('collapses after 10s idle', () => {
|
||
vi.useFakeTimers();
|
||
// ... show panel ...
|
||
vi.advanceTimersByTime(10_001);
|
||
// ... assert chip exists, panel gone ...
|
||
vi.useRealTimers();
|
||
});
|
||
```
|
||
|
||
**Testing VisibilityDetailsPanel:**
|
||
- `happy-dom` supports `<dialog>.showModal()` — test it is called
|
||
- Test backdrop click (`event.target === dialog`) triggers close
|
||
- Test "Close" button calls `dialog.close()`
|
||
- Test `triggerEl.focus()` called after close
|
||
|
||
### ESLint / TypeScript Notes (Learnings from Stories 1.3–1.5)
|
||
|
||
- JSDoc `/** ... */` class comment required on EVERY exported class (`jsdoc/require-jsdoc`)
|
||
- Use `// eslint-disable-next-line no-unused-vars` on line ABOVE a `catch (_)` binding
|
||
- `Hooks`/`game`/`ui` globals declared in `src/types/foundry-globals.d.ts` — add `setFlag`/`getFlag` to `game.user` if missing; NEVER add a second `declare const game` — extend the existing one's `user` sub-property
|
||
- Named exports only: `export class VisibilityBadge` — never `export default`
|
||
- Pre-existing lint errors in `scripts/package.mjs` (7 errors) — NOT in scope, do not touch
|
||
- `async/await` not `.then()`; guard clauses with early return; `null` not `undefined` from public APIs
|
||
|
||
### Project Structure Notes
|
||
|
||
**Files to create:**
|
||
```
|
||
src/ui/player/VisibilityBadge.js ← NEW; VisibilityBadge + FirstEncounterPanel + VisibilityDetailsPanel
|
||
tests/unit/ui/player/VisibilityBadge.test.js ← NEW
|
||
```
|
||
|
||
**Files to update:**
|
||
```
|
||
module.js ← add import + init badge for !isGM clients
|
||
styles/components/_player-badge.less ← replace stub with full badge/panel CSS
|
||
lang/en.json ← add badge i18n keys
|
||
src/types/foundry-globals.d.ts ← add setFlag/getFlag to game.user if absent
|
||
```
|
||
|
||
**Files NOT changed:**
|
||
- `src/ui/shared/AVTileAdapter.js` — reused as-is (fully implemented Story 1.5)
|
||
- `src/ui/RoleRenderer.js` — no changes
|
||
- `src/core/` files — no changes
|
||
- `src/contracts/` — no changes
|
||
- `tests/helpers/foundryAdapterMock.js` — no structural changes needed; the `current()` override in individual test `makeAdapter()` helpers is sufficient
|
||
|
||
**Import boundary check for new files:**
|
||
```
|
||
src/ui/player/VisibilityBadge.js → imports: src/core/ ✅, src/contracts/ ✅, src/utils/ ✅
|
||
```
|
||
|
||
### References
|
||
|
||
- Story 1.6 spec (ACs, vocabulary): `_bmad-output/planning-artifacts/epics.md` §Story 1.6 (lines 499–554)
|
||
- UX components §6.9–6.11 (VisibilityBadge, FirstEncounterPanel, VisibilityDetailsPanel): `_bmad-output/planning-artifacts/ux-design-specification.md` (lines 1268–1321)
|
||
- VisibilityBadge injection pattern: `_bmad-output/planning-artifacts/ux-design-specification.md` §VisibilityBadge Injection Pattern (lines 465–472)
|
||
- Player journey JY-3: `_bmad-output/planning-artifacts/ux-design-specification.md` §5.3 (lines 923–952)
|
||
- Overlay/modal patterns + focus trap rules: `_bmad-output/planning-artifacts/ux-design-specification.md` §7.3 (lines 1452–1465)
|
||
- 4-tier feedback pattern (no toast on success): `_bmad-output/planning-artifacts/ux-design-specification.md` §7.2 (lines 1415–1447)
|
||
- `firstBadgeEncounter` decision: `_bmad-output/planning-artifacts/architecture.md` (lines 228, 250)
|
||
- Architecture init order: `_bmad-output/planning-artifacts/architecture.md` §Initialisation Order (lines 303–319)
|
||
- Import boundaries: `_bmad-output/planning-artifacts/architecture.md` (lines 428–444)
|
||
- Error handling by layer: `_bmad-output/planning-artifacts/architecture.md` (lines 510–517)
|
||
- UX design requirements UX-DR9 (badge injection): `_bmad-output/planning-artifacts/epics.md` (line 120)
|
||
- Story 1.5 dev notes (ActionPopover pattern, test stubs, ESLint learnings): `_bmad-output/implementation-artifacts/1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration.md`
|
||
- AVTileAdapter implementation: `src/ui/shared/AVTileAdapter.js`
|
||
- AVTileAdapter test patterns: `tests/unit/ui/shared/AVTileAdapter.test.js`
|
||
- FoundryAdapter `users.current()` surface: `src/foundry/FoundryAdapter.js` (line 104)
|
||
- Canonical adapter mock: `tests/helpers/foundryAdapterMock.js`
|
||
- Deferred work (do not fix in this story): `_bmad-output/implementation-artifacts/deferred-work.md`
|
||
|
||
---
|
||
|
||
## Dev Agent Record
|
||
|
||
### Agent Model Used
|
||
|
||
Claude Sonnet 4.6
|
||
|
||
### Debug Log References
|
||
|
||
- Timer collapse tests required advancing fake timers in two steps: 10_001ms for the collapse timeout, then 301ms for the 300ms CSS transition replacement timer. Reason: `vi.advanceTimersByTime(N)` only fires timers scheduled before the advance boundary — nested timers scheduled during callback execution need a second advance call.
|
||
|
||
### Completion Notes List
|
||
|
||
- VisibilityBadge, FirstEncounterPanel, VisibilityDetailsPanel implemented in single file `src/ui/player/VisibilityBadge.js` following ActionPopover pattern from Story 1.5
|
||
- 48 new tests added covering all three classes (296 total, all passing)
|
||
- FirstEncounterPanel uses private class field `#collapseTimer` with explicit clearTimeout on "Got it" click and `_onClose()` teardown (ghost timer prevention)
|
||
- VisibilityDetailsPanel uses native `<dialog>` + `showModal()` — built-in focus trap, Esc, backdrop
|
||
- CSS animations gated under `@media (prefers-reduced-motion: no-preference)` with default `transition: none; animation: none` applied before media query
|
||
- `_player-badge.less` stub comment replaced with correct documented exception comment
|
||
- `foundry-globals.d.ts` extended with `game.user.getFlag/setFlag` (no duplicate declaration)
|
||
- Pre-existing 7 lint errors in `scripts/package.mjs` untouched per story Dev Notes
|
||
|
||
### File List
|
||
|
||
- `src/ui/player/VisibilityBadge.js` — NEW
|
||
- `tests/unit/ui/player/VisibilityBadge.test.js` — NEW
|
||
- `styles/components/_player-badge.less` — MODIFIED
|
||
- `module.js` — MODIFIED
|
||
- `lang/en.json` — MODIFIED
|
||
- `src/types/foundry-globals.d.ts` — MODIFIED
|
||
|
||
### Change Log
|
||
|
||
- 2026-05-22: Story 1.6 — Player Camera Status Badge. Created VisibilityBadge, FirstEncounterPanel, VisibilityDetailsPanel. Updated module.js, CSS, lang/en.json, foundry-globals.d.ts. 48 new tests.
|