1585 lines
90 KiB
Markdown
1585 lines
90 KiB
Markdown
---
|
||
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||
inputDocuments:
|
||
- _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 |
|
||
|
||
### 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
|
||
|
||
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** — 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*.
|
||
|
||
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 (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; `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 coalescing** — `Map<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.**
|
||
|
||
---
|
||
|
||
### ⚠️ 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:**
|
||
- `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 (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 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` | 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
|
||
|
||
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/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` → `hidden` 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
|
||
|
||
```css
|
||
/* 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
|
||
|
||
```css
|
||
/* 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 `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`, `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:**
|
||
|
||
```css
|
||
.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-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.*
|
||
|
||
```mermaid
|
||
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.*
|
||
|
||
```mermaid
|
||
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.*
|
||
|
||
```mermaid
|
||
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.*
|
||
|
||
```mermaid
|
||
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.*
|
||
|
||
```mermaid
|
||
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.*
|
||
|
||
```mermaid
|
||
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.*
|
||
|
||
```mermaid
|
||
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.
|
||
|
||
```css
|
||
.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:**
|
||
```js
|
||
_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-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.
|