# 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.
