27 KiB
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
-
Given a player is connected with AV enabled
When the module is active
Then a persistentVisibilityBadgeappears on their own AV tile
And the badge is visible only to the owning player (not to other players or the GM)
Androle="status",aria-live="polite",aria-label="Camera visibility: [state label]"are set
And badge tokens are declared on:root(badge mounted outside.scrying-poolroot, usingAVTileAdapterfrom Story 1.5) -
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)
-
Given the GM changes a player's visibility state
When the socket broadcast completes
Then the player'sVisibilityBadgeupdates within 500ms -
Given
firstBadgeEncounteruser flag is not set and a state change occurs
When the badge updates
ThenFirstEncounterPanelappears with a plain-language explanation
And a 10s auto-collapse timer starts
Andmouseenteror:focus-withinon the panel pauses the timer (resumes on leave/blur)
And "Got it" setsfirstBadgeEncounterand immediately closes the panel
And the panel isaria-modal="false",role="dialog", and is NOT a focus trap -
Given the 10s timer expires without interaction
When auto-collapse fires
Then the panel collapses viamax-heightfold animation (300ms ease-out) into a persistent chip
And the chip is focusable and keyboard-activatable, re-openingVisibilityDetailsPanelon 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)
AndclearTimeoutis called on "Got it" click and on_onClose()teardown to prevent ghost timers -
Given a player clicks their
VisibilityBadgeor the collapsed chip
WhenVisibilityDetailsPanelopens
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 ishidden, the audience list is suppressed and replaced with reassurance copy: "Other players cannot see your feed"
And a stale-data indicator appears whenScryingPoolControlleris unavailable
And the panel is a focus-trapped<dialog>witharia-modal="true"
And Esc, click-outside, or "Close" button dismisses it and returns focus to the triggering element -
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 logsconsole.warnwithout throwing (fail-open) -
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
AndAVTileAdapter.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.jsfirst (TDD red) - 1.2: Implement
VisibilityBadgeclass — constructor receives(stateStore, controller, avTileAdapter, adapter); side-effect-free; store all deps - 1.3: Implement
init()— resolvecurrentUserIdfromadapter.users.current()?.id; subscribe toscrying-pool:stateChangedhook; mount initial badge at current state; registeravTileAdapter.onTileRerender()callback for re-mount resilience; no-op if nocurrentUserId - 1.4: Implement
_createBadgeElement(state)— creates<div class="sp-visibility-badge" data-sp-role="visibility-badge">with correct ARIA attributes; applies label fromPLAYER_STATE_LABELS[state] - 1.5: Implement
_onStateChanged(data)— guard: only processdata.userId === this._currentUserId; update badge element label + aria-label; callavTileAdapter.mount(userId, badgeEl)(idempotent); triggerFirstEncounterPanelif_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
VisibilityDetailsPanelwith current state + actor info - 1.9: Implement
teardown()— callavTileAdapter.disconnect()and clean up hook listeners - 1.10: Green all VisibilityBadge tests
- 1.1: Write failing tests in
-
Task 2: Implement
FirstEncounterPanelclass (insideVisibilityBadge.js) (AC: 4, 5)- 2.1: Write failing tests for
FirstEncounterPanel(TDD red) — timer-based tests usevi.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/mouseleaveon panel element;focusin/focusoutevents (not:focus-withindirectly — use event listeners) - 2.4: Implement "Got it" button handler —
clearTimeout(this.#collapseTimer),this.#collapseTimer = null, callsetFirstBadgeEncountered(), then_dismiss()(removes panel from DOM) - 2.5: Implement
_collapse()— setmax-heightanimation via CSS class; aftertransitionend(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";clickandkeydown Enter/Space→ openVisibilityDetailsPanel - 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
- 2.1: Write failing tests for
-
Task 3: Implement
VisibilityDetailsPanelclass (insideVisibilityBadge.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(), storetriggerElfor focus return - 3.4: Implement close handlers:
- Esc: native
<dialog>handles; listen tocloseevent →_onClose() - Backdrop click:
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); }) - "Close" button:
dialog.close()
- Esc: native
- 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
- 3.1: Write failing tests for
-
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-badge—position: 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:
FirstEncounterPanelstyles —max-heighttransition300ms ease-outfor 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')afterroleRenderer.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.jsto mention Story 1.6
- 5.1: Add
-
Task 6: Update
lang/en.jsonwith i18n keys- 6.1: Add badge state labels (hidden, self-muted, offline, cam-lost, reconnecting, never-connected, ghost)
- 6.2: Add
FirstEncounterPanelcopy (title: "Your camera visibility changed.", body: "Audio continues normally.", "Got it" button label) - 6.3: Add
VisibilityDetailsPanelcopy ("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 / getFlagis declared; if not, add to existinggamedeclaration (do NOT duplicate thegamedeclaration — extend theusersub-object)
- 7.1: Verify if
-
Task 8: Verify full pipeline
- 8.1:
npm run test— all tests pass (expect ~20–30 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
- 8.1:
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 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:
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
FirstEncounterPanelon first encounter - Instantiate
VisibilityDetailsPanelon badge/chip click
FirstEncounterPanel responsibilities:
- Non-modal explanatory panel; 10s collapse timer
- Pause timer on
mouseenter/focusin; resume onmouseleave/focusout - "Got it" → set flag + dismiss;
clearTimeoutalways max-heightfold animation → chip after collapse_onClose()MUSTclearTimeout(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 → dispatchescancel+closeevents; listen toclosefor 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-domsupports<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-varson line ABOVE acatch (_)binding Hooks/game/uiglobals declared insrc/types/foundry-globals.d.ts— addsetFlag/getFlagtogame.userif missing; NEVER add a seconddeclare const game— extend the existing one'susersub-property- Named exports only:
export class VisibilityBadge— neverexport default - Pre-existing lint errors in
scripts/package.mjs(7 errors) — NOT in scope, do not touch async/awaitnot.then(); guard clauses with early return;nullnotundefinedfrom 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 changessrc/core/files — no changessrc/contracts/— no changestests/helpers/foundryAdapterMock.js— no structural changes needed; thecurrent()override in individual testmakeAdapter()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) firstBadgeEncounterdecision:_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.jsfollowing ActionPopover pattern from Story 1.5 - 48 new tests added covering all three classes (296 total, all passing)
- FirstEncounterPanel uses private class field
#collapseTimerwith 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 defaulttransition: none; animation: noneapplied before media query _player-badge.lessstub comment replaced with correct documented exception commentfoundry-globals.d.tsextended withgame.user.getFlag/setFlag(no duplicate declaration)- Pre-existing 7 lint errors in
scripts/package.mjsuntouched per story Dev Notes
File List
src/ui/player/VisibilityBadge.js— NEWtests/unit/ui/player/VisibilityBadge.test.js— NEWstyles/components/_player-badge.less— MODIFIEDmodule.js— MODIFIEDlang/en.json— MODIFIEDsrc/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.