19 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-live-show-execution | 02 | execute | 2 |
|
|
true |
|
|
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).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```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; ```// 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 }
GET /api/shows -> [{ id, name, created_at }]
GET /api/shows/{id} -> ShowModel (full, including tracks + devices)
// 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
import { setCurrentShowId } from './timeline/commands.js';
// Already imported in app.js: DeleteBlockCommand
// Need to also import: setCurrentShowId
<select id="audio-select" class="transport-file-select">...</select>
<button id="btn-load" class="transport-btn">LOAD</button>
- Add the live preview panel div between
<canvas id="timeline-canvas">and<div id="block-inspector">inside<main class="main-area">:
<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.
- 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:
<div class="transport-sep"></div>
<select id="show-select" class="transport-file-select">
<option value="">— no shows —</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
...
- Add a keyboard hint label at the far right of the transport bar, after
<button id="btn-load">:
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
<div style="flex:1"></div>
<span class="keyboard-hint">[SPACE] PLAY [←→] SEEK [^Z] UNDO [^S] SAVE</span>
style.css changes:
Append these styles to the end of lightsync/frontend/style.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).
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)
"
<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>
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.
1. Update import statement (line 3) to also import setCurrentShowId:
import { DeleteBlockCommand, setCurrentShowId } from './timeline/commands.js';
2. Add preview_update handler in LightSyncClient.ws.onmessage (currently only handles beat). Extend the handler:
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):
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):
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):
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:
// ── 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:
// 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';
});
<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>