Files
scrying-pool/_bmad-output/implementation-artifacts/1-6-player-camera-status-badge.md
T
2026-05-23 18:23:48 +02:00

27 KiB
Raw Blame History

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

  • Task 1: Create src/ui/player/VisibilityBadge.js (AC: 1, 2, 3, 4, 5, 6, 7, 8)

    • 1.1: Write failing tests in tests/unit/ui/player/VisibilityBadge.test.js first (TDD red)
    • 1.2: Implement VisibilityBadge class — constructor receives (stateStore, controller, avTileAdapter, adapter); side-effect-free; store all deps
    • 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
    • 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]
    • 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
    • 1.6: Implement _getFirstBadgeEncountered()return adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false
    • 1.7: Implement _setFirstBadgeEncountered()await adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true)
    • 1.8: Implement badge click handler → instantiate and open VisibilityDetailsPanel with current state + actor info
    • 1.9: Implement teardown() — call avTileAdapter.disconnect() and clean up hook listeners
    • 1.10: Green all VisibilityBadge tests
  • Task 2: Implement FirstEncounterPanel class (inside VisibilityBadge.js) (AC: 4, 5)

    • 2.1: Write failing tests for FirstEncounterPanel (TDD red) — timer-based tests use vi.useFakeTimers()
    • 2.2: Implement show(anchorEl) — create and append panel element; role="dialog", aria-modal="false"; start 10s #collapseTimer
    • 2.3: Implement timer pause: mouseenter / mouseleave on panel element; focusin / focusout events (not :focus-within directly — use event listeners)
    • 2.4: Implement "Got it" button handler — clearTimeout(this.#collapseTimer), this.#collapseTimer = null, call setFirstBadgeEncountered(), then _dismiss() (removes panel from DOM)
    • 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
    • 2.6: Implement chip element — role="button", tabindex="0"; click and keydown Enter/Space → open VisibilityDetailsPanel
    • 2.7: Implement _onClose()clearTimeout(this.#collapseTimer), this.#collapseTimer = null (must be called in teardown to prevent ghost timers)
    • 2.8: Green all FirstEncounterPanel tests
  • Task 3: Implement VisibilityDetailsPanel class (inside VisibilityBadge.js) (AC: 6)

    • 3.1: Write failing tests for VisibilityDetailsPanel (TDD red)
    • 3.2: Implement using native <dialog> element + showModal() — built-in focus trap + backdrop in modern browsers; aria-modal="true" attribute
    • 3.3: Implement show(state, actor, triggerEl) — create <dialog>, populate content, document.body.appendChild(dialog), dialog.showModal(), store triggerEl for focus return
    • 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()
    • 3.5: Implement _onClose()dialog.remove(), this._triggerEl?.focus() (return focus)
    • 3.6: Populate content per state — actor line, state explanation, audience (suppress + reassurance when state === 'hidden'), reassurance ("Your audio is active for all participants.")
    • 3.7: Handle stale data — show "Data may be outdated" note if controller is not available; for v1 this can check controller != null
    • 3.8: Green all VisibilityDetailsPanel tests
  • Task 4: Implement styles/components/_player-badge.less (AC: 1, 2, 4, 5, 6)

    • 4.1: Replace stub content with full badge + panel CSS; remove incorrect scoping comment (see §CSS Exception note below)
    • 4.2: .sp-visibility-badgeposition: absolute; top: 0; left: 50%; transform: translateX(-50%); for top-center tile injection; anatomy: mini StateRing (16px) + state icon + label text
    • 4.3: State label typography: font-size: 0.6875rem (11px), letter-spacing: 0.02em
    • 4.4: FirstEncounterPanel styles — max-height transition 300ms ease-out for collapse; panel positioning relative to badge/tile
    • 4.5: Chip styles — small, focusable, matches badge visual language
    • 4.6: VisibilityDetailsPanel (<dialog>) styles — content layout; "Close" button; backdrop
    • 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
  • Task 5: Update module.js (AC: 1)

    • 5.1: Add import { VisibilityBadge } from './src/ui/player/VisibilityBadge.js'; at top of file
    • 5.2: Add let visibilityBadge; with other module-level variables
    • 5.3: In Hooks.once('ready') after roleRenderer.init() + openStrip() block:
      if (!adapter.users.isGM()) {
        visibilityBadge = new VisibilityBadge(stateStore, scryingPoolController, avTileAdapter, adapter);
        visibilityBadge.init();
      }
      
    • 5.4: Update the init comment block at top of module.js to mention Story 1.6
  • Task 6: Update lang/en.json with i18n keys

    • 6.1: Add badge state labels (hidden, self-muted, offline, cam-lost, reconnecting, never-connected, ghost)
    • 6.2: Add FirstEncounterPanel copy (title: "Your camera visibility changed.", body: "Audio continues normally.", "Got it" button label)
    • 6.3: Add VisibilityDetailsPanel copy ("Close", audience suppression text, stale data indicator text, reassurance text)
  • Task 7: Update src/types/foundry-globals.d.ts

    • 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)
  • Task 8: Verify full pipeline

    • 8.1: npm run test — all tests pass (expect ~2030 new tests)
    • 8.2: npm run lint — 0 new lint errors
    • 8.3: npm run typecheck — 0 new typecheck errors
    • 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.

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()):

// 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 localStorageignore 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:

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):

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:

// 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

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:

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):

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:

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:

function makeAVTileAdapter() {
  return {
    mount:           vi.fn(),
    unmount:         vi.fn(),
    setStateClass:   vi.fn(),
    onTileRerender:  vi.fn(),
    disconnect:      vi.fn(),
  };
}

Fake timers for FirstEncounterPanel:

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.31.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 499554)
  • UX components §6.96.11 (VisibilityBadge, FirstEncounterPanel, VisibilityDetailsPanel): _bmad-output/planning-artifacts/ux-design-specification.md (lines 12681321)
  • VisibilityBadge injection pattern: _bmad-output/planning-artifacts/ux-design-specification.md §VisibilityBadge Injection Pattern (lines 465472)
  • Player journey JY-3: _bmad-output/planning-artifacts/ux-design-specification.md §5.3 (lines 923952)
  • Overlay/modal patterns + focus trap rules: _bmad-output/planning-artifacts/ux-design-specification.md §7.3 (lines 14521465)
  • 4-tier feedback pattern (no toast on success): _bmad-output/planning-artifacts/ux-design-specification.md §7.2 (lines 14151447)
  • firstBadgeEncounter decision: _bmad-output/planning-artifacts/architecture.md (lines 228, 250)
  • Architecture init order: _bmad-output/planning-artifacts/architecture.md §Initialisation Order (lines 303319)
  • Import boundaries: _bmad-output/planning-artifacts/architecture.md (lines 428444)
  • Error handling by layer: _bmad-output/planning-artifacts/architecture.md (lines 510517)
  • 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.