90 KiB
stepsCompleted, inputDocuments
| stepsCompleted | inputDocuments | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
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:
- GM notices a camera state that needs to change
- GM acts (right-click, keyboard shortcut, or Director's Board tap)
- State applies optimistically on the GM's client immediately; all other clients update within 500ms
- 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
-
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.
-
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.
-
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 |
Emotion–Design 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; 300–500ms fade on hide; toast delayed 1–2s 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
-
Speed is trust — Optimistic UI is an emotional design decision. A state change that feels instant communicates GM competence; lag communicates a broken system.
-
Presence is safety — The badge must never disappear. An absent badge is more alarming than a "Camera managed" badge. Consistency is the product.
-
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.
-
Transitions carry emotional weight — 300–500ms fade on hide; 1–2s toast delay after emotionally significant hide events. The how of a change matters as much as the what.
-
Design for the exhausted user — Calm authority must be achievable at hour three. Undo is always reachable. Director's Board is scannable, not readable.
-
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 (v11–v14)
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;
contextmenuon<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.notificationsAPI — 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
ApplicationV2window; position/open state persisted to GMUserflag{left, top, width, height, open}; keyboard shortcut to re-open viagame.keybindings.register()(restricted: true, singleton-guarded, registered inHooks.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; keeppendingOps: Map<opId, timeoutId>; authority client emitsackwith sameopIdafter 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
Userflag (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.notificationswithout coalescing —Map<participantId, {timer, lastState}>aboveui.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
firstGMActivationtooltip — 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
ApplicationV2listeners 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 tolocalStorage(client-local) until consent/GDPR policy is resolved.
⚠️ Open Design Decision: GDPR / Consent Policy
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:
ApplicationV2window chrome for Director's Board (persisted geometry via GMUserflag, 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.notificationsAPI 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
ApplicationV2handlers 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 (50–300KB) 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 barebutton,a,inputselectors - 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-poolCSS — 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 |
300–500ms 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 |
1–2s | 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 1–5s); 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
- Attach
MutationObserver(subtree: true, childList: true) toui.webrtc.element - Debounce by one microtask before processing
- Inject only when tile has
[data-user-id]attribute and video child present - Check for existing badge before injecting — update in place; remove-and-reinsert only if structure needs full rebuild
position: absoluterelative to tile bounding box (tile hasposition: relative);top: 0; left: 50%; transform: translateX(-50%)adapts to any tile width and AV grid position- Own only the injected
.scrying-pool__badgenode; 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 iffirstGMActivationis already set.
Interaction
- GM initiates action (primary or fast path)
- Both the action-origin surface and the compact strip update optimistically — regardless of which surface was used
- Socket message emitted with opId and revision
- Sofia's badge transitions to
pendingoptimistically 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/hidden→pending(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 (300–500ms) applies to the affected player's view of their own tile only
- Other players see the tile appear/disappear without transition animation
- Badge transitions
pending→hiddenon 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-directornever 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 180px–240px 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,ghosttokens 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); /* 300–500ms 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-labelrequired; 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-expandedclass toggle on strip root;max-width: 44px ↔ 240px+overflow: hidden— no interpolatedwidthanimation- Collapsed titlebar: icon + chevron only; entire titlebar = expand/collapse click target
- Position/open state + expanded/collapsed state → GM User flag
Popover:
<dialog>withposition: absoluteanchored to strip's DOM — not a separateApplicationV2instance- 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 viadata-tooltip+ SP tooltip component (linting rule)
Position management:
- "Reset position" option in titlebar right-click menu → snap to default corner
- Drag + resize handled by
ApplicationV2native 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: 300–500ms\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
- Convergence over branching — novice and expert paths meet at one state mutation
- Optimistic-first, graceful revert — never wait for network before showing feedback
- Latest revision wins — rapid state changes never produce split state across surfaces
- Education at the right moment — first-encounter on first meaningful event, not module load
- No toast for success (expert) — ambient strip is ground truth; one-time toast for first success only
- Keyboard paths are first-class — Jake's live broadcast depends on them
- 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.warnif not found (fail-open)unmount(userId)— removes badge if present; no-op if already goneonTileRerender(userId, callback)— scopedMutationObserver; 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 ActionPopoveranchors viagetBoundingClientRect()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 AVTileAdapter — StateRing (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-heightfold 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/offlineonly - "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.notificationstoast on success — ambient + settled cue are the signals ConfirmationBarfor preset apply — strip-local only, never system toastFirstEncounterPanel/VisibilityDetailsPanel— educational, never triggered by errors- Revert: amber ring flash (200ms) →
ui.notifications.warn()— ring fires first - Partial fail:
ConfirmationBaramber 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:
opacitytransition only — neverheight/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.