Add re-order, spotlight/focus, and auto-position-snapshots features
- HTML5 drag-and-drop reordering of strip participants (per-GM flag) - Shift+click toggles spotlight focus on a participant (gold ring indicator) - Escape exits focus mode - Auto-save strip position on drag end + every 30s with viewport validation - Reset strip position button in Director's Board - French locale strings for reset button
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: 'Strip: Reorder, Spotlight & Auto-Snapshots'
|
||||
type: 'feature'
|
||||
created: '2026-05-27'
|
||||
status: 'done'
|
||||
baseline_commit: '816b7951fb88353b43c66e7b9f898701ea65ad2b'
|
||||
context: []
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem:** Strip participants have a fixed order, no way to focus a single participant, and position is only saved on close.
|
||||
|
||||
**Approach:** Add HTML5 drag-and-drop reordering (per-GM flag), Shift+click spotlight (in-memory, one-tile focus mode), and periodic auto-save of strip position (debounced + 30s interval with viewport validation).
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always:**
|
||||
- Re-order: persists to `game.user.setFlag('scrying-pool', 'participantOrder', string[])`, per-GM only
|
||||
- Spotlight: in-memory `_focusedUserId` only, no socket/persistence
|
||||
- Auto-snapshots: extends existing `stripState` flag (`left, top, width, height, savedAt`), backward-compatible
|
||||
- All three features are strip-only (not Director's Board)
|
||||
- French locale strings in `scrying-pool.lang.fr.json`
|
||||
|
||||
**Ask First:** None
|
||||
|
||||
**Never:**
|
||||
- No external drag-and-drop libraries
|
||||
- No socket broadcasts for re-order or spotlight
|
||||
- No CSS animation framework changes
|
||||
|
||||
## I/O & Edge-Case Matrix
|
||||
|
||||
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||
|----------|--------------|---------------------------|----------------|
|
||||
| Re-order: drag participant | Drag start on tile | Tile shows `opacity: 0.3` during drag | N/A |
|
||||
| Re-order: drop between tiles | Drop at new position | Participant order updates, flag saved, strip re-renders | Save failure silently caught |
|
||||
| Re-order: double-click grip | Double-click grip area | Order resets to connection order | N/A |
|
||||
| Spotlight: Shift+click | Shift+click participant | Only that participant visible, others `display:none` in DOM, strip resized | N/A |
|
||||
| Spotlight: exit via Escape | Press Escape | Full list restored, `_focusedUserId` cleared | N/A |
|
||||
| Spotlight: exit via button | Click exit-focus button (replaces DB icon) | Same as Escape | N/A |
|
||||
| Auto-snapshot: drag ends | mouseup after drag | Debounced save to `stripState` flag | Save failure silently caught |
|
||||
| Auto-snapshot: 30s timer | Interval fires | Save current position to `stripState` | Save failure silently caught |
|
||||
| Auto-snapshot: position off-screen | Saved position outside viewport | Fall back to default position | Silent fallback, no error |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
- `src/ui/gm/ScryingPoolStrip.js` — All three features: drag handlers, spotlight state, auto-save timer
|
||||
- `styles/components/_roster-strip.less` — Drag feedback (`opacity: 0.3`), spotlight visual state
|
||||
- `templates/directors-board.hbs` — Reset strip position button
|
||||
- `src/ui/gm/DirectorsBoard.js` — Reset position handler
|
||||
- `module.js` — (no changes needed, instantiates the class)
|
||||
- `scrying-pool.lang.fr.json` — French locale strings
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Execution:**
|
||||
- [x] `src/ui/gm/ScryingPoolStrip.js` — Add `_focusedUserId`, drag handlers, _savePosition, re-order in _prepareContext
|
||||
- [x] `styles/components/_roster-strip.less` — `.sp-state-focused` gold ring, drag ghost opacity
|
||||
- [x] `templates/directors-board.hbs` — Reset strip position button
|
||||
- [x] `src/ui/gm/DirectorsBoard.js` — `_onResetStripPosition()` handler
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Given a strip with 3+ participants, when GM drags a participant tile to a new position, then the participant order updates and persists across re-renders
|
||||
- Given any layout, when GM Shift+clicks a participant, then other participants collapse and the focused tile shows gold state ring
|
||||
- Given spotlight mode is active, when GM presses Escape, then all participants return to normal
|
||||
- Given the strip is open, when GM drags the strip to a new position and releases, then the position is saved
|
||||
- Given a saved off-screen position, when the strip re-renders, then it appears at default coordinates
|
||||
|
||||
## Suggested Review Order
|
||||
|
||||
**Re-order + Spotlight core logic**
|
||||
|
||||
- Entry point: participant filtering, DnD handlers, focus toggle, position save
|
||||
[`ScryingPoolStrip.js:149`](../../src/ui/gm/ScryingPoolStrip.js#L149)
|
||||
|
||||
- Drag-drop splice with `fromIdx < toIdx` adjustment + null guard on element
|
||||
[`ScryingPoolStrip.js:1013`](../../src/ui/gm/ScryingPoolStrip.js#L1013)
|
||||
|
||||
- Focus toggle toggles `_focusedUserId`, re-renders; Escape exits via document listener
|
||||
[`ScryingPoolStrip.js:1076`](../../src/ui/gm/ScryingPoolStrip.js#L1076)
|
||||
|
||||
- Auto-save: called on grip mouseup + 30s interval; cleanup on teardown
|
||||
[`ScryingPoolStrip.js:1094`](../../src/ui/gm/ScryingPoolStrip.js#L1094)
|
||||
|
||||
- Viewport-validated position restore with negative-value guard
|
||||
[`ScryingPoolStrip.js:178`](../../src/ui/gm/ScryingPoolStrip.js#L178)
|
||||
|
||||
**Template changes**
|
||||
|
||||
- `isFocused` conditional class for gold ring on focused participant
|
||||
[`roster-strip.hbs:54`](../../templates/roster-strip.hbs#L54)
|
||||
|
||||
- Reset strip position button in Director's Board footer
|
||||
[`directors-board.hbs:140`](../../templates/directors-board.hbs#L140)
|
||||
|
||||
**CSS**
|
||||
|
||||
- Gold state ring for `.sp-state-focused`, drag ghost opacity, drop target indicator
|
||||
[`_roster-strip.less:517`](../../styles/components/_roster-strip.less#L517)
|
||||
|
||||
**Director's Board**
|
||||
|
||||
- Reset position handler clears stripState flag
|
||||
[`DirectorsBoard.js:861`](../../src/ui/gm/DirectorsBoard.js#L861)
|
||||
|
||||
**Locales**
|
||||
|
||||
- Reset strip button strings (EN + FR)
|
||||
[`en.json:82`](../../lang/en.json#L82) · [`fr.json:82`](../../lang/fr.json#L82)
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands:**
|
||||
- `npm run lint` -- expected: 0 errors
|
||||
- `npm run typecheck` -- expected: 0 errors
|
||||
- `npm run test` -- expected: all tests pass
|
||||
@@ -12,7 +12,12 @@ inputDocuments:
|
||||
workflowType: 'architecture'
|
||||
project_name: 'video-view-manager'
|
||||
user_name: 'Morr'
|
||||
date: '2026-05-20'
|
||||
date: '2026-05-27'
|
||||
extendedAt: '2026-05-27'
|
||||
extendedFeatures:
|
||||
- reorder-participants
|
||||
- spotlight-focus
|
||||
- auto-position-snapshots
|
||||
---
|
||||
|
||||
# Architecture Decision Document: Video View Manager (Scrying Pool)
|
||||
@@ -1091,3 +1096,82 @@ both critical gaps found during validation were resolved inline without reopenin
|
||||
Story 0 scaffold — `module.json` + `tsconfig.json` + `vitest.config.js` + `.eslintrc.js` +
|
||||
`scripts/package.mjs` + `src/contracts/` (with validators + frozen fixtures) +
|
||||
`.gitea/workflows/ci.yml` — all Story 0 ACs green before any Story 1 code.
|
||||
|
||||
---
|
||||
|
||||
## Feature Addendum (2026-05-27): Re-order Participants
|
||||
|
||||
### Problem
|
||||
Strip participant order is fixed to user connection order. GM cannot rearrange participants to match table seating, spotlight priority, or personal preference.
|
||||
|
||||
### Decision: Drag-and-drop via native HTML5 API
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Mechanism | HTML5 Drag & Drop (`draggable`, `dragstart`/`dragover`/`drop` events) | Zero dependencies; works in Foundry's embedded Chromium; no external lib needed |
|
||||
| Persistence | User flag: `game.user.setFlag('scrying-pool', 'participantOrder', string[])` | Per-GM order; does not affect other GMs or players |
|
||||
| Storage format | Ordered array of user IDs | Minimal, sortable, forward-compatible |
|
||||
| Visual feedback | `opacity: 0.3` on dragged tile + `box-shadow` drop indicator on target gap | Lightweight; no layout shift |
|
||||
| Reset | Double-click the grip area → reset to connection order | Escape hatch if order gets confusing |
|
||||
| Scope | Strip only (not Director's Board) | Strip is the primary real-estate; DB reorder adds complexity with no clear need |
|
||||
|
||||
### Implementation Notes
|
||||
- `ScryingPoolStrip` gets `_onDragStart`, `_onDragOver`, `_onDrop`, `_onDragEnd` handlers
|
||||
- Attached in `_onRender` via `el.addEventListener`
|
||||
- On drop: reorder participant list in `_prepareContext`, persist to user flag
|
||||
- `_prepareContext` reads flag, applies order before filtering hidden participants
|
||||
- No socket broadcast — order is per-GM/local-only
|
||||
|
||||
---
|
||||
|
||||
## Feature Addendum (2026-05-27): Spotlight / Focus
|
||||
|
||||
### Problem
|
||||
In all layouts, participants share equal visual weight. GM cannot temporarily focus on one participant's video feed (e.g., a player speaking, a dramatic reveal).
|
||||
|
||||
### Decision: One-tile-expand mode within the strip
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Activation | Click on participant avatar while holding `Shift` (or context menu → "Focus") | Intentional action; avoids accidental triggers |
|
||||
| Visual | Selected tile expands to fill strip content area; other tiles collapse to `height: 0; overflow: hidden` (preserved in DOM for instant restore) | No layout reflow on restore; preserved DOM state |
|
||||
| Restore | Click `Shift+click` again, or click an "Exit focus" button that replaces the toolbar, or press `Escape` | Multiple escape hatches |
|
||||
| State | In-memory only: `_focusedUserId: string|null` on `ScryingPoolStrip` | Ephemeral — no persistence needed |
|
||||
| Indicator | Focused tile gets `.sp-state-focused` class → gold state ring (`--sp-urgency-director`) | Visual consistency with existing state ring system |
|
||||
| Strip sizing | `setPosition` recomputed with `1` participant during focus | Window snaps to single-tile dimensions |
|
||||
| Layout compatibility | Works in all layouts (vertical, horizontal, mosaic) | Tile fills available space via same CSS that handles single-participant edge case |
|
||||
| Director's Board | Unaffected — spotlight is strip-only | DB maintains overview while strip focuses |
|
||||
|
||||
### Implementation Notes
|
||||
- `_focusedUserId` field on `ScryingPoolStrip`
|
||||
- `_prepareContext` filters or transforms participant list: if `_focusedUserId` set, only that participant is visible; others have `hidden: true` (but preserved in DOM)
|
||||
- Restore clears `_focusedUserId` → re-render → full list visible
|
||||
- CSS: `.sp-participant-avatar.sp-state-focused` inherits existing state ring pattern (green → gold via `--sp-urgency-director`)
|
||||
- No socket broadcast — purely local UI state
|
||||
|
||||
---
|
||||
|
||||
## Feature Addendum (2026-05-27): Auto Position Snapshots
|
||||
|
||||
### Problem
|
||||
Strip position is saved only on close. If the browser window is resized, display changed, or the strip is accidentally dragged off-screen, there is no way to restore a known-good position without relaunching.
|
||||
|
||||
### Decision: Periodic auto-save + explicit save/restore
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Auto-save trigger | `debounced` save on `mouseup` after drag ends | Saves only when user finishes moving; no save storm during drag |
|
||||
| Auto-save interval | Also every 30s via `setInterval` while strip is open | Safety net if drag event fails to fire |
|
||||
| Storage | User flag: `game.user.setFlag('scrying-pool', 'stripState')` | Already partially used (saves on close); extend to include timestamp |
|
||||
| Save payload | `{ left, top, width, height, savedAt }` | Position + dimensions + timestamp for diagnostics |
|
||||
| Restore trigger | On `_onRender` — if saved position exists and strip has no explicit position yet | First render gets saved position |
|
||||
| Reset | Director's Board button "Reset strip position" → clears flag + re-renders at default position | Manual escape hatch |
|
||||
| Multi-monitor safety | Validate `saved.left` and `saved.top` are within available viewport (`window.screen.availWidth/Height`) before applying | Prevents strip from loading off-screen after monitor config change |
|
||||
|
||||
### Implementation Notes
|
||||
- Extend existing `_loadPosition()` in `ScryingPoolStrip` — already reads `stripState` flag
|
||||
- Add `_savePosition()` called on `mouseup` after drag + every 30s interval
|
||||
- Viewport validation: `saved.left < screen.availWidth - 50 && saved.top < screen.availHeight - 50`
|
||||
- On validation failure: silently fall back to default position (no error notification)
|
||||
- Director's Board: add "Reset strip position" button (minor template change)
|
||||
- Extends existing `stripState` flag — no new flags needed, backward-compatible with old saves
|
||||
|
||||
Reference in New Issue
Block a user