# Story 5.3: Freeform Layout for Floating Camera Windows

**Status:** ready-for-dev

**Epic:** 5 - Full AV Replacement

**Story Key:** 5-3-freeform-layout-floating-windows

**Created:** 2026-06-07

**Last Updated:** 2026-06-07

**Target Version:** v0.2.0

---

## Story Header

| Field | Value |
|-------|-------|
| **Epic** | 5 - Full AV Replacement |
| **Story ID** | 5.3 |
| **Story Key** | 5-3-freeform-layout-floating-windows |
| **Title** | Freeform Layout for Floating Camera Windows |
| **Status** | ready-for-dev |
| **Priority** | Medium |
| **Assigned Agent** | DEV |
| **Created** | 2026-06-07 |
| **Last Updated** | 2026-06-07 |

---

## Story Requirements

### User Story

**As a** GM using Scrying Pool,
**I want to** select a "Windows" layout mode where each participant's camera feed appears in its own freely draggable and resizable window,
**So that** I can arrange camera feeds anywhere on my screen, resize them independently, and close/hide participants individually.

### Acceptance Criteria

#### AC-1: Layout Selector
**Given** the module is active and GM opens the Director's Board
**When** the GM looks at the layout selector
**Then** there is a 7th layout button labeled "Windows" (icon: `fa-window-restore`)
**And** clicking it switches to freeform mode
**And** the previously active layout (e.g. vertical/horizontal/mosaic) is replaced — freeform is mutually exclusive

#### AC-2: Per-Participant Floating Windows
**Given** the GM has selected the freeform layout
**Then** each visible participant (not hidden from table) gets their own `ApplicationV2` floating window
**And** the GM's own feed is included if `showGMSelfFeed` is enabled and GM has video active
**And** each window displays the participant's webcam feed as an `<video>` element with `object-fit: cover`
**And** each window has the participant's name in the title bar

#### AC-3: Drag and Resize
**Given** there is a freeform camera window
**When** the GM drags the window by its header
**Then** the window moves freely to any screen position
**And** when the GM resizes the window (via the resize handle in the bottom-right corner)
**Then** the new dimensions are applied
**And** both position and size are persisted globally (same positions on all scenes)

#### AC-4: Position Persistence
**Given** the GM has arranged freeform windows at certain positions and sizes
**When** the page is reloaded or the GM re-enters freeform mode
**Then** all windows restore to their last saved positions and sizes

#### AC-5: Cascade for New Participants
**Given** a new participant appears (first time or no saved position)
**Then** their window appears at the top-left of the screen with a cascading offset (50,50 + 30px each step)
**And** the cascade wraps around after ~300px offset so windows don't go off-screen

#### AC-6: Volume Control
**Given** a freeform camera window is open
**Then** there is a volume slider in the window footer (range input, 0 to 1, step 0.05)
**And** moving the slider changes the volume of that window's video element
**And** volume is not persisted (resets to 100% on reload)

#### AC-7: Window Controls
**Given** a freeform camera window is open
**Then** the window title bar has two control buttons: "Spotlight" (star icon) and "Hide" (eye-slash icon)
**And** clicking "Spotlight" toggles a visual glow effect (golden box-shadow/outline) on that window
**And** clicking "Hide" hides that participant from the table (same as hiding from the strip or Directors Board)
**And** clicking the window's close (X) button also hides that participant

#### AC-8: Visual Spotlight (Glow Effect)
**Given** a participant is spotlighted in freeform mode
**Then** their window shows a glowing golden border/outline
**And** all other windows remain unchanged (no resize, no hiding)
**And** clicking the spotlight button again removes the glow
**And** only one participant can be spotlighted at a time (switching moves the glow)

#### AC-9: Mode Switching
**Given** the GM is in freeform mode
**When** the GM selects another layout (vertical/horizontal/mosaic)
**Then** all freeform windows are closed
**And** the strip layout opens/re-renders normally

**Given** the GM is in strip mode (not freeform)
**When** the GM selects the freeform layout
**Then** the strip is closed (not rendered)
**And** freeform windows are created for all visible participants
**And** saved positions are restored

#### AC-10: Sync with Visibility Changes
**Given** a participant is hidden (via Directors Board or strip) while in freeform mode
**Then** their freeform window closes
**And** when they are shown again, their window re-opens at its last saved position

**Given** a participant connects or disconnects while in freeform mode
**Then** windows are created/destroyed accordingly

---

## Implementation Plan

### Files to Create

| File | Purpose |
|------|---------|
| `src/ui/gm/FreeformCameraWindow.js` | Individual ApplicationV2 window per participant |
| `src/ui/gm/FreeformLayoutManager.js` | Orchestrates window creation/destruction/sync |
| `templates/freeform-camera.hbs` | Template for each camera window |
| `styles/components/_freeform-camera.less` | Styles for floating camera windows |

### Files to Modify

| File | Change |
|------|--------|
| `module.js` | Import FreeformLayoutManager, construct in ready, add life cycle hooks |
| `src/ui/gm/DirectorsBoard.js` | Add freeform to `DOCK_LAYOUTS`, handle `set-dock-layout` |
| `templates/directors-board.hbs` | Add freeform layout button |
| `lang/en.json` | Add freeform layout i18n keys |
| `lang/fr.json` | Add freeform layout i18n keys |
| `styles/scrying-pool.less` | Import `_freeform-camera.less` |

### Data Contracts

#### World Setting: `freeformLayout`
```json
{
  "windows": {
    "userId1": { "left": 100, "top": 200, "width": 320, "height": 300 },
    "userId2": { "left": 450, "top": 200, "width": 320, "height": 300 }
  }
}
```
Not user-visible in config (`config: false`).

---

## Dev Notes

### Architecture

```
FreeformLayoutManager (adapter, controller, stateStore)
  ├── init() → registers hooks
  ├── sync() → reconciles visible users with open windows
  ├── setSpotlight(userId) → toggles visual glow
  ├── destroy() → closes all windows
  │
  └── Map<userId, FreeformCameraWindow>
        └── FreeformCameraWindow ({userId, adapter, manager, position})
              ├── ApplicationV2 window with resizable: true
              ├── _onRender() → _attachVideo()
              ├── _onPosition() → manager._scheduleSave()
              ├── _onClickWindowControl() → spotlight/hide actions
              ├── _onClose() → _detachVideo(), hide participant
              └── Volume slider → videoElement.volume (session only)
```

### Key Decisions (from user discussions)

| Decision | Choice |
|----------|--------|
| Volume persistence | Session only (default 1.0) |
| GM self-feed | Included if `showGMSelfFeed` + GM has video |
| Spotlight behavior | Visual glow only — no resize/hide of others |
| Default position | Top-left cascade: (50,50) + 30px per window, wrap at ~300px |
| Close button | Hides participant from table |
| Hide button | Hidden via controller.action |

### ApplicationV2 Patterns

- **Window controls** in `window.controls` array → override `_onClickWindowControl(event)`
- **Position tracking** → override `_onPosition(position)` → calls manager's save
- **Fallback base class** for test env → follow same pattern as ScryingPoolStrip
- **Constructor side-effect-free** → no render/init in constructor
- **Template** uses Handlebars `localize` helper for i18n

### Stream Access

- Get stream: `adapter.webrtc.getMediaStreamForUser(userId)`
- Create video: `document.createElement('video')` → `video.srcObject = stream`
- Autoplay + playsInline + mute (mute only for current user)
- Cleanup: `video.pause()`, `video.srcObject = null`, `video.remove()`
