Files
scrying-pool/_bmad-output/planning-artifacts/ux-design-specification.md
T
2026-05-21 23:08:34 +02:00

90 KiB
Raw Blame History

stepsCompleted, inputDocuments
stepsCompleted inputDocuments
1
2
3
4
5
6
7
8
9
10
11
12
13
14
_bmad-output/planning-artifacts/briefs/brief-video-view-manager-2026-05-19/brief.md
_bmad-output/planning-artifacts/prds/prd-video-view-manager-2026-05-19/prd.md

UX Design Specification: Scrying Pool (Video View Manager)

Author: Morr Date: 2026-05-19


Executive Summary

Project Vision

Scrying Pool is a FoundryVTT v14 module that gives the GM direct, real-time control over individual player webcam visibility — something the native AV system completely lacks. The core promise: one click to show or hide any player's camera, with every connected client updating instantly.

Beyond the v1 toggle, the module grows into a session cinematography tool — GMs can design the visual experience of their table like a director. The architecture is Progressive Enhancement: Level 1 adds a right-click action to existing AV Tiles (zero new UI); Level 2 adds the Director's Board for bulk control; Level 3 adds Scene Presets and automation.

Target Users

Persona Core Need Key UX Insight
Marcus (Veteran GM) Prep once, trust automation; one-click override always available Ambient status over popup-driven notifications; Director framing resonates
Sofia (Privacy-conscious player) Always know her feed state; opt out of automation touching her camera Needs persistent self-state indicator; language must be neutral ("managed" not "hidden"). Open question: Sofia A (wants GM camera management — relief) vs. Sofia B (resents unilateral control — needs consent). These have opposite requirements; primary driver must be decided before detailed design.
Jake (Streamer GM) Keyboard-first operation; zero mouse during live broadcast Full action set must have keyboard bindings as first-class feature; compact strip must be a roster (avatar + initials), not a status bar
Alex (New player) Plain language, toast explanations, no camera anxiety Wants rich informative toasts; role-differentiated notification verbosity required

Key Design Challenges

1. Role-differentiated rendering is a core architectural requirement The GM UI and Player UI are not the same surface with permissions masked. The GM needs: bulk controls, ambient status, keyboard affordances, scene-level automation. The player needs: one thing — a persistent, never-dismissible indicator of their own camera state, with GM controls entirely absent. These must be built as separate surfaces at the template and rendering level — separate component trees, separate event handlers. The shared layer is the data model, not the UI.

2. Notification verbosity is role-differentiated Marcus (GM) needs zero interrupt-driven notifications — ambient, glanceable status is the correct pattern. Alex (new player) needs rich, plain-language toasts. A persistent self-state badge is the ground truth signal (always present, never dismissible); toasts are the announcement layer. Architecture decision needed: a notification bus (role + user preference determines render), not toasts bolted onto individual state mutations.

3. Language and framing carry psychological weight "Hidden by GM" reads as punitive. "Camera managed by GM" / "Camera visible to table" is factually identical but psychologically neutral. All player-facing state copy must be audited for framing as a trust concern, not just tone.

4. Level 1 discoverability is a known strategic tradeoff Right-click on AV Tile is fast and zero-footprint but entirely undiscoverable. This is not a tension — it is a known tradeoff: zero footprint costs zero adoption for new users without out-of-band documentation. Mitigation: a single one-time contextual tooltip on first GM activation ("Right-click to manage visibility") that is dismissed forever. Marcus never sees it again; Alex gets his footing. Alternatively, accept Level 1 is deliberately power-user-only and document that decision explicitly.

5. Consent is an open question requiring explicit decision The module enables a GM to manipulate what others see of a player's video stream. GDPR Article 7 concerns apply. An explicit documented decision is required: is consent handling in-scope or out-of-scope? If out-of-scope, what is the rationale? "Camera managed by GM" language softens the feeling but does not address the fact.

Design Opportunities

1. State display tiers: director cues / table awareness / background operations Don't design 8 equal visual treatments. Design 3 tiers that reflect the GM's directing posture: Director cues (GM changed something — confirmation feedback, high visual weight), table awareness (something happened that affects the scene — offline, reconnecting, medium weight), background operations (infrastructure doing its job, stay out of the way — ghost, never-connected, no tile rendered). Same three buckets as urgency hierarchy, but framed from control rather than reaction.

2. Director's Board as production monitor — two modes The Director's Board should feel like a broadcast monitor the GM glances at: persistent, always-on, low-footprint. Two modes required: full seating chart (faces, names, states — spatial, iconic, tabletop culture) and compact roster strip (avatar thumbnail at 24px minimum + initials + state — for streamers who cannot sacrifice screen real estate during live broadcast). A collapsed strip that shows only colored dots is insufficient; it must remain a roster under compression.

3. Unified visual language system A coherent system of icons, overlays, color, and copy legible across all surfaces: AV tile overlays, Director's Board cards, toast notifications, player self-indicator badge. Icons must always have plain-language tooltips — icons alone fail new users. Copy language is shared and consistent across all surfaces.

4. Director framing as product identity (UI layer only) "Director's Board," "Scene Presets," "Spotlight," "Blackout" — this vocabulary positions camera management as session craft, not system administration. This framing belongs exclusively in the UI and documentation layer. Internal code uses functional names: VisibilityManager, ParticipantState, AudienceView.

5. Architecture-forward Visibility Matrix Map<participantId, Map<viewerId, VisibilityState>> from day one — even though v1 uses GM-global state. GM-only writes with read-fanout to all players: simple, auditable, no race conditions. Level 1 and Level 2 must write to the same matrix; a GM upgrading from Level 1 to Level 2 sees fully coherent state with no migration.

6. Empty and cold-start states need design What does the Director's Board look like when no webcams are enabled? What does a player see joining a session with no configuration? Cold-start moments are first impressions. These states are currently undesigned and must be explicitly addressed.


Core User Experience

Defining Experience

The defining experience of Scrying Pool is the GM as session director — not a system administrator, not a settings manager, but someone who shapes the visual atmosphere of the table in real time.

The core loop is minimal and fast:

  1. GM notices a camera state that needs to change
  2. GM acts (right-click, keyboard shortcut, or Director's Board tap)
  3. State applies optimistically on the GM's client immediately; all other clients update within 500ms
  4. GM's ambient monitor confirms the change — and moves on

The entire experience must support playing while managing. A GM who has to leave the fiction to operate the module has been failed by the UX.

Platform Strategy

  • Desktop browser only (Chrome/Firefox, 1080p+). No mobile target. Player-facing surface is minimal by design.
  • The module overlays and enhances existing AV Tile markup — it does not replace it.

Effortless Interactions

Interaction Target User Effortless Standard
Hide/show a player's camera Marcus & Jake (GM) One right-click or single keyboard shortcut. No confirmation dialog. Optimistic — instant on GM client.
Know your own camera state Sofia (Player) Persistent badge at top-center of own tile + secondary fixed HUD anchor independent of tile position. Always present. Never requires action to find.
Understand why your camera changed Alex (New player) Toast appears automatically with plain-language explanation. Role-based verbosity defaults — players receive own-state changes only.
See all camera states at once Marcus (GM) Director's Board is a glance, not a read. State is spatial and iconic. GM notification default: silent for system transitions, minimal for director cues.

Friction to eliminate:

  • No confirmation dialogs for show/hide
  • No page reload required for any state change
  • No manual sync — state propagates automatically to late joiners
  • No visual reflow when states change — tile positions stay fixed
  • No silent lag — a brief tile spinner appears only on sync timeout, not on normal operation

Critical Success Moments

The first successful hide — A GM right-clicks an AV tile, selects "Hide Camera," the overlay appears instantly on their own client, propagates to all others within 500ms. The Director's Board confirms the state. This is the moment the module earns trust. Failure mode guarded: context menu item available across all applicable Participant States; socket listener registered at ready hook.

The persistent badge, noticed — A player glances at their tile and sees "Camera managed by GM" at top-center. They weren't watching when it changed. The badge was just there — also anchored in a fixed HUD position independent of tile scroll. They feel informed, not surveilled. This is the moment trust is extended to the player. Failure mode guarded: badge passes WCAG AA contrast against all tile backgrounds; secondary HUD anchor survives tile being off-screen.

The cold start works — A new GM activates the module. A sidebar icon confirms the module is active. When the first AV tile renders, a single tooltip appears: "Right-click to manage visibility." Triggered by firstGMActivation world flag — not session state. The tooltip never appears again. This is the moment zero-footprint and discoverability coexist. Failure mode guarded: tested with AV disabled, AV with no players, and AV with players connected.

Experience Principles

  1. Play while managing — Every control must be operable without breaking the GM's attention from the session. If it requires a modal, a wizard, or more than two clicks, it's too much.

  2. State is always visible, never chased — Persistent indicators are the truth. Toasts are the announcement. A user should never have to remember a notification to know current state. The badge and HUD anchor are ground truth; toasts are optional.

  3. Neutral language is non-negotiable — Player-facing copy is audited at every surface. No language that implies punishment, surveillance, or loss of agency.

Notification Design Constraints

Role-based defaults wired to urgency tiers from day one:

Urgency Tier GM Default Player Default
Director cues (GM changed something) Minimal — ambient confirmation Not shown
Table awareness (system event affecting scene) Silent — visible in Director's Board only Shown for own state only
Background operations Silent Silent

Toast spam is a trust-destroyer. The notification architecture must enforce these defaults, not leave them to user discovery.


Desired Emotional Response

Primary Emotional Goals

For the GM (Marcus, Jake): Calm authority. The feeling of a director who knows exactly what the audience sees, who can adjust the frame without breaking a sweat. This is an output of good UX, not an input — the design must produce calm authority in a tired, distracted GM at hour three of a session.

For the player (Sofia, Alex): Informed safety. The knowledge that your presence is being managed, you know exactly how, and you have not been excluded without explanation. Informed safety requires both the what (persistent badge) and the why (one-time normalising explanation). Without the why, informed can become alarmed.

Emotional Journey Mapping

Stage GM Feels Player Feels
First activation Curious, slightly skeptical Unaware — module invisible to players
First GM activation nudge Mildly prompted, easily dismissed
First successful hide Satisfied, surprised by speed Informed via badge + toast; grounded by audio-continuity phrase
First badge encounter (player) One-time micro-explanation: why this exists, normalised as table craft not surveillance
Mid-session use Confident, in flow Reassured — badge consistent and predictable
Something goes wrong In control; undo reachable Aware and unworried — toast with context explains the situation
Returning next session Habitual trust — state persisted Familiar safety — badge behaviour predictable across sessions

Micro-Emotions to Achieve

Emotion For Whom Triggered By
Confidence GM Instant visual confirmation; undo always reachable
Trust Player Persistent badge with neutral language; one-time explanation of why
Flow GM Zero-friction controls; Director's Board scannable with degraded attention
Clarity New player Contextual toasts: "Camera managed by GM. Your audio is unaffected."
Belonging All players Portrait fallback; soft visual fade on hide (not instant cut)
Readiness GM (no-video sessions) Sidebar icon + empty state communicates active and waiting, not broken

Micro-Emotions to Avoid

Emotion For Whom Risk Trigger
Anxiety Socially anxious player Badge visible to others; no audio-continuity phrase; uninformed state
Surveillance unease Player Badge without micro-explanation on first encounter
Frustration GM Slow confirmation; no undo; text-heavy Director's Board at hour three
Confusion New GM Module active but silent; empty state feels like failure
Distrust Player Inconsistent badge; hidden from stream without knowing
Overwhelm All Toast spam; notification at emotionally invested moments

EmotionDesign Connections

Desired Emotion UX Design Approach
Calm authority (GM) Optimistic UI; colour-coded Director's Board tiles (scannable, not readable); undo/quick-reveal as first-class action
Informed safety (Player) Persistent badge at top-center + HUD anchor; one-time micro-explanation on first encounter; all viewer-category states always visible to participant
Flow (GM) Right-click + keyboard shortcut; no confirmation dialogs; optional reason field on hide (easy to provide, never required)
Clarity (New player) Toasts with context and grounding: "Camera managed by GM · Scene direction. Your audio is unaffected — you can still participate fully."
Belonging (All) Portrait fallback; 300500ms fade on hide; toast delayed 12s after hide to avoid interrupting emotionally invested moments
Ethical readiness Dismissible first-activation nudge: "Before your first session, consider telling your players that you can manage camera visibility during play."

Ethical Design Positions

Tension Design Position
Dramatic reveal vs. informed player Real-time badge transparency always. Solution is pre-session consent via opt-out nudge, not delayed transparency.
Authority without justification Optional reason field on hide action. Easy to provide context, never required. Toast gracefully omits reason when none provided.
Streamer asymmetric visibility (v2) Players must always see all their visibility states across all viewer categories. Hard architectural requirement for v2.
Consent friction vs. GM autonomy Opt-out nudge at first GM activation, never a gate. One click to dismiss.

Emotional Design Principles

  1. Speed is trust — Optimistic UI is an emotional design decision. A state change that feels instant communicates GM competence; lag communicates a broken system.

  2. Presence is safety — The badge must never disappear. An absent badge is more alarming than a "Camera managed" badge. Consistency is the product.

  3. Explain the why, not just the what — Badge + one-time micro-explanation + audio-continuity grounding phrase. Players who understand the reason feel agency even when they lack control.

  4. Transitions carry emotional weight — 300500ms fade on hide; 12s toast delay after emotionally significant hide events. The how of a change matters as much as the what.

  5. Design for the exhausted user — Calm authority must be achievable at hour three. Undo is always reachable. Director's Board is scannable, not readable.

  6. Ethical defaults, not ethical enforcement — The module prompts good behaviour (consent nudge, optional reason field) but never gates it. Experienced GMs are not lectured; new GMs are guided.

UX Pattern Analysis & Inspiration

Inspiring Products Analysis

FoundryVTT (v11v14)

Core problem it solves elegantly: Orchestrates a complex multi-role collaborative experience (GM authority + player participation) with a single shared canvas, without overwhelming either party. Every interaction is spatially grounded — things live somewhere on screen with consistent affordances.

What makes it compelling:

  • Right-click as authority gesture — context menus are the GM's Swiss Army knife; one click, contextually appropriate. Must target Scrying Pool's own roster strip (WebRTC DOM is not a stable extension surface; contextmenu on <video> may surface browser native media menu)
  • ApplicationV2 — floating, resizable panels with consistent chrome; says "tool, not interruption"
  • Sidebar icon — module-active signal; tooltip must explicitly say "right-click participants in the Scrying Pool panel" to bridge cold-start discoverability
  • AV docking — ambient, always visible, never demanding; Director's Board follows this feeling
  • Player-GM role differentiation — rendered differently, not permission-masked; validates separate component tree architecture
  • Hooks and socket eventing — decoupled named events; maps directly to notification bus and read-fanout model
  • ui.notifications API — delivery mechanism for director-cue urgency tier; not the architecture

Critical insight: Scrying Pool is a social trust tool wearing Foundry's clothes. The underlying job is "manage attention in-session without breaking flow, trust, or pace." That job is interpersonal, not mechanical.


Transferable UX Patterns

Navigation / Layout Patterns:

  • Sidebar icon as module signal → tooltip: "right-click participants in the Scrying Pool panel"
  • Docked ambient panel → Director's Board as ApplicationV2 window; position/open state persisted to GM User flag {left, top, width, height, open}; keyboard shortcut to re-open via game.keybindings.register() (restricted: true, singleton-guarded, registered in Hooks.once("init"))
  • Compact strip as canonical state overview — primary multi-participant state surface; AV tile badges are confirmatory only (fail at >5 participants due to tile size constraints)
  • Scene-centric spatial thinking → good for entry points and visual trust; must not imply global state certainty (visibility is viewer-relative, not spatial — the same participant has different states for different viewers)

Interaction Patterns:

  • Toggle switches with immediate effect → optimistic UI with socket ACK reconciliation: emit {opId, senderId, targetId, nextState} on module socket; keep pendingOps: Map<opId, timeoutId>; authority client emits ack with same opId after persistence; ~2s timeout → revert + retry notification; guard stale ACKs by opId/revision so late ACKs do not re-apply reverted state
  • Hover-to-reveal controls → compact strip only (own DOM); never in core AV tile DOM (volatile during WebRTC state changes)
  • Drag-to-reorder → participant order stored as GM User flag (ordered array of stable IDs); merge persisted order with current participants on load, append unknown IDs safely

Visual Patterns:

  • Ambient state as primary signal — status always readable without a click; compact strip always-visible fallback
  • Eye-slash icon overlay as primary hidden-state signal on all participant states including offline/no-camera; opacity 0.6 on video layer only as secondary signal (opacity fails as sole indicator on black/grey tiles)
  • Badge ring at visual weight matching Foundry's disposition ring conventions

Anti-Patterns to Avoid

  • Modal dialogs for fast actions — never gate hide/show; live play demands sub-2-action, sub-1s-perceived paths
  • Opacity dimming as sole hidden-state indicator — semantically overloaded in Foundry (inactive, disabled, occluded all look "faded"); must always be paired with eye-slash icon overlay as primary signal; apply only to video layer element, never tile root
  • Patching core AV tile DOM for Level 1 right-click — core WebRTC tile DOM is not a stable extension surface; right-click must target Scrying Pool's own roster strip
  • ui.notifications without coalescingMap<participantId, {timer, lastState}> above ui.notifications; reset 3s timer on repeated changes; emit single summary when quiet; director-cue toasts auto-dismiss 1s (not Foundry default 3s)
  • Silent optimistic UI failure — socket ACK reconciliation mandatory; revert + retry notification on ~2s timeout
  • One-shot firstGMActivation tooltip — flag not set until GM confirms via "Got it" button (not dismissal/Escape); persistent ? help button in compact strip header; sidebar empty state carries permanent guidance text
  • Global one-time player micro-explanation — must be per-player per first badge encounter; badge click always shows "What is this?" tooltip
  • ApplicationV2 listeners on child nodes — rerenders replace inner DOM; use event delegation on app root
  • Configuration buried in module settings — time-sensitive controls must not require Settings → Module Settings modal
  • Foundry power-user bias without teachability — teachability for new GMs (Alex persona) is a first-class design constraint

Player Badge Experience Design

The moment a player first sees their badge is fragile. If handled poorly, it feels like surveillance. If handled well, it feels like clarity.

Emotional arc: "Something changed → Oh, this is about visibility → I know what it means → I'm still okay."

Copy principle: Calm, plain, non-accusatory. Not "you were hidden." Instead: "This badge shows your camera's current visibility in Scrying Pool. You can always click it to learn more." Goal: dignity, not just comprehension.

Per-player firstBadgeEncounter pattern:

  • Fires per player, per their own first badge encounter — not once globally per session
  • Badge click always shows "What is this?" tooltip regardless of prior encounters (trust tools must be re-explainable; memory is unreliable, anxiety is real)
  • Storage: game.users.get(userId)?.setFlag(moduleId, "firstBadgeEncounter", true) is the correct Foundry API — but this is world-persistent, user-linked data with GDPR implications. Default to localStorage (client-local) until consent/GDPR policy is resolved.

Status: Unresolved. Must be decided before implementation.

Decision needed Implication
Is per-player behavioural data stored world-side? Determines User flag vs. localStorage for firstBadgeEncounter
Is the opt-out consent nudge sufficient? Determines whether firstGMActivation requires a documented consent record
Is GDPR compliance in-scope for v1? Determines non-consenting player (5th stakeholder) job coverage
Are players informed what is stored about them? Informs badge "What is this?" copy and module description

Non-consenting player's job (currently uncovered): "Participate without personally linked persistence unless justified and consented, and know what is stored."

Option A (recommended default): GDPR out of scope for v1. Document rationale. Use localStorage for all per-player flags. Note as v2 requirement. Option B: GDPR in scope. Define consent boundary, stored data, player-facing disclosure. Affects badge copy, GM activation flow, module settings.


Jobs This Module Must Serve

Job Persona Success Signal
Hide/show without breaking session flow Marcus, Jake < 2 actions, < 1s perceived
Know instantly what changed for whom All GMs Compact strip reflects new state immediately
Recover from silent socket failure All GMs Auto-revert + retry notification within ~2s
Undo wrong hide before anyone notices Marcus, Alex Single undo path, < 3s
Feel included when visibility changed Sofia A/B Per-player badge + calm micro-explanation at first encounter; badge always clickable
Understand "what can I do here" cold Alex Confirmed tooltip + strip empty state + persistent ? button
Trust visibility was changed fairly Sofia B Optional reason field + consent nudge visible
Monitor multi-participant state at a glance Jake, Marcus Compact strip readable at any participant count
Re-open Director's Board fast Marcus, Jake Keyboard shortcut; compact strip always visible as fallback
Participate without unjustified linked persistence Non-consenting player (5th stakeholder) Resolved by GDPR policy decision

Design Inspiration Strategy

Adopt directly:

  • ApplicationV2 window chrome for Director's Board (persisted geometry via GM User flag, keyboard shortcut, singleton guard)
  • Sidebar icon as module-active signal (tooltip: explicit guidance to roster strip)
  • Right-click context menu on Scrying Pool's own roster strip for Level 1 (icon + short label, max 2 items)
  • ui.notifications API as delivery mechanism for director-cue tier (with notification bus coalescing layer above)
  • Hover-to-reveal action controls on compact strip (own DOM only)
  • Ambient-state-first hierarchy — status always readable without a click

Adapt:

  • Token HUD positioning → badge anchor top-center of player tile (video layer only, not tile root)
  • Combat tracker compact row → compact strip roster (24px avatar + name + eye-slash ring; scales to any participant count)
  • Disposition ring → eye-slash icon + ring as primary signal; opacity 0.6 on video layer as secondary only
  • Foundry event delegation pattern → all ApplicationV2 handlers on app root, not child nodes

Avoid / deliberately subvert:

  • Foundry's text-heavy context menus → icon + short label, max 2 items at Level 1
  • One-size-fits-all toasts → 3-tier urgency with coalescing, severity-distinct presentation, differentiated auto-dismiss timing
  • GM-sees-everything spatial assumption → Visibility Matrix is viewer-relative; UI avoids implying false global certainty
  • One-shot discoverability → layered, dignity-respecting teachability (confirmed tooltip + persistent help + empty state + badge tooltip)
  • Silent failure as acceptable default → mandatory socket ACK reconciliation with revert and retry path

Design System Foundation

Design System Choice

Foundry-Native Themeable Component System — module-scoped, built on FoundryVTT's CSS custom properties, Font Awesome 6, and ApplicationV2. No external UI framework. No imported component library. One design dependency: FoundryVTT itself.

Rationale for Selection

  • Platform coherence first — Scrying Pool must feel native; external systems create visual dissonance for experienced Foundry users
  • Zero CSS conflict risk — external libraries introduce selector conflicts, specificity wars, and theme breakage; module-scoped CSS with Foundry tokens avoids this entirely
  • Foundry's accessibility baseline for free — font scaling, contrast tokens, focus rings; inherited via the SP semantic layer, not overridden
  • Bundle discipline — external component libraries (50300KB) are inappropriate in the module context
  • Theme compatibility — Foundry-native tokens inherit theme changes automatically; external systems break on theme change
  • HandlebarsApplicationMixin(ApplicationV2) — v14-correct templated window path; PARTS, _prepareContext(), event delegation on app root; nothing better shipped in v14

CSS Scoping Architecture

All module CSS scoped via DEFAULT_OPTIONS.classes = ["scrying-pool"]. Protects module styles from leaking out; does not fully prevent Foundry styles from leaking in (form element defaults, window chrome, tag-level styling).

Required discipline:

  • Every selector scoped to .scrying-pool — no bare button, a, input selectors
  • Small local reset for interactive elements inside the namespace
  • Class-based selectors only; no reliance on Foundry DOM structure
  • Specificity one level above core Foundry selectors when conflict resolution needed
  • Linting convention: --color-* / --font-* / --border-* Foundry tokens are forbidden inside .scrying-pool CSS — always use --sp-* aliases. This is the sole enforcement point for the semantic layer.

Design Token Architecture

Layer 1 — SP Semantic Alias Tokens

Thin alias layer mapping to Foundry tokens in one place. If Foundry renames or shifts token semantics between versions, only this layer needs updating. Every token includes a hardcoded fallback for environments where the upstream token is absent.

SP Token Maps to Fallback Purpose
--sp-surface --color-bg #1a1a2e Panel/strip backgrounds
--sp-border --color-border #4a4a6a Component borders
--sp-text-primary --color-text-primary #c9c9c9 Labels, names
--sp-text-secondary --color-text-dark #888 Muted/secondary labels
--sp-accent --color-light-highlight #5599aa Active/visible accent
--sp-focus Foundry focus ring 2px solid #5599aa Keyboard focus state

Layer 2 — SP Participant State Tokens

All 8 participant states require distinct visual tokens. The intentional/technical/passive/self taxonomy is a design constraint: every state must have a second signal beyond colour (icon, shape, or motion) to serve colour-blind users.

State Token Category Colour direction Second signal
active --sp-state-active Intentional Positive accent (inherit --sp-accent) Default positive — none needed
hidden --sp-state-hidden Intentional Cool, deliberate (TBD Step 8) Eye-slash corner badge (10px, bottom-right of avatar)
self-muted --sp-state-self-muted Self Neutral, informational Microphone-slash icon
offline --sp-state-offline Technical Subdued, low-saturation User-slash or cloud-slash icon
cam-lost --sp-state-cam-lost Technical Amber family Video-slash icon + shimmer motion
reconnecting --sp-state-reconnecting Technical Amber family Ring pulse animation
never-connected --sp-state-never-connected Passive Quiet, placeholder grey Distinct placeholder avatar (initials or "")
ghost --sp-state-ghost Passive Dimmed, faded Ghost icon or distinct absent-face treatment

ghost and never-connected must be visually distinguishable from a loading tile (loading = spinner; never use opacity or colour alone for any state).

Specific colour values defined in Step 8. WCAG AA contrast required against both Foundry dark and light themes.

Layer 3 — SP Urgency Tier Tokens

--sp-urgency-director must not inherit Foundry's error/warn colours. A director cue is a deliberate stage direction — not a failure. Red/amber communicates "something broke"; the correct signal is "someone made a decision."

Tier Token Colour direction Toast behaviour
Director cues --sp-urgency-director Cool, deliberate — distinct from error and info Fires; auto-dismiss 1s; copy: "Scrying Pool: [Name] visibility updated"; distinct position/chrome from Foundry error toasts
Table awareness --sp-urgency-awareness Neutral/information Fires; standard dismiss
Background operations --sp-urgency-background No toast ever. Silent by design.

Layer 4 — SP Motion Tokens

All motion tokens wrapped in @media (prefers-reduced-motion: no-preference). Fallback = instant transition with icon/state change only.

Token Value Use Client scope
--sp-fade-hide 300500ms ease-out Video layer opacity on hide Player client only — GM sees instant state change
--sp-pulse-reconnecting 1.5s ease-in-out infinite StateRing pulse for reconnecting Both clients
--sp-shimmer-degraded TBD Step 8 cam-lost shimmer Both clients
--sp-toast-delay 12s Director-cue toast delay after hide GM client

Hide fade on GM client reads as lag/uncertainty — the fade is a player-side emotional design decision only.

Component Architecture

UI Components

Component Description
ParticipantRosterRow 24px avatar with integrated StateRing + name + fixed-width hover action rail; panel supports overflow-y: scroll for 10+ participants; hover actions are shortcuts, not the sole interaction path
VisibilityBadge Eye-slash corner badge + state ring + click-to-explain; injected into AV tile DOM via MutationObserver on ui.webrtc.element
EmptyStatePanel Alex's first-contact surface; see content spec below

Styling Primitives

Primitive Description
StateRing Coloured ring wrapping avatar; eye-slash appears as 10px corner badge at bottom-right (not centered overlay — centered obscures face at 24px) when hidden only

Behavioral Patterns

Pattern Description
OptimisticToggle UI state shell only: idle → pending → confirmed/reverted/error; owns no socket protocol
Hover action rail Fixed-width container; reveal via .row:is(:hover,:focus-within) .row__actions; hide via opacity/visibility/pointer-events (not display:none)

Services (not components)

Service Description
NotificationBus Coalescing layer above ui.notifications; Map<participantId, {timer, lastState, changeCount}>; coalesces after 3s quiet; reports final state + change count; suppresses notification if net state = original state
VisibilityController Socket protocol owner; pendingOps: Map<opId, timeoutId>; configurable ACK timeout (default 2s, range 15s); retry-before-revert: resend op once on timeout; revert only if retry also times out; guard stale ACKs by opId/revision

ParticipantRosterRow Layout

Row reads in one sweep: face → name → status confidence → available action

[ StateRing(avatar 24px) ] [ Name (ellipsized, flex-1) ]   [ action rail (fixed-width) ]
  └─ eye-slash corner badge                                   └─ revealed on :hover/:focus-within
     (hidden state only,
      10px, bottom-right)

VisibilityBadge Injection Pattern

  1. Attach MutationObserver (subtree: true, childList: true) to ui.webrtc.element
  2. Debounce by one microtask before processing
  3. Inject only when tile has [data-user-id] attribute and video child present
  4. Check for existing badge before injecting — update in place; remove-and-reinsert only if structure needs full rebuild
  5. position: absolute relative to tile bounding box (tile has position: relative); top: 0; left: 50%; transform: translateX(-50%) adapts to any tile width and AV grid position
  6. Own only the injected .scrying-pool__badge node; never modify Foundry's native tile structure

EmptyStatePanel Content

"No participants yet"

When players join with cameras, you'll be able to manage who is visible in the Scrying Pool. Hiding a participant affects visibility, not their place at the table.

Visibility changes are always explained in-session.

Visual: centred, low-noise; eye icon with gentle state ring; slow breathing/pulse animation on icon (ready and waiting, not broken); one optional "Learn how visibility works" link. Anticipatory tone — not empty, not broken, prepared.


2. Core User Experience

2.1 Defining Experience

"The GM changes visibility, the player understands it, and the table keeps moving."

The defining experience of Scrying Pool is not a feature — it is the absence of a pause. A GM hides a participant. Two actions maximum. The table never stops. The hidden player is informed and still present. No one wonders what just happened.

JTBD: When a webcam needs to be hidden mid-session, help the GM change visibility instantly — without creating confusion, suspicion, or a social stop-the-world moment.

The real enemy is not slowness. It is social rupture. The interaction succeeds when three things are simultaneously true: the GM acted in ≤2 gestures; the affected player understands what changed, who can still see them, and that they are not disconnected; and everyone else experienced continuity.

2.2 User Mental Model

GM mental model — the stage director: Marcus and Jake think of webcam management as stage direction, not data administration. Gestures must feel like a director's quiet signal: fast, private, reversible. The compact strip is the director's board at a glance — ambient, low-chrome, always at the edge of attention. The Director's Board (Level 2) makes this metaphor explicit.

The GM needs to know who can currently see whom before acting, not after. Confidence first, speed second.

Player mental model — continuity signal: A player who sees their tile change has three simultaneous questions: Who changed this? What changed exactly? Who can still see me? They do not want a feature explanation. They want a continuity reassurance: you are still part of the table, you are informed, your voice is still present. Audio continuity is mandatory — the social bond is audio, not video.

The two-path learning curve: Two explicit initiation paths serving a learnability arc:

  • Primary path (novice): Explicit action in a familiar participant management surface — visible "Hide from table" option, no hunting required
  • Fast path (expert): Compact strip hover toggle / right-click context menu / keyboard shortcut for repeat operation
  • Learning bridge: First successful novice-path action surfaces a one-time tip: "Manage faster from the Scrying Pool compact strip."

2.3 Success Criteria

Criterion Target Measurement Method
GM speed ≤2 gestures from any operating state Task timing in usability test
GM pre-action confidence GM states who can see whom before acting, without guessing Comprehension probe
GM state immediacy Optimistic update before animation completes Timestamps: click → local state commit → animation start
Propagation ≤500ms (local/ideal), ≤2s (expected household latency) Network-banded latency test
Player 3-question test Within 10s: Who changed this? What changed? Who can still see me? Unaided, ≥90% accuracy Post-change usability probe
No-confusion rate ≥90% report change felt explained, not alarming Post-session survey
Scope comprehension Correct identification of affected audience (table/stream/GM-only). Misinterpretation <10% Labelled scenario cards
Cold-start discoverability New GM finds hide control in ≤30s, no docs First-session task test
Outcome prediction Alex correctly states what will happen in ≥80% of trials before clicking Think-aloud test
Terminology clarity Labels/tooltips understood by first-time users ≥90% Cognitive walkthrough
Reversibility GM undoes in ≤1 gesture from completion state Task test
Audio continuity Audio never interrupted by visibility change Live session verification
Resilience Optimistic revert reason legible to GM; retry-before-revert fires silently Error injection test
Jake — broadcast certainty GM confirms stream-audience visibility in ≤1 glance / ≤2s Glance test
Jake — hotkey efficiency Hide/unhide via single chord; no palette traversal Keystroke count audit
Jake — mode error rate Table-hidden vs stream-hidden confusion <5% Scenario test

Canonical action labels (all surfaces): "Hide from table" / "Show to table" — verbatim in participant panel, strip context menu, hover rail, and keyboard shortcut tooltip.

Badge copy (canonical): "This badge shows who can currently see your camera. Click anytime to learn more."

Table-wide notification: OFF by default. Default: affected player gets private badge explanation; GM gets optimistic strip confirmation; all other players experience continuity. Table-wide notification is GM-configurable.

2.4 Novel UX Patterns

Pattern name: Scope-Explicit Moderator Action

Precedent family: Zoom host controls, Google Docs permission notices, community moderation transparency, OBS program/preview. The novelty is the combination: unilateral GM control + asymmetric role rendering + live social pressure + possible third audience (stream viewers) + webcam as the controlled object.

Six required design elements:

Element Required Design Surface
Actor Who performed the action — visible in player badge
Action What changed — hidden/visible, for which audience
Scope Which audience is affected — table / stream / GM-only
Duration/State Current state always visible; is this persistent or temporary?
Reversal path Player knows this is reversible; GM undoes in 1 gesture
Optional reason GM can attach a note; surfaced in badge on player client
Canonical labels "Hide from table" / "Show to table" verbatim on every surface

Trust will not come from kindness. It will come from legible power.

2.5 Experience Mechanics — Level 1 (Core Hide/Show)

Initiation

Primary path (novice GM): Explicit "Hide from table" / "Show to table" action in participant management surface. No right-click discovery required.

Fast path (expert GM): Right-click on compact strip row → context menu. Or: hover row → hover action rail → eye/toggle icon. Or: keyboard shortcut (single chord, game.keybindings.register(), restricted: true).

Two onboarding flags:

  • firstGMActivation — set on first successful hide/show via any path, confirmed via "Got it" button. Persistent ? button in strip header.
  • firstStripOpen — set on first time GM opens compact strip. Triggers right-click affordance tip: "Right-click any row to manage visibility faster." Fires even if firstGMActivation is already set.

Interaction

  1. GM initiates action (primary or fast path)
  2. Both the action-origin surface and the compact strip update optimistically — regardless of which surface was used
  3. Socket message emitted with opId and revision
  4. Sofia's badge transitions to pending optimistically the moment the socket message is emitted (same pattern as GM strip — badge never lags the fade)

Feedback — GM client

  • State ring on strip row: active/hiddenpending (pulsing ring indicator — not error styling)
  • On ACK received: pending → confirmed (pulsing stops — no toast for successful expected operations)
  • On revert: brief director-cue toast: "Scrying Pool: [Name] visibility update failed — retrying." Then retry. If final revert: "Scrying Pool: [Name] visibility reverted — check connection."

Feedback — Affected player client

  • Hide fade (300500ms) applies to the affected player's view of their own tile only
  • Other players see the tile appear/disappear without transition animation
  • Badge transitions pendinghidden on state confirmation
  • First firstBadgeEncounter: Badge pulses once + inline explanation (private, not broadcast): "This badge shows who can currently see your camera. Click anytime to learn more." "What is this?" always clickable.
  • Subsequent encounters: Badge updates silently. Always clickable.
  • Emotional arc: Something changed → I am informed → I am still here.
  • Audio is never interrupted.

Feedback — Other players

Other players see Sofia's tile appear/disappear without transition animation — no fade on their clients. No announcement by default. If GM enables table-wide transparency: coalesced notification after 3s debounce, suppressed if net state equals original state.

Completion

GM strip reflects stable state (no pulsing). No success toast. pendingOps clears opId on ACK. All initiation methods immediately available for next action.


3. Visual Design Foundation

3.1 Color System

Theme: Night Watch — near-black surfaces, moss-green accent, amber director-cue. The module stays invisible until needed; green is the universal "visible/on" signal.

Three-Tier Token Architecture

Tier Tokens Purpose
Foundation/Theme --sp-surface, --sp-surface-raised, --sp-border, --sp-accent, --sp-focus, --sp-urgency-director Raw theme values; one place to swap themes
Semantic UI --sp-text-primary, --sp-text-secondary, --sp-text-muted, --sp-border-subtle, --sp-surface-interactive, --sp-focus-ring, --sp-badge-bg, --sp-badge-text, --sp-control-bg Component-usage aliases; decoupled from theme values
State --sp-state-active, --sp-state-hidden, --sp-state-self-muted, --sp-state-offline, --sp-state-cam-lost, --sp-state-reconnecting, --sp-state-never-connected, --sp-state-ghost State ring/icon colours only — never used as text/pill foreground

Token File Structure (v14 module CSS loading)

styles/
  tokens/foundry-adapter.css    ← :root; SP aliases → Foundry tokens → hardcoded fallbacks
  themes/night-watch.css        ← :root, .scrying-pool; Night Watch concrete values
  scrying-pool.css              ← .scrying-pool; all component/layout rules

Loaded via module.json > "styles": [...] in declaration order. No runtime <link> injection.

VisibilityBadge note: badge is injected into ui.webrtc.element — outside any .scrying-pool root. Token definitions must live on :root to be accessible to tile-adjacent DOM.

Adapter Layer — Indirection Pattern

/* styles/tokens/foundry-adapter.css */
:root {
  --sp-surface:          var(--sp-theme-surface,      var(--color-bg-option,       #141618));
  --sp-text-primary:     var(--sp-theme-text-primary, var(--color-text-primary,    #dde2e8));
  --sp-text-secondary:   var(--sp-theme-text-muted,   var(--color-text-secondary,  #7a8390));
  --sp-accent:           var(--sp-theme-accent,       var(--color-warm-2,          #4a9e6b));
  --sp-focus:            var(--sp-theme-focus,        var(--color-focus-outline,   #63c287));
  --sp-urgency-director: var(--sp-theme-urgency,      #c8982a); /* NO Foundry error/warn token */
}

Night Watch Values

/* styles/themes/night-watch.css */
:root, .scrying-pool {
  --sp-theme-surface:       #141618;
  --sp-surface-raised:      #1c1f22;
  --sp-border:              #282c30;
  --sp-theme-text-primary:  #dde2e8;
  --sp-theme-text-muted:    #7a8390;
  --sp-theme-accent:        #4a9e6b;
  --sp-theme-focus:         #63c287;
  --sp-theme-urgency:       #c8982a;
}

State Token Map

Token Value Meaning Second Signal Text/Pill use?
--sp-state-active #4a9e6b Visible, GM-controlled Filled solid ring Safe (AA)
--sp-state-hidden #6b7280 Hidden by GM — intentional Dashed ring + eye-slash Icon/shape only (~3.75:1)
--sp-state-self-muted #8b92a5 Player's own choice Mic-slash icon Safe (AA)
--sp-state-offline #4b5563 Disconnected — technical Cloud-off icon Icon/shape only (~2.4:1)
--sp-state-cam-lost #9ca3af Camera failure — technical Camera-off icon Safe (AA)
--sp-state-reconnecting #c8982a In-progress — technical Pulsing ring (lighthouse rhythm) Safe (AA)
--sp-state-never-connected #374151 Never joined — passive Empty circle (no icon) Icon/shape only (~1.76:1)
--sp-state-ghost #1f2937 Placeholder slot — passive Ghost/dash icon, lowest opacity Icon/shape only (~1.24:1)

Rules:

  • hidden/offline/never-connected/ghost: icon and shape are the only signals — never appear as text, label, or small-pill foreground
  • --sp-urgency-director never inherits from --color-warm-* or any Foundry error/warn token

Visibility Hierarchy — Stacked State Rule

When multiple states apply simultaneously, one story wins:

Priority State Wins because
1 (highest) reconnecting Transient — resolves itself; player needs to know system is working
2 hidden GM-intentional — the action that needs acknowledgement
3 cam-lost / offline Technical failure — explains why video is absent
4 self-muted Player choice — never overrides GM/system
5 (lowest) never-connected / ghost Baseline — no event has occurred

Visual rule: highest-priority ring style wins; lower-state icon may appear as small overlay badge at reduced opacity.

Player-Facing State Copy Vocabulary

State User-facing label Badge/tooltip explanation
hidden "Not visible to others" "Your camera is currently managed by the GM. Audio continues normally."
cam-lost "Camera unavailable" "Your camera feed isn't showing. Check your device settings."
offline "Disconnected" "You appear offline. Reconnecting…"
reconnecting "Rejoining view" "Reconnecting — you'll be visible again shortly."
never-connected "Not yet connected" "No camera feed yet for this session."
ghost (no label shown) (slot reserved — no interaction)

3.2 Typography System

Strategy: full Foundry inheritance. No font assets shipped.

Context Value Rationale
Strip row label font-size: 0.8125rem (13px) Compact strip density
Badge copy font-size: 0.6875rem (11px), letter-spacing: 0.02em 10px corner badge legibility
State pill font-size: 0.625rem (10px), font-weight: 600, text-transform: uppercase AA-passing tokens only
Director-cue toast font-size: 0.8125rem, font-weight: 500 Distinct from Foundry error toast weight
Empty state heading font-size: 1rem, font-weight: 400 Anticipatory tone — not broken

3.3 Spacing & Layout Foundation

Base unit: 4px. Matches Foundry's grid.

Step Value Use
micro 4px Badge inset, icon padding
inner 8px Row padding-x, pill padding
gap 12px Avatar → label gap
section 16px Strip section separation
panel 24px Panel internal padding
row 32px Standard row height

Compact strip: row 32px, avatar 24px, badge 10px bottom-right absolute, width 180px240px resizable, position persisted to GM User flag.

Director's Board (Level 2): repeat(auto-fill, minmax(80px, 1fr)), card 96px, ApplicationV2 floating window position → GM User flag {left, top, width, height, open}.


3.4 Accessibility Considerations

  • WCAG AA on all text-use tokens against #141618 (to be formally verified in Step 13)
  • Icon/shape-only rule: hidden, offline, never-connected, ghost tokens must never appear as text or small-pill foreground
  • Second signal: every state has an icon and ring-style signal independent of colour
  • Reduced motion — gate consuming rules, not just tokens:
.scrying-pool .state-ring,
.sp-visibility-badge { transition: none; animation: none; }

@media (prefers-reduced-motion: no-preference) {
  .scrying-pool .state-ring {
    animation: var(--sp-pulse-reconnecting, none); /* lighthouse: 2s ease-in-out */
  }
  .sp-video--hide {
    transition: opacity var(--sp-fade-hide, 0ms); /* 300500ms player client only */
  }
}
  • Pulsing ring (reconnecting): 2s ease-in-out lighthouse rhythm — not 0.5s heartbeat
  • Focus ring: --sp-focus #63c287, 2px solid, 2px offset
  • Badge: always keyboard-accessible; aria-label required; never icon-only for screen readers
  • --sp-urgency-director: amber only; no Foundry error/warn token in its cascade chain

4. Design Direction Decision

4.1 Directions Explored

Direction Concept Key Tradeoff
D1 — Docked Strip Permanently docked to Foundry's right panel Zero footprint but requires docking API; conflicts with native panel
D2 — Floating Strip ApplicationV2 floating window; keyboard shortcut Selected: zero conflict, standard v14 pattern
D3 — Avatar-Only Condensed 44px strip, avatars + state rings; action popover on click Selected: maximum density; detail on demand
D4 — Director's Board Primary Board promoted to main GM surface Changes product story; board remains Level 2 opt-in
D5 — Rich Row Strip 36px rows with full state-copy labels Self-teaching but verbose at 6+ participants
D6 — Player View Badge states, first-encounter panel Locked as player-side pattern regardless of GM direction

4.2 Chosen Direction: D2 + D3 — Floating Avatar Strip

The compact strip is a floating ApplicationV2 window. First open: expanded (rich rows, 240px). Expert steady-state: avatar-only (44px). Detail always accessible via click popover.

Layer Source Behaviour
Positioning D2 Floating ApplicationV2; draggable titlebar; position/open state → GM User flag {left, top, width, height, open}
Keyboard open/close D2 Single chord; game.keybindings.register(); restricted: true; Hooks.once("init")
Default content D3 Avatar-only (32px height, 24px avatar) — expert steady-state, minimum footprint
State at a glance D3 State ring (solid/dashed) + corner icon badge — no labels needed in steady-state
Action surface D3 Click avatar → inline <dialog> popover: name, state label, primary CTA, optional note
First-open default D2+D3 firstStripOpen → expanded 240px with rich rows; subsequent opens → last known width from GM User flag
Expand/collapse D2+D3 Entire collapsed titlebar click = toggle; .is-expanded class on strip root

Interaction model

Strip (floating ApplicationV2)
  │
  ├── firstStripOpen → expanded (240px, rich rows)
  │     One-time tip: "Hover any row for actions. Click 🔮 titlebar to collapse."
  │
  ├── Expert steady-state → avatar-only (44px)
  │     Collapsed titlebar: 🔮 + chevron ▶ only
  │     Entire titlebar click = expand
  │
  ├── Hover row (expanded) → action rail slides in
  │     "Hide from table" / "Show to table" (canonical labels)
  │
  ├── Click avatar → <dialog> popover (anchored to strip DOM)
  │     Name + state label (player-facing vocabulary)
  │     Primary CTA | "Add note…"
  │     Esc / click-outside → dismiss
  │
  ├── Right-click row → context menu (fast path)
  │
  ├── Keyboard shortcut → open/close strip + all child dialogs
  │
  └── Footer CTA: "Open Director's Board ↗" → Level 2

4.3 Design Rationale

The 44px avatar strip makes the module nearly invisible when nothing is changing — state rings speak without labels. When a state needs attention (amber pulse = reconnecting, dashed ring = hidden), the ring signals without demanding focus. The popover keeps action labels visible and canonical without cluttering the passive state view.

Floating ApplicationV2 avoids every docking conflict, respects Foundry's chrome, and uses the standard v14 pattern. firstStripOpen → expanded solves the cold-start discoverability criterion (≤30s for new GM, no docs) without burdening the expert steady-state.

4.4 Implementation Approach

Strip component:

  • ApplicationV2 + HandlebarsApplicationMixin; DEFAULT_OPTIONS.classes = ["scrying-pool"]
  • .is-expanded class toggle on strip root; max-width: 44px ↔ 240px + overflow: hidden — no interpolated width animation
  • Collapsed titlebar: icon + chevron only; entire titlebar = expand/collapse click target
  • Position/open state + expanded/collapsed state → GM User flag

Popover:

  • <dialog> with position: absolute anchored to strip's DOM — not a separate ApplicationV2 instance
  • Strip close event closes all child <dialog> elements (one handler)
  • Keyboard shortcut closes strip + all children atomically

Accessibility:

  • Avatar rows: role="button", aria-label="[Name] — [state label]" (player-facing vocabulary)
  • Strip: role="list", aria-label="Scrying Pool participants"
  • Never use title="" attribute — all tooltips via data-tooltip + SP tooltip component (linting rule)

Position management:

  • "Reset position" option in titlebar right-click menu → snap to default corner
  • Drag + resize handled by ApplicationV2 native APIs

State ring implementation:

  • Solid ring → box-shadow: 0 0 0 2px var(--sp-state-active)
  • Dashed ring (hidden) → outline: 2px dashed var(--sp-state-hidden); outline-offset: 2px

5. User Journey Flows

5.1 Journey JY-1: GM — First Hide, Novice Path (Alex as GM)

Entry: First session. Strip opens for the first time. No prior training.

flowchart TD
    A([Session starts]) --> B{Strip open?}
    B -- No --> C[GM opens Scrying Pool\nvia keyboard shortcut\nor module menu]
    B -- Yes --> D
    C --> D[Strip opens — firstStripOpen\nExpanded view, 240px rich rows]
    D --> E[One-time tip shown:\n'Hover any row for actions.\nAffects that participant's camera only.']
    E --> F{Alex finds\nhide control?}
    F -- Via strip hover --> G[Hover participant row\nAction rail slides in\n'Hide from table' visible]
    F -- Via participant panel --> H[Opens Foundry AV\nparticipant area\nSees 'Hide from table' button]
    G --> I[Clicks 'Hide from table']
    H --> I
    I --> J[Optimistic update fires:\nStrip row → pending\npulsing ring — not error styling]
    J --> K[Socket emit:\naction source, participantId,\ntargetState, opId, baseRevision]
    K --> L{ACK received ≤2s?}
    L -- Yes --> M[Pulsing stops\nRow → hidden state\ndashed ring + eye-slash icon]
    L -- No → silent retry --> N{Retry ACK?}
    N -- Yes --> M
    N -- No --> O[Revert to previous state\nToast: 'Visibility reverted —\ncheck connection'\nAudio always unaffected]
    M --> P{firstHideSuccess set?}
    P -- No --> Q[Single toast fires:\n'Participant hidden from table.\nAudio continues normally.'\nSets firstHideSuccess flag]
    P -- Yes --> R
    Q --> R{firstGMActivation set?}
    R -- No --> S[firstGMActivation 'Got it' prompt\n'?' button added to strip header]
    R -- Yes --> T
    S --> T([Journey complete])

Hardened: One-time success toast (firstHideSuccess) teaches new GMs that audio is unaffected. Never fires again after first confirmation.


5.2 Journey JY-2: GM — Expert Hide, Fast Path (Marcus / Jake)

Entry: Strip open, avatar-only 44px. Mid-session, eyes barely leave map.

flowchart TD
    A([GM needs to hide participant]) --> B{Preferred input}
    B -- Keyboard shortcut --> C[Single chord fires\non focused participant]
    B -- Avatar click --> D[Click avatar → popover opens\nanchored to strip DOM]
    B -- Right-click row --> E[Context menu:\n'Hide from table'\nor 'Show to table']
    D --> F[Popover: Name + state label\nPrimary CTA]
    F --> G[Clicks CTA]
    E --> G
    C --> H
    G --> H[action called:\nsource, participantId, targetState,\nopId, baseRevision]
    H --> I[Strip row + action-origin surface\nboth update optimistically\nState ring → pending pulsing]
    I --> J{ACK ≤2s?}
    J -- Yes --> K[Pulsing stops\nConfirmed state on all surfaces]
    J -- No → silent retry --> L{Retry ACK?}
    L -- Yes --> K
    L -- No --> M[Revert — latest revision wins\nStale ACK on superseded op = no-op\nDirector-cue toast: reason shown]
    K --> N[No success toast\nAmbient strip = ground truth]
    N --> O([Complete — ≤2 gestures])

Hardened: action() is the single pipeline. All 3 input paths call it identically. Latest revision wins — rapid toggles cannot produce split state.


5.3 Journey JY-3: Player — Visibility Change Received (Sofia / Alex)

Entry: GM has hidden the player's camera. Player is mid-scene.

flowchart TD
    A([GM hides player]) --> B[Socket message reaches\nplayer client\nRevision validated: latest wins]
    B --> C{Is this revision\nstale — superseded by\nlater op?}
    C -- Yes → stale --> D[ACK sent back\nNo state change\nNo animation]
    C -- No → current --> E[Badge transitions to pending\nbefore tile fade begins]
    E --> F[Hide fade: 300500ms\non player's own tile only\nOthers: instantaneous]
    F --> G{firstBadgeEncounter\nin localStorage?}
    G -- Not set --> H[Badge pulses once\nFirst-encounter panel:\n'Your camera visibility changed.'\n'Audio continues normally.'\n'Click badge anytime to learn more.'\n'What is this?' always visible]
    G -- Already set --> I[Badge updates silently]
    H --> J{Player responds\nto panel?}
    J -- Clicks 'Got it' --> K[firstBadgeEncounter set\nPanel dismisses]
    J -- Ignores --> L[Panel auto-collapses\nto 'What changed?' chip\nafter 10 seconds]
    K --> M
    L --> M
    I --> M[Badge stable:\nstate label + ring\nplayer-facing vocabulary]
    M --> N{ACK fails and\nstate reverts?}
    N -- Yes --> O['Change didn't stick' beat:\nBadge reverts\nSmall inline note:\n'Camera update pending —\naudio unchanged'\nDismisses after 5s]
    N -- No → confirmed --> P[Player clicks badge\nif curious]
    O --> P
    P --> Q[3-question answers:\nWho changed this?\nWhat changed?\nWho can still see me?]
    Q --> R([Audio never interrupted\nTable keeps moving])

Hardened: Audio-unchanged explicit in first-encounter panel. Revert state is "change didn't stick" (not a glitch). Panel collapses to chip after 10s (not permanent furniture). Stale revision = no-op with immediate ACK.


5.4 Journey JY-4: Jake — Director's Board Pre-Session (Level 2)

Entry: Jake, 5 min before live. 5 participants connected. UJ-3 from PRD.

flowchart TD
    A([Pre-session\n5 min before live]) --> B[Keyboard shortcut\nSingle chord — hands stay on keys]
    B --> C[Director's Board opens\nFloating ApplicationV2\nPosition from GM User flag]
    C --> D[Grid: all participants\nauto-fill minmax 80px\nCurrent states visible at a glance]
    D --> E{P4 not arrived yet?}
    E -- Yes --> F[Card: 'Not yet connected'\nEmpty ring]
    F --> G[Jake clicks card or hovers → toggle\nSets P4 → 'Hidden'\nOptimistic update on card\naction pipeline fires identically to JY-2]
    G --> H[Repeat for other pre-config]
    E -- No --> H
    H --> I[Jake: 'Save Preset…'\nNames it 'Pre-show layout'\nSaved to GM User flag]
    I --> J[Jake glances at board:\n≤1 glance / ≤2s\nAll states legible]
    J --> K([Goes live\nBoard stays open\nas live monitor])
    K --> L{Mid-show change needed?}
    L -- Yes --> M[Keyboard chord or board card\nor strip avatar → action pipeline\n≤2 gestures\nBoard + strip both update]
    M --> K
    L -- No --> N([Session ends\nPreset saved\nPosition flag persisted])

5.5 Journey JY-5: Sofia — Player Privacy Panel Opt-Out (UJ-2)

Entry: Sofia, before session, wants to control cam automation.

flowchart TD
    A([Before session]) --> B[Opens FoundryVTT settings\nor Scrying Pool Player Panel]
    B --> C[Sees HP-Reactive Cam Styling: ON\nDescription in plain language — no jargon]
    C --> D[Toggles OFF\nInstant save — no reload\nSetting stored in player User flag]
    D --> E[Inline confirmation updates:\nSetting indicator shows OFF\nNo toast — inline state is signal]
    E --> F{Wants to check\ncurrent visibility?}
    F -- Yes --> G[Opens player badge / status panel\n3-question view:\nWho can see me? What state? GM-controlled?]
    G --> H[Sofia enters session\nwith full awareness]
    F -- No --> H
    H --> I([Sofia plays with agency\nfull visibility awareness])

5.6 Journey JY-6: Player — Reconnect / Rejoin Mid-Session

Entry: Player's browser refreshes or connection drops. Table is still moving.

flowchart TD
    A([Player disconnects\nor page refreshes]) --> B[FoundryVTT re-establishes\nsocket connection]
    B --> C[Scrying Pool module init fires\non reconnecting client]
    C --> D[Client requests current\nvisibility state from server]
    D --> E{GM client available\nto respond?}
    E -- Yes --> F[Server sends current\nvisibility matrix snapshot\nfor this player]
    E -- No — GM offline --> G[Use last-known state\nfrom player User flag cache]
    F --> H[Client applies state:\nbadge, tile overlay, ring\nNo animation — direct to final state]
    G --> H
    H --> I{State differs from\npre-disconnect?}
    I -- Same --> J[Badge stable\nNo notification]
    I -- Different --> K[Badge shows new state\nFirst-encounter panel\nor 'What changed?' chip\nper firstBadgeEncounter flag]
    K --> L{Player was hidden\nwhile offline?}
    L -- Yes --> M[Toast once:\n'Your camera is hidden.\nAudio continues normally.'\nBadge explains on click]
    L -- No → became visible --> N[Badge updates silently\nNo toast — positive change]
    J --> O
    M --> O
    N --> O([Player back in session\nfully aware of current state])

Hardened: Reconnect bypasses first-encounter animation (direct to final state). Cache fallback when GM offline. Hidden-while-reconnecting gets exactly one explanatory toast.


5.7 Journey JY-7: Marcus — Scene Preset Auto-Apply (Level 3)

Entry: Marcus transitions to "Throne Room" scene mid-session. UJ-1 from PRD.

flowchart TD
    A([Marcus activates\n'Throne Room' scene]) --> B[Scene transition fires\nHooks.on sceneChange]
    B --> C{Preset bound to\nthis scene?}
    C -- No preset --> D[No visibility change\nStrip shows current states unchanged]
    C -- Yes → preset found --> E[Preset data read:\nfull visibility matrix snapshot\nfor this scene]
    E --> F[Batch action pipeline fires:\nAll preset ops emitted\nwith shared sceneRevision token]
    F --> G[Strip + board update\noptimistically in batch\nAll affected rows → pending ring]
    G --> H[ACKs received per participant\n≤500ms total]
    H --> I{All ACKs confirmed?}
    I -- Yes → all confirmed --> J[Pending rings stop\nAll rows show preset states]
    I -- Partial fail --> K[Failed rows revert\nACKed rows hold\nDirector bar: 'Preset partial —\nN participants not updated']
    J --> L[Confirmation bar:\n'Throne Room preset applied — 4 hidden, 2 visible'\n'Override any participant →'\nDismisses after 8s or on click]
    K --> L
    L --> M{Marcus needs\none-off override?}
    M -- Yes --> N[Right-click row or avatar click\nSingle participant action\nDoes not affect preset]
    M -- No --> O[Marcus keeps narrating\nStrip ambient — no interruption]
    N --> O
    O --> P{End of encounter?}
    P -- Yes → save updated layout --> Q[Saves current strip layout\nas new or updated preset]
    P -- No --> O
    Q --> R([Preset updated\nNext transition auto-applies])

Hardened: Batch emit with sceneRevision token. Partial failure shows which participants failed. Confirmation bar is timed-dismiss (8s) — never blocks play. One-off override does not mutate the preset.


5.8 Journey Patterns

Pattern 1 — Optimistic Update + ACK Reconciliation

action(source, participantId, targetState, opId, baseRevision)
  → update: strip row, action-origin surface, board card (all surfaces simultaneously)
  → emit socket op with opId + baseRevision
  → on ACK: resolve opId in pendingOps, stop pending ring
  → on timeout: silent retry (N attempts, T delay)
  → on final fail: revert to pre-op state, director-cue toast with reason

Surface enum: STRIP | PARTICIPANT_PANEL | DIRECTOR_BOARD | POPOVER

Pattern 2 — Latest Revision Wins (Stale ACK Guard)

onReceiveACK(opId, revision):
  if revision < participant.lastCommittedRevision → ignore (stale)
  else → apply, update lastCommittedRevision

Prevents rapid toggle producing split state. Stale ACK = no-op, no revert.

Pattern 3 — Per-Participant Last-Intent Guard

pendingOps: Map<participantId, { opId, targetState, baseRevision }>
onRevert(participantId, opId):
  if pendingOps.get(participantId).opId !== opId → skip (superseded)
  else → revert, clear pendingOps entry

Op A revert cannot roll back Op B that already committed.

Pattern 4 — Per-Role First-Encounter Education

  • GM: firstStripOpen → expanded + tip; firstGMActivation → "Got it"; firstHideSuccess → one-time audio-unchanged toast
  • Player: firstBadgeEncounter → localStorage panel (audio-unchanged explicit, collapses to chip after 10s)
  • Persistent fallback: ? button (GM strip header) / clickable badge (player tile)

Pattern 5 — Keyboard-First Convergence All input paths call the same action(). No path has special-cased logic.

Pattern 6 — Audio Continuity Invariant Visibility operations never touch WebRTC audio tracks. Confirmed proactively at: first-encounter panel, firstHideSuccess toast, reconnect toast, revert inline note.

5.9 Flow Optimisation Principles

  1. Convergence over branching — novice and expert paths meet at one state mutation
  2. Optimistic-first, graceful revert — never wait for network before showing feedback
  3. Latest revision wins — rapid state changes never produce split state across surfaces
  4. Education at the right moment — first-encounter on first meaningful event, not module load
  5. No toast for success (expert) — ambient strip is ground truth; one-time toast for first success only
  6. Keyboard paths are first-class — Jake's live broadcast depends on them
  7. Scope-explicit at every decision point — every state change tells the player which audience is affected

6. Component Strategy

6.1 Design System Coverage

Available from FoundryVTT v14 native — used as-is:

Component Foundry API Role in Scrying Pool
Floating window ApplicationV2 + HandlebarsApplicationMixin Strip, Board
Drag / resize ApplicationV2 native Strip, Board position
Context menu ContextMenu Right-click row/avatar
Keybinding game.keybindings.register() Shortcut open/close
Toast ui.notifications Revert + failure only
Tooltip data-tooltip attribute All tooltips (no title="")
User flags game.user.setFlag() GM state persistence
Native dialog <dialog> HTML element Owned by overlay layer

Custom components required: AVTileAdapter · ScryingPoolController · StateRing · ParticipantAvatar · ScryingPoolStrip · StripOverlayLayer · ActionPopover · VisibilityBadge · FirstEncounterPanel · VisibilityDetailsPanel · ConfirmationBar · ParticipantCard · DirectorBoard


6.2 Shared Service: ScryingPoolController

Purpose: Single source of truth for visibility state, socket ops, optimistic updates, and revision tracking. ScryingPoolStrip and DirectorBoard are dumb views that subscribe — neither holds its own derived cache or socket handling.

Responsibilities:

  • Owns visibilityMatrix: Map<participantId, Map<viewerId, VisibilityState>>
  • Owns pendingOps: Map<participantId, { opId, targetState, baseRevision }>
  • Implements action(source, participantId, targetState, opId, baseRevision)
  • Implements latest-revision-wins guard and per-participant last-intent guard
  • Emits change events; Strip and Board listen and rerender
  • Manages retry policy and revert notifications

Module-singleton created at Hooks.once("ready"). Strip + Board call ScryingPoolController.getInstance().


6.3 Adapter: AVTileAdapter

Purpose: Isolates all Foundry AV tile DOM interaction. VisibilityBadge never touches Foundry internals directly.

Responsibilities:

  • mount(userId, badgeElement) — idempotent; finds tile via stable selector; no-op + console.warn if not found (fail-open)
  • unmount(userId) — removes badge if present; no-op if already gone
  • onTileRerender(userId, callback) — scoped MutationObserver; reattaches badge on DOM changes; disconnect()ed on module teardown

Adapter + stable selectors + idempotent mount/unmount = isolated breakage surface when Foundry core updates AV tile DOM.


6.4 StateRing (CSS primitive)

Purpose: Shared ring rendering. Single implementation — no ad-hoc ring CSS anywhere else.

.sp-state-ring--solid   { box-shadow: 0 0 0 2px var(--sp-state-color); }
.sp-state-ring--dashed  { outline: 2px dashed var(--sp-state-color); outline-offset: 2px; }
.sp-state-ring--pending { box-shadow: 0 0 0 2px var(--sp-state-color); animation: none; }
.sp-state-ring--revert  { box-shadow: 0 0 0 2px var(--sp-urgency-director); }

@media (prefers-reduced-motion: no-preference) {
  .sp-state-ring--pending { animation: sp-pulse 2s ease-in-out infinite; }
  @keyframes sp-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
}

Default: static. Animation enabled only in no-preference — CSS MQ5 §12.1 confirmed correct baseline.


6.5 ParticipantAvatar

Anatomy: 44px×44px — 32px rounded avatar + StateRing + 12px corner badge (bottom-right)

States:

State Ring class Corner badge Token
active solid --sp-state-active
hidden dashed eye-slash --sp-state-hidden
self-muted solid mic-slash --sp-state-muted
offline solid wifi-off --sp-state-offline
cam-lost solid camera-slash --sp-state-cam-lost
reconnecting solid pulsing spinner --sp-state-reconnecting
never-connected dashed grey --sp-state-never-connected
ghost none ghost --sp-state-ghost
pending solid pulsing current state colour
revert solid amber flash --sp-urgency-director

Interaction: click → StripOverlayLayer.openPopover(participantId, anchorEl). Right-click → ContextMenu.

Accessibility: role="button", aria-label="[Name] — [state label]", aria-pressed when popover open.


6.6 ScryingPoolStrip

Anatomy:

ScryingPoolStrip (ApplicationV2)
  ├── titlebar: collapsed = 🔮 + ▶ (entire titlebar = toggle)
  │             expanded = 🔮 + label + close + "?"
  ├── participant-list (role="list")
  │     └── ParticipantAvatar × N (collapsed) | ParticipantRow × N (expanded)
  ├── StripOverlayLayer (owns ActionPopover + ConfirmationBar)
  └── footer: "Open Director's Board ↗" (expanded only)

Width toggle: .is-expanded + max-width transition — never width animation.

Keyboard close: this.element.querySelectorAll('dialog[open]') — scoped to strip element, not document.

Position persistence: GM User flag { left, top, open, expanded }. "Reset position" in titlebar right-click.

Accessibility: role="complementary", aria-label="Scrying Pool", shortcut via game.keybindings.register() restricted: true.


6.7 StripOverlayLayer

Purpose: Single overlay container owned by ScryingPoolStrip. All positioned overlays render here — eliminates per-avatar dialog clipping by lifting positioning above the constrained strip scroll container.

  • position: absolute; inset: 0; pointer-events: none; overflow: visible
  • Children restore pointer-events: auto
  • ActionPopover anchors via getBoundingClientRect() relative to strip

One-at-a-time popover mechanism:

_openPopover(participantId, anchorEl) {
  if (this.#openPopover) this.#openPopover.close("superseded");
  this.#openPopover = this._overlayLayer.showPopover(participantId, anchorEl);
  this.#openPopover.addEventListener("close", () => {
    if (this.#openPopover?.returnValue !== "superseded") this.#openPopover = null;
  }, { once: true });
}

6.8 ActionPopover

Anatomy:

ActionPopover (<dialog>, absolute, anchored via StripOverlayLayer)
  ├── header: participant name (h3) + state label
  ├── primary-cta: "Hide from table" | "Show to table" [disabled during pending]
  ├── secondary: "Add note…" | "Reconnect…" (state-conditional)
  └── dismiss: Esc | click-outside

Accessibility: <dialog> native role, aria-labelledby → name h3, focus → primary CTA on open, Esc closes.


6.9 VisibilityBadge

Anatomy: Mounted via AVTileAdapterStateRing (mini 16px) + state-icon + state-label (player-facing vocabulary) + click target

Token note: All tokens on :root — badge mounted outside .scrying-pool root.

localStorage scope: firstBadgeEncounter is device/browser scoped by design. Future migration path: game.user.setFlag("scrying-pool", "firstBadgeEncounter", true) if per-user persistence required.

Accessibility: role="status", aria-live="polite", aria-label="Camera visibility: [state label]".


6.10 FirstEncounterPanel

Anatomy:

FirstEncounterPanel
  ├── "Your camera visibility changed."
  ├── "Audio continues normally."
  ├── "Got it" → sets firstBadgeEncounter, clearTimeout, dismiss
  └── auto-collapse → "What changed?" chip after 10s idle

Collapse rules:

  • 10s idle timer; paused on mouseenter, :focus-within, screen-reader active
  • Cancelled on "Got it"; clearTimeout(this.#collapseTimer) also in _onClose() teardown
  • Collapse animation: max-height fold 300ms ease-out (not snap)

Variants: panel (first time) | chip (subsequent / post-10s)

Accessibility: role="dialog", aria-modal="false", not a focus trap.


6.11 VisibilityDetailsPanel

Purpose: Answers the 3-question test. Opened from VisibilityBadge click. Replaces FirstEncounterPanel when firstBadgeEncounter already set.

Anatomy:

VisibilityDetailsPanel (<dialog>)
  ├── actor: "Hidden by: [GM name]" (or "Scene preset")
  ├── action: "Your camera was hidden from the table."
  ├── audience: "Currently visible to: [list or 'no one']"
  ├── note: [optional GM note]
  ├── reassurance: "Your audio is active for all participants."
  └── close: Esc | click-outside | "Close"

States: current (live from controller) | stale ("Data may be outdated" note if controller unavailable)

Accessibility: <dialog>, aria-modal="true", focus trap, Esc closes.


6.12 ConfirmationBar

Anatomy:

ConfirmationBar (StripOverlayLayer, position: absolute; bottom: 0)
  ├── "Preset applied — N hidden, N visible" [amber if partial fail]
  ├── "Override any participant →" (dismiss only)
  └── auto-dismiss: 8s

Zero layout shift: position: absolute within overlay layer — no strip content push.

Timer cleanup: this.#dismissTimer stored; clearTimeout() on click, strip close, and _onClose() teardown.


6.13 ParticipantCard

Anatomy: 80px×100px — 48px avatar + StateRing + name label (12px, 2-line truncate) + hover: toggle-icon overlay

States: default | hover | pending | revert

Accessibility: role="listitem", aria-label="[Name] — [state label]", hover icon is keyboard-focusable role="button".


6.14 DirectorBoard

Anatomy:

DirectorBoard (ApplicationV2)
  ├── titlebar: "Director's Board" + close
  ├── grid: display:grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr))
  │     └── ParticipantCard × N
  └── footer: "Save Preset…" | "Load Preset…"

Subscribes to ScryingPoolController events — dumb view, no local state cache.


6.15 Implementation Roadmap

Phase Component Journey
1 — Core ScryingPoolController All
AVTileAdapter JY-3, JY-6
StateRing All
ParticipantAvatar JY-1, JY-2
StripOverlayLayer JY-1, JY-2
ScryingPoolStrip JY-1, JY-2
ActionPopover JY-1, JY-2
VisibilityBadge JY-3
2 — Onboarding FirstEncounterPanel JY-3, JY-6
VisibilityDetailsPanel JY-3, JY-6
ConfirmationBar JY-1
3 — Level 2 ParticipantCard JY-4
DirectorBoard JY-4
4 — Level 3 Scene preset hooks JY-7

Shared rules: All tokens from 3-tier architecture · All timer ids stored + clearTimeout() on dismiss and teardown · querySelectorAll scoped to this.element · StateRing is the single ring implementation · ScryingPoolController is module-singleton


7. UX Consistency Patterns

7.1 Action Hierarchy

Primary actions — one per surface, always visible or one click away:

Surface Primary action Label
Strip row (expanded, hover) Visibility toggle "Hide from table" / "Show to table"
ActionPopover Visibility toggle "Hide from table" / "Show to table"
DirectorBoard card hover Visibility toggle eye / eye-slash icon + data-tooltip
Right-click context menu Visibility toggle "Hide from table" / "Show to table"
Keyboard shortcut Visibility toggle on focused participant No label (chord fires directly)

Canonical label rule: "Hide from table" and "Show to table" — verbatim on every surface. Same string constant. Never "Hide camera", "Mute video", "Remove", or any synonym.

First-encounter tooltip rule: On first hover over primary CTA (firstHideTooltip unset), data-tooltip reads: "Hide this participant from other players." Sets firstHideTooltip flag. Subsequent hovers: canonical label only.

Secondary actions (ActionPopover only, state-conditional):

  • "Add note…" — always available; inline text input within popover
  • "Reconnect…" — cam-lost / offline only
  • "Spotlight" — Level 2+ only

Disabled state: Primary CTA disabled + aria-disabled="true" during pending. 50% opacity, cursor: not-allowed. Never hidden — confirms op in progress.


7.2 Feedback Patterns

Four tiers — never mixed:

Tier Surface When Example
Ambient Strip ring / badge State stable Green solid ring = active
Optimistic Pending ring (pulsing) Op in flight Amber pulse = waiting for ACK
Strip-local ConfirmationBar Preset applied "Preset applied — 4 participants hidden, 2 visible."
System ui.notifications Failure / revert "Visibility reverted — check connection."

"Settled" cue: When a pending op confirms on the player badge, the ring performs a 300ms calm return to steady-state (pulse → solid, eased). Not a toast — ambient confirmation that the change landed.

Rules:

  • No ui.notifications toast on success — ambient + settled cue are the signals
  • ConfirmationBar for preset apply — strip-local only, never system toast
  • FirstEncounterPanel / VisibilityDetailsPanel — educational, never triggered by errors
  • Revert: amber ring flash (200ms) → ui.notifications.warn() — ring fires first
  • Partial fail: ConfirmationBar amber variant only

ConfirmationBar behaviour:

  • Primary affordance: "Undo" button — single click undoes preset apply
  • Replace rule: instant — zero crossfade (no double-bar, not even 1 frame)
  • Adaptive timer: ≥2 presets in 60s → 4s dismiss; default → 8s
  • Animation: opacity transition only — never height/max-height; zero layout impact

Deferred feedback rule: If SP overlay suspended by Foundry Dialog, queue ConfirmationBar/toast and show immediately on dialog close. Silent success = doubt.

System notification copy:

Success:       suppressed
Revert:        "Visibility reverted — [name]. Check your connection."
Partial fail:  ConfirmationBar amber only
Error:         "Could not update visibility — [name]. Try again."

7.3 Overlay and Modal Patterns

Overlay Focus trap Dismiss One-at-a-time Owner
ActionPopover No Esc, click-outside, new popover Yes StripOverlayLayer
FirstEncounterPanel No (sticky-anchored) "Got it", 10s idle fold, teardown Yes VisibilityBadge
VisibilityDetailsPanel Yes Esc, click-outside, "Close" Yes VisibilityBadge
ConfirmationBar No "Undo" click, adaptive timeout, teardown Yes (instant replace) StripOverlayLayer

FirstEncounterPanel anchor: aria-modal="false" + position: sticky — keeps panel in viewport while player interacts elsewhere. Does not drift before confidence forms.

Layering rule: SP overlays suspend if ui.activeWindow is a Foundry Dialog. Ops queued — confirmation shown immediately on dialog close.

Strip close atomicity: this.element.querySelectorAll('dialog[open]') closed atomically before ApplicationV2 close().


7.4 State Communication Vocabulary

Hard partition — never cross-contaminate:

Role Vocabulary
GM Technical: hidden, cam-lost, reconnecting
Player Human-readable: "Camera unavailable", "Rejoining view"

Player-facing state label table:

Technical Player label Aria suffix
active (no label) "— camera active"
hidden "Hidden from table" "— hidden from table"
self-muted "Camera paused" "— camera paused"
offline "Not connected" "— not connected"
cam-lost "Camera unavailable" "— camera unavailable"
reconnecting "Rejoining view" "— rejoining"
never-connected "Not yet connected" "— not yet connected"
ghost "Leaving" "— leaving"
pending (inherits current) (inherits)
revert (inherits previous) (inherits)

VisibilityDetailsPanel actor field:

  • GM action → "Hidden by: [GM name]"
  • System event → "Connection issue"
  • Level 3 preset → "Scene preset: [scene name]"

VisibilityDetailsPanel audience field:

  • State ≠ hidden → "Currently visible to: [player name list]"
  • State = hidden → suppress list; show: "Hidden from other players. The game host can still see you."

"Camera status" chip: opens VisibilityDetailsPanel with current actor + audience.

Rapid state flip rule: ≥2 transitions in 3s → skip to final state; badge performs single 150ms pulse before settling (continuity acknowledgment, not invisible jump).

Notification precedence: Visibility state change wins over reconnect. Reconnect = ambient ring only when visibility op in flight on same participant.

Audio reassurance — canonical string: "Audio continues normally." Extended form "Audio continues normally for everyone." only when precision required. Never on GM surfaces.


7.5 Loading and Empty States

Strip — 0 participants: "No participants connected yet." — stable, no spinner.

Module activation: All known players show never-connected immediately (dashed grey ring). Connect transition is reflow-free.

DirectorBoard before any participant: Placeholder cards in never-connected. "Save Preset…" active immediately.

No preset on active scene: Silence is the signal.


7.6 Error Recovery Patterns

Tier 1 — Silent retry (transient, ≤2 retries): pending ring continues; user unaware.

Tier 2 — Revert + toast (all retries exhausted): amber ring flash → ui.notifications.warn("Visibility reverted — [name]. Check your connection.").

Tier 3 — Partial fail bar (batch preset): ConfirmationBar amber: "Preset partially applied — [N] participants were not updated." ACKed participants hold; failed show revert ring then return to pre-op.

Never: modal error dialogs; confirmation-required overlays for failures.


7.7 Notification Non-Overlap Rules

At most one toast + one ambient per GM event. Player events player-side only — never echo to GM.

GM hide op succeeds
  → Ambient ring + settled cue · Toast suppressed
  → Player: settled cue + FirstEncounterPanel if firstBadgeEncounter unset

GM hide op fails + reverts
  → Amber ring flash · ui.notifications.warn (GM only)
  → ConfirmationBar suppressed · FirstEncounterPanel suppressed

Scene preset applies
  → All rings update + settled cues · Toast suppressed
  → ConfirmationBar fires (strip-local, with Undo)
  → Player: settled cue + FirstEncounterPanel if changed + firstBadgeEncounter unset

Player reconnects with changed state
  → Badge ring update (ambient only if visibility op in flight)
  → One-time toast if state = hidden, no visibility op in flight (player only)
  → FirstEncounterPanel if changed + firstBadgeEncounter unset

Reconnect + GM hide simultaneous (same participant)
  → Visibility wins · Reconnect = ambient ring only, no toast

7.8 Typography and Copy Patterns

Sentence case everywhere. Active voice for actions. Passive for states. Complete sentences for reasons.

Canonical copy strings:

Surface Copy
firstHideSuccess toast "Participant hidden from table. Audio continues normally."
Reconnect toast "Your camera is hidden. Audio continues normally."
Revert inline note "Camera update is pending. Audio is unchanged."
ConfirmationBar (mixed) "Preset applied — [N] participants hidden, [N] visible."
ConfirmationBar (all hidden) "Preset applied — all [N] participants hidden."
ConfirmationBar (partial fail) "Preset partially applied — [N] participants were not updated."
Strip tip "Hover any row for actions. Affects that participant's camera only."
FirstEncounterPanel "Your camera visibility changed. Audio continues normally."
Hidden-state panel "Hidden from other players. The game host can still see you."
First-hover tooltip "Hide this participant from other players."

Legible reason rule: Every GM-initiated state change reaching a player carries: who, what changed, who is affected.