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:
2026-05-27 11:44:24 +02:00
parent 816b7951fb
commit 9e80c2c028
44 changed files with 1770 additions and 11 deletions
@@ -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