---
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
