docs(05): create phase plan — 3 plans across 3 waves

This commit is contained in:
Claude
2026-04-07 09:16:17 +00:00
parent 02826b2c89
commit 6a1ec4e3b6
4 changed files with 1125 additions and 3 deletions

View File

@@ -0,0 +1,499 @@
---
phase: 05-live-show-execution
plan: 02
type: execute
wave: 2
depends_on:
- 05-01
files_modified:
- lightsync/frontend/index.html
- lightsync/frontend/style.css
- lightsync/frontend/app.js
autonomous: true
requirements:
- UI-04
- SHW-03
must_haves:
truths:
- "Live preview panel shows active animation color and type per device, updated in sync with playback"
- "Devices with no active cue show a dim inactive indicator"
- "A show can be selected from a dropdown and loaded into the timeline + server"
- "Preview panel is visible between timeline canvas and block inspector when a show is loaded"
artifacts:
- path: "lightsync/frontend/index.html"
provides: "Live preview div and show selector UI elements"
contains: "id=\"live-preview\""
- path: "lightsync/frontend/style.css"
provides: "Terminal-aesthetic styles for preview panel and device strips"
contains: ".live-preview"
- path: "lightsync/frontend/app.js"
provides: "preview_update WebSocket handler, show selector logic, preview strip builder"
contains: "preview_update"
key_links:
- from: "lightsync/frontend/app.js"
to: "lightsync/api/ws.py"
via: "WebSocket onmessage handler for preview_update messages"
pattern: "msg\\.type === .preview_update"
- from: "lightsync/frontend/app.js"
to: "/api/shows"
via: "fetch to list and load shows"
pattern: "fetch.*api/shows"
- from: "lightsync/frontend/app.js"
to: "lightsync/frontend/timeline/commands.js"
via: "setCurrentShowId() call on show load"
pattern: "setCurrentShowId"
---
<objective>
Live preview panel and show selector: add the browser-side UI for live show execution feedback and show management.
Purpose: Implements UI-04 (live preview panel) and the frontend half of SHW-03 (show load into timeline + server). The preview panel shows which animation is active on each device during playback. The show selector lets users load saved shows into the timeline editor and connect them to the server-side cue scheduler.
Output: Modified `index.html` (new DOM elements), `style.css` (preview panel styles), and `app.js` (preview handler, show selector, preview strip builder).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-live-show-execution/05-CONTEXT.md
@.planning/phases/05-live-show-execution/05-RESEARCH.md
@.planning/phases/05-live-show-execution/05-UI-SPEC.md
@.planning/phases/05-live-show-execution/05-01-SUMMARY.md
<interfaces>
<!-- Existing CSS variables from style.css -->
```css
--bg-primary: #0a0a0a;
--bg-panel: #0f0f0f;
--bg-panel-dark: #080808;
--border-dim: #1a4a4a;
--accent: #00ffff;
--text-primary: #cccccc;
--text-dim: #555555;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
```
<!-- WebSocket preview_update message format (from Plan 01) -->
```javascript
// Active cue:
{ type: "preview_update", device_id: "uuid", animation: "chase", color: [0, 255, 180] }
// Inactive (no cue):
{ type: "preview_update", device_id: "uuid", animation: null, color: null }
```
<!-- Existing show API -->
```
GET /api/shows -> [{ id, name, created_at }]
GET /api/shows/{id} -> ShowModel (full, including tracks + devices)
```
<!-- Existing app.js exports/globals -->
```javascript
// client.send(msg) — WebSocket send
// timeline — TimelineCanvas instance (global in app.js)
// timeline.tracks — [{device_id, device_name, strip_type, cues: []}]
// timeline.showId — current show UUID or null
// timeline.loadTracks(devices) — called with device array from /api/devices
```
<!-- From timeline/commands.js -->
```javascript
import { setCurrentShowId } from './timeline/commands.js';
// Already imported in app.js: DeleteBlockCommand
// Need to also import: setCurrentShowId
```
<!-- Existing HTML structure for transport bar selectors -->
```html
<select id="audio-select" class="transport-file-select">...</select>
<button id="btn-load" class="transport-btn">LOAD</button>
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add preview panel HTML and show selector to index.html, add CSS styles</name>
<files>lightsync/frontend/index.html, lightsync/frontend/style.css</files>
<read_first>
- lightsync/frontend/index.html
- lightsync/frontend/style.css
- .planning/phases/05-live-show-execution/05-UI-SPEC.md
</read_first>
<action>
**index.html changes:**
1. Add the live preview panel div between `<canvas id="timeline-canvas">` and `<div id="block-inspector">` inside `<main class="main-area">`:
```html
<canvas id="timeline-canvas"></canvas>
<div id="live-preview" class="live-preview" style="display:none;"></div>
<div id="block-inspector" class="block-inspector" style="display:none;"></div>
```
The `#live-preview` div starts hidden (`display:none` inline). JS sets `display:flex` when a show loads.
Content (device strips) is populated dynamically by JS — the div starts empty.
2. Add show selector controls to the transport bar `<footer class="transport-bar">`. Insert AFTER the existing `<div class="transport-sep"></div>` and BEFORE the upload label. Add a new separator + show select + load button:
```html
<div class="transport-sep"></div>
<select id="show-select" class="transport-file-select">
<option value="">&#8212; no shows &#8212;</option>
</select>
<button id="btn-load-show" class="transport-btn" title="Load saved show">LOAD</button>
<div class="transport-sep"></div>
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG" style="cursor:pointer;">
+ UPLOAD
...
```
3. Add a keyboard hint label at the far right of the transport bar, after `<button id="btn-load">`:
```html
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
<div style="flex:1"></div>
<span class="keyboard-hint">[SPACE] PLAY [&#8592;&#8594;] SEEK [^Z] UNDO [^S] SAVE</span>
```
**style.css changes:**
Append these styles to the end of `lightsync/frontend/style.css`:
```css
/* ── Live Preview Panel (Phase 5) ──────────────────────────────────────────── */
.live-preview {
border-top: 1px solid var(--border-dim);
border-bottom: 1px solid var(--border-dim);
background: var(--bg-panel-dark);
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 0;
max-height: 60px;
overflow-y: auto;
flex-shrink: 0;
}
.device-strip {
display: flex;
align-items: center;
height: 18px;
padding: 0 4px;
background: var(--bg-panel);
gap: 8px;
font-size: 12px;
font-family: var(--font-mono);
letter-spacing: 0.05em;
transition: background-color 0.1s;
}
.strip-label {
color: var(--text-dim);
text-transform: uppercase;
min-width: 80px;
flex-shrink: 0;
font-size: 10px;
letter-spacing: 0.12em;
}
.strip-color {
width: 40px;
height: 10px;
background: var(--text-dim);
flex-shrink: 0;
transition: background-color 0.15s;
}
.strip-anim {
color: var(--text-dim);
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.device-strip.active .strip-label {
color: var(--text-primary);
}
.device-strip.active .strip-anim {
color: var(--text-primary);
}
.keyboard-hint {
color: var(--text-dim);
font-size: 10px;
font-family: var(--font-mono);
letter-spacing: 0.05em;
white-space: nowrap;
flex-shrink: 0;
}
```
Do NOT use `--accent` for preview strip colors — the strip color is data-driven from cue params (per UI-SPEC Color section). Do NOT add rounded corners, shadows, or gradients — terminal aesthetic is flat and sharp (per Phase 1 CONTEXT.md).
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
checks = {}
with open('lightsync/frontend/index.html') as f:
html = f.read()
checks['live-preview div'] = 'id=\"live-preview\"' in html
checks['show-select'] = 'id=\"show-select\"' in html
checks['btn-load-show'] = 'id=\"btn-load-show\"' in html
checks['keyboard-hint'] = 'keyboard-hint' in html
with open('lightsync/frontend/style.css') as f:
css = f.read()
checks['live-preview class'] = '.live-preview' in css
checks['device-strip class'] = '.device-strip' in css
checks['strip-label class'] = '.strip-label' in css
checks['strip-color class'] = '.strip-color' in css
checks['strip-anim class'] = '.strip-anim' in css
checks['max-height 60px'] = 'max-height: 60px' in css
checks['keyboard-hint style'] = '.keyboard-hint' in css
for name, ok in checks.items():
print(f'{name}: {\"PASS\" if ok else \"FAIL\"}')
import sys; sys.exit(0 if all(checks.values()) else 1)
"</automated>
</verify>
<acceptance_criteria>
- index.html contains `<div id="live-preview" class="live-preview"` between timeline-canvas and block-inspector
- index.html contains `<select id="show-select" class="transport-file-select">`
- index.html contains `<button id="btn-load-show" class="transport-btn">`
- index.html contains `class="keyboard-hint"` span in transport bar
- style.css contains `.live-preview` with `max-height: 60px` and `flex-shrink: 0`
- style.css contains `.device-strip` with `height: 18px`
- style.css contains `.strip-label` with `text-transform: uppercase` and `font-size: 10px`
- style.css contains `.strip-color` with `width: 40px` and `height: 10px` and `transition: background-color 0.15s`
- style.css contains `.device-strip.active .strip-label` with `color: var(--text-primary)`
- No `border-radius`, `box-shadow`, or `linear-gradient` in new CSS rules
</acceptance_criteria>
<done>Live preview panel DOM structure exists in index.html. Show selector dropdown + LOAD button exist in transport bar. Keyboard hint visible. All CSS styles follow terminal aesthetic with correct spacing and typography from UI-SPEC.</done>
</task>
<task type="auto">
<name>Task 2: Wire preview_update handler, show selector logic, and preview strip builder in app.js</name>
<files>lightsync/frontend/app.js</files>
<read_first>
- lightsync/frontend/app.js
- lightsync/frontend/timeline/timeline.js (first 50 lines — constructor, tracks, showId, loadTracks)
- lightsync/frontend/timeline/commands.js (first 20 lines — setCurrentShowId export)
- lightsync/api/shows.py (GET /api/shows response format)
</read_first>
<action>
Modify `lightsync/frontend/app.js`:
**1. Update import statement** (line 3) to also import `setCurrentShowId`:
```javascript
import { DeleteBlockCommand, setCurrentShowId } from './timeline/commands.js';
```
**2. Add preview_update handler in `LightSyncClient.ws.onmessage`** (currently only handles `beat`). Extend the handler:
```javascript
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'beat') flashBeat();
else if (msg.type === 'preview_update') updatePreviewStrip(msg.device_id, msg.animation, msg.color);
};
```
**3. Add the `updatePreviewStrip` function** (after `flashBeat`):
```javascript
function updatePreviewStrip(deviceId, animation, color) {
const strip = document.querySelector(`.device-strip[data-device-id="${deviceId}"]`);
if (!strip) return;
const colorEl = strip.querySelector('.strip-color');
const animEl = strip.querySelector('.strip-anim');
if (animation && color) {
const [r, g, b] = color;
colorEl.style.backgroundColor = `rgb(${r},${g},${b})`;
animEl.textContent = animation.toUpperCase().replace('_', ' ');
strip.classList.add('active');
} else {
colorEl.style.backgroundColor = ''; // revert to CSS default (--text-dim)
animEl.textContent = '\u2014'; // em dash
strip.classList.remove('active');
}
}
```
**4. Add `buildPreviewStrips` function** that populates the `#live-preview` div from the device list. Call this AFTER devices load (inside the existing `fetch('/api/devices').then(...)` in `initTimeline`):
```javascript
function buildPreviewStrips(devices) {
const panel = document.getElementById('live-preview');
if (!panel) return;
panel.innerHTML = devices.map(d => `
<div class="device-strip" data-device-id="${d.id}">
<span class="strip-label">${d.name}</span>
<div class="strip-color"></div>
<span class="strip-anim">\u2014</span>
</div>
`).join('');
}
```
**5. Call `buildPreviewStrips` inside `initTimeline`**, in the existing device fetch callback (around line 285-288):
```javascript
fetch('/api/devices').then(r => r.json()).then(devices => {
timeline.loadTracks(devices);
inspector.tracks = timeline.tracks;
buildPreviewStrips(devices); // NEW — populate preview strips
}).catch(e => console.warn('[timeline]', e));
```
**6. Add show selector logic.** After the `initTimeline()` call at the bottom, add:
```javascript
// ── Show Selector ───────────────────────────────────────────────────────────
async function refreshShows() {
const sel = document.getElementById('show-select');
if (!sel) return;
try {
const shows = await (await fetch('/api/shows')).json();
sel.innerHTML = shows.length
? '<option value="">\u2014 select show \u2014</option>'
: '<option value="">\u2014 no shows \u2014</option>';
shows.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
sel.appendChild(opt);
});
} catch (e) { console.error('[shows]', e); }
}
refreshShows();
document.getElementById('btn-load-show')?.addEventListener('click', async () => {
const sel = document.getElementById('show-select');
const showId = sel?.value;
if (!showId) return;
const btn = document.getElementById('btn-load-show');
btn.textContent = '...';
try {
const show = await (await fetch(`/api/shows/${showId}`)).json();
// Populate timeline tracks with saved cues
if (timeline && show.tracks) {
for (const showTrack of show.tracks) {
const existing = timeline.tracks.find(
t => String(t.device_id) === String(showTrack.device_id)
);
if (existing) {
existing.cues = (showTrack.cues || []).map(c => ({...c}));
} else {
console.warn('[show] track for unknown device', showTrack.device_id);
}
}
}
// Set current show ID for auto-save
if (timeline) timeline.showId = showId;
setCurrentShowId(showId);
// Load audio if show has audio path
if (show.audio?.path) {
const filename = show.audio.path.split('/').pop();
const audio = document.getElementById('player');
if (audio && filename) {
audio.src = `/shows/${filename}`;
audio.load();
}
}
// Tell server to load show cue list for scheduling
client.send({ type: 'load', show_id: showId, path: show.audio?.path || '' });
// Show preview panel
const preview = document.getElementById('live-preview');
if (preview) preview.style.display = 'flex';
console.log('[show] loaded', show.name, 'with', show.tracks?.length || 0, 'tracks');
} catch (e) {
console.error('[show] failed to load', showId, e);
} finally {
btn.textContent = 'LOAD';
}
});
```
**7. Clear preview strips on seek** — add a seek handler that dims all strips. In the existing `mouseup` handler on seek bar (around line 141-147), after `client.send({ type: 'seek', ... })`, add:
```javascript
// Clear preview strips on seek (strips re-light when next cue fires)
document.querySelectorAll('.device-strip').forEach(s => {
s.classList.remove('active');
s.querySelector('.strip-color').style.backgroundColor = '';
s.querySelector('.strip-anim').textContent = '\u2014';
});
```
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
with open('lightsync/frontend/app.js') as f:
js = f.read()
checks = {
'setCurrentShowId import': 'setCurrentShowId' in js and 'import' in js.split('setCurrentShowId')[0][-100:],
'preview_update handler': 'preview_update' in js,
'updatePreviewStrip fn': 'function updatePreviewStrip' in js,
'buildPreviewStrips fn': 'function buildPreviewStrips' in js,
'refreshShows fn': 'async function refreshShows' in js,
'btn-load-show listener': 'btn-load-show' in js,
'show_id in load msg': \"show_id: showId\" in js or \"show_id:\" in js,
'setCurrentShowId call': 'setCurrentShowId(showId)' in js,
'preview display flex': \"display = 'flex'\" in js or 'display = \"flex\"' in js,
}
for name, ok in checks.items():
print(f'{name}: {\"PASS\" if ok else \"FAIL\"}')
import sys; sys.exit(0 if all(checks.values()) else 1)
"</automated>
</verify>
<acceptance_criteria>
- app.js imports `setCurrentShowId` from `./timeline/commands.js`
- app.js ws.onmessage handles `preview_update` message type
- app.js contains `function updatePreviewStrip(deviceId, animation, color)` that updates `.device-strip` DOM
- app.js contains `function buildPreviewStrips(devices)` that creates `div.device-strip[data-device-id]` elements
- app.js calls `buildPreviewStrips(devices)` inside the existing device fetch callback in `initTimeline`
- app.js contains `async function refreshShows()` that fetches `/api/shows` and populates `#show-select`
- app.js `btn-load-show` click handler fetches show, populates timeline tracks, calls `setCurrentShowId(showId)`, sends `{ type: 'load', show_id: showId }` via WebSocket
- app.js sets `#live-preview` `display` to `flex` on show load
- app.js clears preview strips (removes `.active` class) on seek
</acceptance_criteria>
<done>Preview panel updates live during playback via WebSocket preview_update messages. Shows can be selected and loaded into the timeline + server. Preview strips are built from device list. Strips clear on seek and re-activate when cues fire.</done>
</task>
</tasks>
<verification>
1. `grep -c 'preview_update' lightsync/frontend/app.js` returns at least 2
2. `grep -c 'buildPreviewStrips' lightsync/frontend/app.js` returns at least 2 (definition + call)
3. `grep 'live-preview' lightsync/frontend/index.html` finds the div
4. `grep 'show-select' lightsync/frontend/index.html` finds the select element
5. `grep '.device-strip' lightsync/frontend/style.css` finds style rules
6. `grep 'setCurrentShowId' lightsync/frontend/app.js` finds import and call
</verification>
<success_criteria>
- Live preview panel renders one strip per device, colored by active animation's params.color
- Inactive devices show dim state (--text-dim background, em dash text)
- Show selector dropdown lists saved shows from /api/shows
- Loading a show populates timeline tracks, sets showId, sends WebSocket load message with show_id
- Preview panel becomes visible (display:flex) when a show loads
- Preview strips clear on seek and re-activate when cues fire
- All new CSS follows terminal aesthetic (flat, sharp, monospace, no rounded corners)
</success_criteria>
<output>
After completion, create `.planning/phases/05-live-show-execution/05-02-SUMMARY.md`
</output>