diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 9e65db4..14401b7 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -87,7 +87,7 @@ Plans:
4. Each block has a color picker and editable parameters (speed, colors, direction, length); changes persist in the show file
5. The playback cursor moves in real time across the timeline as audio plays, reflecting audio position accurately
6. A beat calibration offset control shifts all beat marks backward by a configurable amount to compensate for librosa's systematic latency bias
-**Plans**: 3 plans
+**Plans**: 4 plans
Plans:
- [ ] 04-01: Timeline canvas — per-device tracks, time axis, block rendering, playback cursor
diff --git a/.planning/phases/04-timeline-editor/04-01-PLAN.md b/.planning/phases/04-timeline-editor/04-01-PLAN.md
new file mode 100644
index 0000000..174b370
--- /dev/null
+++ b/.planning/phases/04-timeline-editor/04-01-PLAN.md
@@ -0,0 +1,556 @@
+---
+phase: 04-timeline-editor
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - lightsync/models/show.py
+ - lightsync/frontend/index.html
+ - lightsync/frontend/style.css
+ - lightsync/frontend/timeline/timeline.js
+autonomous: true
+requirements: [TL-01, TL-05]
+
+must_haves:
+ truths:
+ - "Per-device horizontal tracks render on a time axis with device name labels"
+ - "Playback cursor moves across the timeline in realtime as audio plays"
+ - "Waveform peaks render as a background layer behind tracks"
+ - "CueModel has a duration field that defaults to 4.0"
+ artifacts:
+ - path: "lightsync/models/show.py"
+ provides: "CueModel with duration field, AnalysisBlock with calibration_offset"
+ contains: "duration: float = 4.0"
+ - path: "lightsync/frontend/timeline/timeline.js"
+ provides: "TimelineCanvas class with rendering loop"
+ exports: ["TimelineCanvas"]
+ min_lines: 150
+ - path: "lightsync/frontend/index.html"
+ provides: "Timeline canvas element, toolbar, inspector placeholder"
+ contains: "timeline-canvas"
+ - path: "lightsync/frontend/style.css"
+ provides: "Timeline layout styles, toolbar, inspector strip"
+ contains: "#timeline-canvas"
+ key_links:
+ - from: "lightsync/frontend/timeline/timeline.js"
+ to: "HTML5 audio element"
+ via: "requestAnimationFrame reads audio.currentTime for cursor position"
+ pattern: "audio\\.currentTime"
+ - from: "lightsync/frontend/timeline/timeline.js"
+ to: "/api/audio/waveform"
+ via: "fetch waveform peaks on audio load"
+ pattern: "api/audio/waveform"
+ - from: "lightsync/frontend/timeline/timeline.js"
+ to: "/api/devices"
+ via: "fetch device list to build tracks"
+ pattern: "api/devices"
+---
+
+
+Build the timeline canvas foundation — data model changes, HTML layout restructure, canvas rendering with per-device tracks, time axis, waveform background, and realtime playback cursor.
+
+Purpose: This is the visual backbone of the DAW UI. Everything else (block interactions, beat overlay, inspector) builds on top of this canvas and coordinate system.
+Output: A visible, scrollable timeline with device tracks and a moving playback cursor synced to the HTML5 audio element.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/04-timeline-editor/04-CONTEXT.md
+@.planning/phases/04-timeline-editor/04-RESEARCH.md
+@.planning/phases/04-timeline-editor/04-UI-SPEC.md
+
+@lightsync/models/show.py
+@lightsync/frontend/index.html
+@lightsync/frontend/style.css
+@lightsync/frontend/app.js
+
+
+
+class CueModel(BaseModel):
+ id: UUID = Field(default_factory=uuid4)
+ timestamp: float
+ mode: Literal["animation", "frame_sequence"] = "animation"
+ animation: str | None = None
+ params: dict[str, Any] = Field(default_factory=dict)
+
+class AnalysisBlock(BaseModel):
+ analysed_at: datetime | None = None
+ tempo_bpm: float | None = None
+ beat_times: list[float] = Field(default_factory=list)
+ onset_times: list[float] = Field(default_factory=list)
+
+
+// WebSocket client: client.send({ type, ... })
+// Audio: document.getElementById('player') — HTML5 audio element
+// API: apiPost(path, data), apiDelete(path)
+// Devices: loadDevices() populates #device-list
+
+
+--bg-primary: #0a0a0a; --bg-panel: #0f0f0f; --bg-panel-dark: #080808;
+--border-dim: #1a4a4a; --accent: #00ffff; --text-primary: #cccccc;
+--text-dim: #555555; --text-accent: var(--accent); --font-mono: JetBrains Mono chain
+
+
+
+
+
+
+ Task 1: Data model changes + HTML layout restructure
+ lightsync/models/show.py, lightsync/frontend/index.html
+ lightsync/models/show.py, lightsync/frontend/index.html, lightsync/frontend/style.css
+
+**1. lightsync/models/show.py — Add duration field to CueModel and calibration_offset to AnalysisBlock:**
+
+```python
+class CueModel(BaseModel):
+ id: UUID = Field(default_factory=uuid4)
+ timestamp: float
+ duration: float = 4.0 # block length in seconds; defaults for old files
+ mode: Literal["animation", "frame_sequence"] = "animation"
+ animation: str | None = None
+ params: dict[str, Any] = Field(default_factory=dict)
+```
+
+```python
+class AnalysisBlock(BaseModel):
+ analysed_at: datetime | None = None
+ tempo_bpm: float | None = None
+ beat_times: list[float] = Field(default_factory=list)
+ onset_times: list[float] = Field(default_factory=list)
+ calibration_offset: float = 0.03 # seconds; compensates librosa latency bias
+```
+
+Do NOT change schema_version (stays at 1). Duration defaults gracefully for old files via Pydantic.
+
+**2. lightsync/frontend/index.html — Replace the animation grid in main-area with timeline layout:**
+
+Replace the entire content of `` (the `.section-header` ANIMATIONS div and the `#animation-grid` div) with this structure:
+
+```html
+
+
+
+
+
+```
+
+Move the ANIMATIONS section into the sidebar. Add a second panel BELOW the existing DEVICES panel (inside `
+
+ cd /home/claude/led2 && python -c "from lightsync.models.show import CueModel, AnalysisBlock; c = CueModel(timestamp=1.0); assert c.duration == 4.0; a = AnalysisBlock(); assert a.calibration_offset == 0.03; print('OK')" && grep -q 'timeline-canvas' lightsync/frontend/index.html && grep -q 'timeline-toolbar' lightsync/frontend/index.html && grep -q 'block-inspector' lightsync/frontend/index.html && grep -q 'animation-palette' lightsync/frontend/index.html && echo "ALL PASS"
+
+
+ - lightsync/models/show.py contains `duration: float = 4.0` in CueModel
+ - lightsync/models/show.py contains `calibration_offset: float = 0.03` in AnalysisBlock
+ - lightsync/frontend/index.html contains `id="timeline-canvas"` (canvas element)
+ - lightsync/frontend/index.html contains `id="timeline-toolbar"` with zoom-slider, beat-offset, btn-snap
+ - lightsync/frontend/index.html contains `id="block-inspector"` with style="display:none;"
+ - lightsync/frontend/index.html contains `id="animation-palette"` inside the sidebar
+ - The old animation-grid div is removed from main-area
+ - CueModel(timestamp=1.0).duration == 4.0 (Python import succeeds)
+
+ CueModel has duration field with default 4.0, AnalysisBlock has calibration_offset with default 0.03, index.html has timeline canvas layout with toolbar/canvas/inspector structure, animation palette moved to sidebar
+
+
+
+ Task 2: TimelineCanvas class + CSS + app.js wiring
+ lightsync/frontend/timeline/timeline.js, lightsync/frontend/style.css, lightsync/frontend/app.js
+ lightsync/frontend/index.html, lightsync/frontend/style.css, lightsync/frontend/app.js, lightsync/api/audio.py
+
+**1. Create `lightsync/frontend/timeline/timeline.js` — the TimelineCanvas class.**
+
+This is the core canvas rendering engine. It must export a `TimelineCanvas` class with:
+
+**Constants:**
+```javascript
+const PIXELS_PER_SECOND = 100; // default, adjustable via zoom
+const TRACK_HEIGHT = 48;
+const HEADER_WIDTH = 140;
+const TIME_AXIS_HEIGHT = 24;
+```
+
+**Constructor:** `constructor(canvasEl, audioEl)` — stores references to the canvas element and audio element. Initializes:
+- `this.canvas = canvasEl`
+- `this.ctx = canvasEl.getContext('2d')`
+- `this.audio = audioEl`
+- `this.pixelsPerSecond = PIXELS_PER_SECOND`
+- `this.scrollX = 0`
+- `this.tracks = []` — array of `{device_id, device_name, strip_type, cues: []}`
+- `this.waveformPeaks = []`
+- `this.audioDuration = 0`
+- `this.beatTimes = []`
+- `this.calibrationOffset = 0.03`
+- `this.snapEnabled = true`
+- `this.selectedBlock = null`
+- `this._dragState = null`
+- `this._onSelectBlock = null` — callback for inspector
+- Calls `this._resizeCanvas()` and adds `window resize` listener to call `_resizeCanvas()`
+
+**Coordinate helpers (exposed as methods):**
+- `timeToX(t)` — returns `HEADER_WIDTH + t * this.pixelsPerSecond - this.scrollX`
+- `xToTime(x)` — returns `(x - HEADER_WIDTH + this.scrollX) / this.pixelsPerSecond`
+- `trackIndexToY(i)` — returns `TIME_AXIS_HEIGHT + i * TRACK_HEIGHT`
+- `yToTrackIndex(y)` — returns `Math.floor((y - TIME_AXIS_HEIGHT) / TRACK_HEIGHT)`, clamped to -1 if above time axis
+
+**`_resizeCanvas()` method** — handles devicePixelRatio:
+```javascript
+_resizeCanvas() {
+ const dpr = window.devicePixelRatio || 1;
+ const rect = this.canvas.getBoundingClientRect();
+ this.canvas.width = rect.width * dpr;
+ this.canvas.height = rect.height * dpr;
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+}
+```
+
+**`render()` method** — clears canvas, draws layers in order:
+1. Background fill `#0a0a0a` (full canvas)
+2. Track row banding: even tracks `#0a0a0a`, odd tracks `#0c0c0c` — filled rects from HEADER_WIDTH to canvas width, each TRACK_HEIGHT tall starting at TIME_AXIS_HEIGHT
+3. Waveform: if `this.waveformPeaks.length > 0`, draw vertical bars in `#1a3a3a`. For each peak, compute x via `timeToX((i / peaks.length) * audioDuration)`, draw from midY-h to midY+h where h is `peak * TRACK_HEIGHT * tracks.length * 0.4`. Only draw peaks where x is within visible range (0 to canvasWidth).
+4. Beat marks: for each `beatTime` in `this.beatTimes`, compute `displayTime = beatTime - this.calibrationOffset`, draw 1px wide vertical line in `rgba(0, 255, 255, 0.4)` from TIME_AXIS_HEIGHT to canvas bottom
+5. Block rendering: for each track, for each cue in track.cues, compute x = `timeToX(cue.timestamp)`, w = `cue.duration * this.pixelsPerSecond`, y = `trackIndexToY(trackIdx)`. Fill rect with `#0a2a2a` (or `#0d3a3a` if selected). Border 1px `#1a4a4a` (or `#00ffff` if selected). Clip and draw animation name label in `#00ffff` at 10px uppercase inside block.
+6. Drag ghost: if `this._dragState && this._dragState.ghost`, draw ghost rect with `rgba(0,255,255,0.15)` fill and 1px `#00ffff` border
+7. Track headers: fill `#0f0f0f` rect from x=0 to HEADER_WIDTH, full height. Draw track separator lines. For each track draw device_name in `#cccccc` at 12px, vertically centered in track row.
+8. Time axis: fill `#080808` rect from x=0 to canvasWidth, height TIME_AXIS_HEIGHT. Draw second/minute markers as tick marks in `#1a4a4a` with time labels in `#555555` at 10px. Show marks at every second, with longer ticks and labels at 5s and 10s intervals.
+9. Playback cursor: if `!this.audio.paused`, compute x = `timeToX(this.audio.currentTime)`. Draw 1px vertical line in `#00ffff` from TIME_AXIS_HEIGHT to canvas bottom.
+
+**`startRenderLoop()` method:**
+```javascript
+startRenderLoop() {
+ const loop = () => { this.render(); requestAnimationFrame(loop); };
+ requestAnimationFrame(loop);
+}
+```
+
+**`loadTracks(devices)` method** — takes array of device objects from `/api/devices`, builds `this.tracks`:
+```javascript
+loadTracks(devices) {
+ this.tracks = devices.map(d => ({
+ device_id: d.id,
+ device_name: d.name,
+ strip_type: d.strip_type,
+ cues: []
+ }));
+}
+```
+
+**`loadWaveform(audioPath)` async method:**
+```javascript
+async loadWaveform(audioPath) {
+ try {
+ const res = await fetch(`/api/audio/waveform?path=${encodeURIComponent(audioPath)}&peaks=2000`);
+ const data = await res.json();
+ this.waveformPeaks = data.peaks || [];
+ } catch (e) { console.warn('[waveform]', e); }
+}
+```
+
+**`loadBeats(audioPath)` async method:**
+```javascript
+async loadBeats(audioPath) {
+ try {
+ const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`);
+ const data = await res.json();
+ this.beatTimes = data.beat_times || [];
+ } catch (e) { console.warn('[beats]', e); }
+}
+```
+
+**Scroll handling** — add wheel event on canvas:
+```javascript
+this.canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ this.scrollX = Math.max(0, this.scrollX + e.deltaX + e.deltaY);
+}, { passive: false });
+```
+
+**Auto-scroll during playback** — in `render()`, after computing cursor x: if cursor x > canvasWidth * 0.8, set `this.scrollX` so cursor is at 20% from left.
+
+Export the class as `export { TimelineCanvas }`.
+
+**Empty state handling:** In `render()`, if `this.tracks.length === 0` and no audio loaded, draw centered dim text "NO DEVICES -- add a device in the sidebar to create tracks" in `#555555`. If tracks exist but `this.audioDuration === 0`, draw "LOAD AUDIO -- use the transport bar to load a file".
+
+**2. Add CSS to style.css:**
+
+Add these rules AFTER the existing `.anim-desc` rule block (replacing the existing `.main-area` animation grid styles is NOT needed — those stay for sidebar now):
+
+```css
+/* ── Timeline ──────────────────────────────────── */
+#timeline-canvas {
+ flex: 1;
+ display: block;
+ min-height: 0;
+ cursor: default;
+}
+
+.timeline-toolbar {
+ height: 32px;
+ background: var(--bg-panel-dark);
+ border-bottom: 1px solid var(--border-dim);
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 0 12px;
+ flex-shrink: 0;
+}
+
+.toolbar-field {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.toolbar-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ letter-spacing: 0.1em;
+ white-space: nowrap;
+}
+
+.toolbar-unit {
+ font-size: 10px;
+ color: var(--text-dim);
+}
+
+.toolbar-sep {
+ width: 1px;
+ height: 20px;
+ background: var(--border-dim);
+ flex-shrink: 0;
+}
+
+#zoom-slider {
+ width: 100px;
+ accent-color: var(--accent);
+ height: 3px;
+}
+
+#beat-offset {
+ width: 70px;
+ font-size: 12px;
+ padding: 2px 4px;
+}
+
+.snap-btn.active {
+ background: var(--accent);
+ color: var(--bg-primary);
+ border-color: var(--accent);
+}
+
+/* ── Block Inspector Strip ─────────────────────── */
+.block-inspector {
+ height: 40px;
+ background: var(--bg-panel-dark);
+ border-top: 1px solid var(--border-dim);
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 0 12px;
+ flex-shrink: 0;
+}
+
+.inspector-label {
+ color: var(--text-accent);
+ font-size: 14px;
+ font-weight: 600;
+ text-transform: uppercase;
+ flex-shrink: 0;
+ min-width: 120px;
+}
+
+.inspector-field {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 10px;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ letter-spacing: 0.1em;
+}
+
+.inspector-field input,
+.inspector-field select {
+ font-size: 12px;
+ padding: 2px 4px;
+ background: var(--bg-panel);
+ border: 1px solid var(--border-dim);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+}
+
+.inspector-field input[type="number"] {
+ width: 60px;
+}
+
+.inspector-color-swatch {
+ width: 16px;
+ height: 16px;
+ border: 1px solid var(--border-dim);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.inspector-remove {
+ margin-left: auto;
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 0 4px;
+}
+.inspector-remove:hover {
+ color: #ff3333;
+ border: none;
+}
+```
+
+**3. Wire timeline into app.js:**
+
+At the TOP of app.js, add the import:
+```javascript
+import { TimelineCanvas } from './timeline/timeline.js';
+```
+
+At the BOTTOM of app.js (after `wireAudio();`), add initialization:
+```javascript
+// ── Timeline ─────────────────────────────────────────────────────────────────
+
+let timeline = null;
+
+function initTimeline() {
+ const canvas = document.getElementById('timeline-canvas');
+ const audio = document.getElementById('player');
+ if (!canvas || !audio) return;
+
+ timeline = new TimelineCanvas(canvas, audio);
+
+ // Load device tracks
+ fetch('/api/devices').then(r => r.json()).then(devices => {
+ timeline.loadTracks(devices);
+ }).catch(e => console.warn('[timeline]', e));
+
+ // When audio loads, fetch waveform + beats
+ audio.addEventListener('loadedmetadata', () => {
+ const src = audio.src;
+ const filename = src.split('/').pop();
+ const path = `/app/shows/${filename}`;
+ timeline.audioDuration = audio.duration;
+ timeline.loadWaveform(path);
+ timeline.loadBeats(path);
+ });
+
+ // Zoom slider
+ document.getElementById('zoom-slider')?.addEventListener('input', (e) => {
+ timeline.pixelsPerSecond = parseFloat(e.target.value);
+ });
+
+ // Beat offset
+ document.getElementById('beat-offset')?.addEventListener('input', (e) => {
+ timeline.calibrationOffset = parseFloat(e.target.value) || 0.03;
+ });
+
+ // Snap toggle
+ document.getElementById('btn-snap')?.addEventListener('click', (e) => {
+ timeline.snapEnabled = !timeline.snapEnabled;
+ e.target.classList.toggle('active', timeline.snapEnabled);
+ });
+
+ // Populate animation palette in sidebar
+ const palette = document.getElementById('animation-palette');
+ if (palette) {
+ const anims = ['chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire', 'solid'];
+ palette.innerHTML = anims.map(name => `
+
+ `).join('');
+ }
+
+ timeline.startRenderLoop();
+}
+
+initTimeline();
+```
+
+Remove or comment out the old `selectedAnim` variable and the `animation-grid` click listener (lines that reference `#animation-grid`), since that element no longer exists in main-area.
+
+
+ cd /home/claude/led2 && test -f lightsync/frontend/timeline/timeline.js && grep -q "export.*TimelineCanvas" lightsync/frontend/timeline/timeline.js && grep -q "timeToX" lightsync/frontend/timeline/timeline.js && grep -q "requestAnimationFrame" lightsync/frontend/timeline/timeline.js && grep -q "devicePixelRatio" lightsync/frontend/timeline/timeline.js && grep -q "import.*TimelineCanvas" lightsync/frontend/app.js && grep -q "#timeline-canvas" lightsync/frontend/style.css && grep -q ".block-inspector" lightsync/frontend/style.css && echo "ALL PASS"
+
+
+ - lightsync/frontend/timeline/timeline.js exists and exports TimelineCanvas
+ - timeline.js contains timeToX, xToTime, trackIndexToY, yToTrackIndex methods
+ - timeline.js contains _resizeCanvas with devicePixelRatio handling
+ - timeline.js contains render() method with layer drawing (background, waveform, beat marks, blocks, headers, time axis, cursor)
+ - timeline.js contains startRenderLoop() using requestAnimationFrame
+ - timeline.js contains loadTracks(), loadWaveform(), loadBeats() methods
+ - timeline.js contains wheel scroll handler updating scrollX
+ - app.js imports TimelineCanvas from './timeline/timeline.js'
+ - app.js has initTimeline() function that creates TimelineCanvas, wires zoom/beat-offset/snap
+ - app.js populates #animation-palette with 7 animation types
+ - style.css contains #timeline-canvas, .timeline-toolbar, .block-inspector, .inspector-label rules
+ - Old animation-grid click listener removed from app.js
+
+ Timeline canvas renders per-device tracks with headers, time axis, waveform background, beat marks, and a playback cursor synced to audio.currentTime via requestAnimationFrame. Toolbar controls zoom, beat offset, and snap toggle. Animation palette rendered dynamically in sidebar.
+
+
+
+
+
+1. Docker rebuild: `cd /home/claude/led2 && docker compose build && docker compose up -d`
+2. Open browser: navigate to the app URL
+3. Verify: per-device tracks visible (if devices exist), time axis with second markers, toolbar with ZOOM/BEAT OFFSET/SNAP controls
+4. Load audio file: waveform peaks appear as dim background, beat marks appear as cyan lines
+5. Press play: cursor moves across timeline in sync with audio
+6. Scroll: mouse wheel scrolls timeline horizontally
+7. Sidebar: ANIMATIONS panel visible below DEVICES with 7 animation types
+8. Python model: `python -c "from lightsync.models.show import CueModel; c = CueModel(timestamp=0); print(c.duration)"` prints 4.0
+
+
+
+- Timeline canvas fills the main area with per-device horizontal tracks
+- Playback cursor moves in realtime synced to audio.currentTime
+- Waveform peaks render behind tracks
+- Beat marks overlay on timeline at correct time positions
+- Toolbar zoom slider adjusts pixelsPerSecond
+- CueModel.duration defaults to 4.0
+- AnalysisBlock.calibration_offset defaults to 0.03
+
+
+
diff --git a/.planning/phases/04-timeline-editor/04-02-PLAN.md b/.planning/phases/04-timeline-editor/04-02-PLAN.md
new file mode 100644
index 0000000..4ed5bd8
--- /dev/null
+++ b/.planning/phases/04-timeline-editor/04-02-PLAN.md
@@ -0,0 +1,737 @@
+---
+phase: 04-timeline-editor
+plan: 02
+type: execute
+wave: 2
+depends_on: [04-01]
+files_modified:
+ - lightsync/frontend/timeline/commands.js
+ - lightsync/frontend/timeline/history.js
+ - lightsync/frontend/timeline/timeline.js
+ - lightsync/frontend/app.js
+ - lightsync/api/timeline.py
+ - lightsync/main.py
+autonomous: true
+requirements: [TL-02, TL-03, TL-06]
+
+must_haves:
+ truths:
+ - "Animation blocks can be dragged from the sidebar palette and placed on a track"
+ - "Blocks can be moved by dragging the center region"
+ - "Blocks can be resized by dragging left or right edge handles (8px hit zone)"
+ - "Blocks can be deleted with the Delete key"
+ - "Undo (Ctrl+Z) and Redo (Ctrl+Shift+Z) work for all mutations with at least 50 steps"
+ - "Block overlap on the same track is rejected"
+ artifacts:
+ - path: "lightsync/frontend/timeline/commands.js"
+ provides: "Command classes: PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand"
+ exports: ["PlaceBlockCommand", "MoveBlockCommand", "ResizeBlockCommand", "DeleteBlockCommand"]
+ min_lines: 80
+ - path: "lightsync/frontend/timeline/history.js"
+ provides: "CommandHistory with undoStack, redoStack, execute(), undo(), redo()"
+ exports: ["CommandHistory"]
+ min_lines: 30
+ - path: "lightsync/api/timeline.py"
+ provides: "Block CRUD REST endpoints"
+ contains: "router = APIRouter"
+ key_links:
+ - from: "lightsync/frontend/timeline/commands.js"
+ to: "timeline.js tracks state"
+ via: "Commands mutate track.cues arrays directly"
+ pattern: "track\\.cues"
+ - from: "lightsync/frontend/timeline/timeline.js"
+ to: "lightsync/frontend/timeline/history.js"
+ via: "timeline calls history.execute(cmd) for all mutations"
+ pattern: "history\\.execute"
+ - from: "lightsync/frontend/timeline/commands.js"
+ to: "/api/shows/{show_id}/tracks/{device_id}/blocks"
+ via: "async fetch calls on execute/undo for backend sync"
+ pattern: "api/shows"
+ - from: "lightsync/api/timeline.py"
+ to: "lightsync/models/show.py"
+ via: "CueModel creation and mutation"
+ pattern: "CueModel"
+---
+
+
+Add block interaction capabilities — drag-and-place from palette, move, resize, delete — all wrapped in a command pattern with undo/redo support (min 50 steps). Includes backend block CRUD endpoints.
+
+Purpose: This is the core editing capability. Without it, the timeline is a static display. The command pattern ensures all mutations are reversible.
+Output: Fully interactive block manipulation with undo/redo, backed by REST API persistence.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/04-timeline-editor/04-CONTEXT.md
+@.planning/phases/04-timeline-editor/04-RESEARCH.md
+@.planning/phases/04-timeline-editor/04-UI-SPEC.md
+@.planning/phases/04-timeline-editor/04-01-SUMMARY.md
+
+@lightsync/models/show.py
+@lightsync/frontend/timeline/timeline.js
+@lightsync/frontend/app.js
+@lightsync/api/shows.py
+@lightsync/main.py
+
+
+
+export class TimelineCanvas {
+ constructor(canvasEl, audioEl)
+ timeToX(t), xToTime(x), trackIndexToY(i), yToTrackIndex(y)
+ render()
+ startRenderLoop()
+ loadTracks(devices)
+ tracks: [{device_id, device_name, strip_type, cues: []}]
+ selectedBlock: CueModel | null
+ _dragState: object | null
+ _onSelectBlock: function | null
+ snapEnabled: boolean
+ beatTimes: float[]
+ calibrationOffset: float
+ pixelsPerSecond: number
+ scrollX: number
+}
+
+
+class CueModel(BaseModel):
+ id: UUID
+ timestamp: float
+ duration: float = 4.0
+ mode: Literal["animation", "frame_sequence"] = "animation"
+ animation: str | None = None
+ params: dict[str, Any] = {}
+
+class ShowModel(BaseModel):
+ tracks: list[TrackModel] # TrackModel has device_id: UUID, cues: list[CueModel]
+
+
+router = APIRouter(tags=["shows"])
+# Endpoints: GET/POST /api/shows, GET /api/shows/{show_id}
+# state.show_store.save(show), state.show_store.load(show_id)
+
+
+# app.include_router(shows.router, prefix="/api/shows")
+# Module-level: show_store, registry
+
+
+
+
+
+
+ Task 1: Command pattern + history + backend block CRUD
+ lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/api/timeline.py, lightsync/main.py
+ lightsync/frontend/timeline/timeline.js, lightsync/models/show.py, lightsync/api/shows.py, lightsync/main.py, lightsync/frontend/app.js
+
+**1. Create `lightsync/frontend/timeline/history.js` — CommandHistory class:**
+
+```javascript
+export class CommandHistory {
+ constructor(maxSteps = 50) {
+ this.undoStack = [];
+ this.redoStack = [];
+ this.maxSteps = maxSteps;
+ }
+
+ execute(cmd) {
+ cmd.execute();
+ this.undoStack.push(cmd);
+ if (this.undoStack.length > this.maxSteps) this.undoStack.shift();
+ this.redoStack = []; // clear redo on new action (prevents branching corruption)
+ }
+
+ undo() {
+ const cmd = this.undoStack.pop();
+ if (!cmd) return false;
+ cmd.undo();
+ this.redoStack.push(cmd);
+ return true;
+ }
+
+ redo() {
+ const cmd = this.redoStack.pop();
+ if (!cmd) return false;
+ cmd.execute();
+ this.undoStack.push(cmd);
+ return true;
+ }
+
+ get canUndo() { return this.undoStack.length > 0; }
+ get canRedo() { return this.redoStack.length > 0; }
+}
+```
+
+**2. Create `lightsync/frontend/timeline/commands.js` — Command classes:**
+
+Each command stores the data needed for execute and undo. Commands mutate the `tracks` array (passed by reference from TimelineCanvas). Backend sync is fire-and-forget fetch (no await during rapid undo/redo).
+
+Generate a UUID for new blocks using `crypto.randomUUID()`.
+
+Helper function for backend sync (top of file):
+```javascript
+function syncBlock(showId, deviceId, block, method) {
+ if (!showId) return; // no show loaded yet — skip sync
+ const base = `/api/shows/${showId}/tracks/${deviceId}/blocks`;
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
+ if (method === 'POST' || method === 'PATCH') {
+ opts.body = JSON.stringify(block);
+ }
+ const url = method === 'POST' ? base : `${base}/${block.id}`;
+ fetch(url, opts).catch(e => console.warn('[sync]', e));
+}
+```
+
+**PlaceBlockCommand:**
+```javascript
+export class PlaceBlockCommand {
+ constructor(tracks, block) {
+ this.tracks = tracks;
+ this.block = block; // {id, timestamp, duration, animation, params, device_id}
+ }
+ execute() {
+ const track = this.tracks.find(t => t.device_id === this.block.device_id);
+ if (track) track.cues.push(this.block);
+ syncBlock(this._showId, this.block.device_id, this.block, 'POST');
+ }
+ undo() {
+ const track = this.tracks.find(t => t.device_id === this.block.device_id);
+ if (track) track.cues = track.cues.filter(c => c.id !== this.block.id);
+ syncBlock(this._showId, this.block.device_id, this.block, 'DELETE');
+ }
+}
+```
+
+**MoveBlockCommand:** Stores `oldTimestamp` and `newTimestamp`.
+```javascript
+export class MoveBlockCommand {
+ constructor(tracks, block, oldTimestamp, newTimestamp) {
+ this.tracks = tracks;
+ this.block = block;
+ this.oldTimestamp = oldTimestamp;
+ this.newTimestamp = newTimestamp;
+ }
+ execute() {
+ this.block.timestamp = this.newTimestamp;
+ syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
+ }
+ undo() {
+ this.block.timestamp = this.oldTimestamp;
+ syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
+ }
+}
+```
+
+**ResizeBlockCommand:** Stores old/new values for both `timestamp` and `duration` (left-edge resize changes both, right-edge resize changes only duration).
+```javascript
+export class ResizeBlockCommand {
+ constructor(tracks, block, oldTimestamp, oldDuration, newTimestamp, newDuration) {
+ this.tracks = tracks;
+ this.block = block;
+ this.oldTimestamp = oldTimestamp;
+ this.oldDuration = oldDuration;
+ this.newTimestamp = newTimestamp;
+ this.newDuration = newDuration;
+ }
+ execute() {
+ this.block.timestamp = this.newTimestamp;
+ this.block.duration = this.newDuration;
+ syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
+ }
+ undo() {
+ this.block.timestamp = this.oldTimestamp;
+ this.block.duration = this.oldDuration;
+ syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
+ }
+}
+```
+
+**DeleteBlockCommand:**
+```javascript
+export class DeleteBlockCommand {
+ constructor(tracks, block) {
+ this.tracks = tracks;
+ this.block = block;
+ }
+ execute() {
+ const track = this.tracks.find(t => t.device_id === this.block.device_id);
+ if (track) track.cues = track.cues.filter(c => c.id !== this.block.id);
+ syncBlock(this._showId, this.block.device_id, this.block, 'DELETE');
+ }
+ undo() {
+ const track = this.tracks.find(t => t.device_id === this.block.device_id);
+ if (track) track.cues.push(this.block);
+ syncBlock(this._showId, this.block.device_id, this.block, 'POST');
+ }
+}
+```
+
+All command classes must also export a `setShowId(id)` module-level function that sets `_showId` on future commands (or store it as module-level variable used by `syncBlock`). Alternatively, make `_showId` a module-level `let currentShowId = null;` and export `setCurrentShowId(id)`.
+
+**3. Create `lightsync/api/timeline.py` — Block CRUD router:**
+
+```python
+from uuid import UUID
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Any, Optional
+import lightsync.main as state
+
+router = APIRouter(tags=["timeline"])
+
+
+class BlockCreate(BaseModel):
+ id: str # UUID as string from frontend crypto.randomUUID()
+ timestamp: float
+ duration: float = 4.0
+ animation: str | None = None
+ params: dict[str, Any] = {}
+
+
+class BlockUpdate(BaseModel):
+ timestamp: float | None = None
+ duration: float | None = None
+ animation: str | None = None
+ params: dict[str, Any] | None = None
+
+
+@router.post("/shows/{show_id}/tracks/{device_id}/blocks", status_code=201)
+async def create_block(show_id: str, device_id: str, block: BlockCreate):
+ show = await state.show_store.load(show_id)
+ if show is None:
+ raise HTTPException(404, "Show not found")
+ track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
+ if track is None:
+ # Auto-create track for this device
+ from lightsync.models.show import TrackModel, CueModel
+ cue = CueModel(
+ id=UUID(block.id), timestamp=block.timestamp,
+ duration=block.duration, animation=block.animation,
+ params=block.params
+ )
+ track = TrackModel(device_id=UUID(device_id), cues=[cue])
+ show.tracks.append(track)
+ else:
+ from lightsync.models.show import CueModel
+ cue = CueModel(
+ id=UUID(block.id), timestamp=block.timestamp,
+ duration=block.duration, animation=block.animation,
+ params=block.params
+ )
+ track.cues.append(cue)
+ await state.show_store.save(show)
+ return {"status": "ok"}
+
+
+@router.patch("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}")
+async def update_block(show_id: str, device_id: str, block_id: str, update: BlockUpdate):
+ show = await state.show_store.load(show_id)
+ if show is None:
+ raise HTTPException(404, "Show not found")
+ track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
+ if track is None:
+ raise HTTPException(404, "Track not found")
+ cue = next((c for c in track.cues if str(c.id) == block_id), None)
+ if cue is None:
+ raise HTTPException(404, "Block not found")
+ if update.timestamp is not None:
+ cue.timestamp = update.timestamp
+ if update.duration is not None:
+ cue.duration = update.duration
+ if update.animation is not None:
+ cue.animation = update.animation
+ if update.params is not None:
+ cue.params = update.params
+ await state.show_store.save(show)
+ return {"status": "ok"}
+
+
+@router.delete("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}")
+async def delete_block(show_id: str, device_id: str, block_id: str):
+ show = await state.show_store.load(show_id)
+ if show is None:
+ raise HTTPException(404, "Show not found")
+ track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
+ if track is None:
+ raise HTTPException(404, "Track not found")
+ track.cues = [c for c in track.cues if str(c.id) != block_id]
+ await state.show_store.save(show)
+ return {"status": "ok"}
+```
+
+**4. Register router in `lightsync/main.py`:**
+
+Add import: `from lightsync.api import timeline`
+Add router: `app.include_router(timeline.router, prefix="/api")`
+
+Place the import alongside existing `from lightsync.api import devices, shows, audio, ws` and the `include_router` alongside existing router registrations.
+
+
+ cd /home/claude/led2 && test -f lightsync/frontend/timeline/commands.js && test -f lightsync/frontend/timeline/history.js && test -f lightsync/api/timeline.py && grep -q "PlaceBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "MoveBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "ResizeBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "DeleteBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "class CommandHistory" lightsync/frontend/timeline/history.js && grep -q "undoStack" lightsync/frontend/timeline/history.js && grep -q "redoStack" lightsync/frontend/timeline/history.js && grep -q "timeline" lightsync/main.py && python -c "from lightsync.api.timeline import router; print('router OK')" && echo "ALL PASS"
+
+
+ - lightsync/frontend/timeline/history.js exports CommandHistory with execute(), undo(), redo(), canUndo, canRedo
+ - history.js constructor takes maxSteps=50 and enforces it via shift()
+ - history.js redo clears redoStack on new execute (branching protection)
+ - lightsync/frontend/timeline/commands.js exports PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand
+ - Each command has execute() and undo() methods
+ - commands.js contains syncBlock helper that does fire-and-forget fetch to /api/shows/{showId}/tracks/{deviceId}/blocks
+ - lightsync/api/timeline.py contains POST /shows/{show_id}/tracks/{device_id}/blocks endpoint
+ - lightsync/api/timeline.py contains PATCH /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint
+ - lightsync/api/timeline.py contains DELETE /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint
+ - lightsync/main.py imports timeline router and includes it with prefix="/api"
+ - `python -c "from lightsync.api.timeline import router"` succeeds
+
+ Command pattern (4 command classes) + CommandHistory + backend block CRUD endpoints registered and importable
+
+
+
+ Task 2: Wire mouse interactions into TimelineCanvas
+ lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js
+ lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/app.js, lightsync/frontend/index.html
+
+**1. Add interaction methods to `timeline.js`:**
+
+Import at top of timeline.js:
+```javascript
+import { CommandHistory } from './history.js';
+import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js';
+```
+
+Add to constructor:
+```javascript
+this.history = new CommandHistory(50);
+this.showId = null; // set when a show is loaded
+```
+
+**Add `hitTest(mouseX, mouseY)` method:**
+```javascript
+hitTest(mouseX, mouseY) {
+ const EDGE = 8;
+ for (const track of this.tracks) {
+ const trackIdx = this.tracks.indexOf(track);
+ const y = this.trackIndexToY(trackIdx);
+ if (mouseY < y || mouseY > y + TRACK_HEIGHT) continue;
+ // Iterate cues in reverse (last placed = on top)
+ for (let i = track.cues.length - 1; i >= 0; i--) {
+ const cue = track.cues[i];
+ const x = this.timeToX(cue.timestamp);
+ const w = cue.duration * this.pixelsPerSecond;
+ if (mouseX < x || mouseX > x + w) continue;
+ if (mouseX < x + EDGE) return { block: cue, handle: 'resize-left', trackIdx };
+ if (mouseX > x + w - EDGE) return { block: cue, handle: 'resize-right', trackIdx };
+ return { block: cue, handle: 'move', trackIdx };
+ }
+ }
+ return null;
+}
+```
+
+**Add overlap check method:**
+```javascript
+_hasOverlap(track, block, startTime, duration) {
+ const end = startTime + duration;
+ return track.cues.some(c => {
+ if (c.id === block.id) return false; // skip self
+ const cEnd = c.timestamp + c.duration;
+ return startTime < cEnd && end > c.timestamp;
+ });
+}
+```
+
+**Add snap helper:**
+```javascript
+snapToBeat(t) {
+ if (!this.snapEnabled || this.beatTimes.length === 0) return t;
+ const SNAP_THRESHOLD = 0.1; // 100ms
+ let nearest = null, minDist = Infinity;
+ for (const beat of this.beatTimes) {
+ const adjusted = beat - this.calibrationOffset;
+ const d = Math.abs(t - adjusted);
+ if (d < minDist) { minDist = d; nearest = adjusted; }
+ }
+ return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t;
+}
+```
+
+**Add `_setupInteractions()` method** — call from constructor AFTER _resizeCanvas():
+```javascript
+_setupInteractions() {
+ const canvas = this.canvas;
+
+ canvas.addEventListener('mousedown', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+
+ // Check if in header or time axis area — ignore
+ if (mx < HEADER_WIDTH || my < TIME_AXIS_HEIGHT) return;
+
+ const hit = this.hitTest(mx, my);
+
+ if (!hit) {
+ // Clicked on empty canvas — deselect
+ this.selectedBlock = null;
+ if (this._onSelectBlock) this._onSelectBlock(null);
+ return;
+ }
+
+ // Select the clicked block
+ this.selectedBlock = hit.block;
+ if (this._onSelectBlock) this._onSelectBlock(hit.block);
+
+ if (hit.handle === 'move') {
+ this._startMove(e, hit.block, mx);
+ } else if (hit.handle === 'resize-left' || hit.handle === 'resize-right') {
+ this._startResize(e, hit.block, hit.handle, mx);
+ }
+ });
+
+ // Update cursor on hover
+ canvas.addEventListener('mousemove', (e) => {
+ if (this._dragState) return; // during drag, document handles mousemove
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ const hit = this.hitTest(mx, my);
+ if (hit && (hit.handle === 'resize-left' || hit.handle === 'resize-right')) {
+ canvas.style.cursor = 'ew-resize';
+ } else if (hit && hit.handle === 'move') {
+ canvas.style.cursor = 'grab';
+ } else {
+ canvas.style.cursor = 'default';
+ }
+ });
+
+ // Delete key
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Delete' && this.selectedBlock) {
+ const cmd = new DeleteBlockCommand(this.tracks, this.selectedBlock);
+ this.history.execute(cmd);
+ this.selectedBlock = null;
+ if (this._onSelectBlock) this._onSelectBlock(null);
+ }
+ // Undo: Ctrl+Z
+ if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
+ e.preventDefault();
+ this.history.undo();
+ }
+ // Redo: Ctrl+Shift+Z or Ctrl+Y
+ if ((e.ctrlKey && e.shiftKey && e.key === 'Z') || (e.ctrlKey && e.key === 'y')) {
+ e.preventDefault();
+ this.history.redo();
+ }
+ });
+}
+```
+
+**Add `_startMove(e, block, startMX)` method:**
+```javascript
+_startMove(e, block, startMX) {
+ const oldTimestamp = block.timestamp;
+ const startTime = block.timestamp;
+ const startScrollX = this.scrollX;
+ this.canvas.style.cursor = 'grabbing';
+
+ const onDrag = (ev) => {
+ const rect = this.canvas.getBoundingClientRect();
+ const mx = ev.clientX - rect.left;
+ const deltaX = mx - startMX;
+ const deltaTime = deltaX / this.pixelsPerSecond;
+ block.timestamp = Math.max(0, startTime + deltaTime);
+ };
+
+ const onUp = () => {
+ document.removeEventListener('mousemove', onDrag);
+ document.removeEventListener('mouseup', onUp);
+ this.canvas.style.cursor = 'default';
+ // Snap
+ block.timestamp = this.snapToBeat(block.timestamp);
+ block.timestamp = Math.max(0, block.timestamp);
+ // Check overlap
+ const track = this.tracks.find(t => t.device_id === block.device_id);
+ if (track && this._hasOverlap(track, block, block.timestamp, block.duration)) {
+ block.timestamp = oldTimestamp; // revert
+ return;
+ }
+ if (block.timestamp !== oldTimestamp) {
+ // Reset to old for command to execute properly
+ block.timestamp = oldTimestamp;
+ const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, this.snapToBeat(Math.max(0, startTime + (block.timestamp - oldTimestamp) || (this.snapToBeat(Math.max(0, startTime + (0))) ))));
+ // Simpler: just compute final time
+ const finalTime = this.snapToBeat(Math.max(0, startTime + ((e.clientX - e.clientX) / this.pixelsPerSecond)));
+ // Actually: just record the snapped value before reverting
+ }
+ };
+
+ // Simpler approach — store snapped result:
+ let finalTimestamp = oldTimestamp;
+
+ const onDrag2 = (ev) => {
+ const rect = this.canvas.getBoundingClientRect();
+ const mx = ev.clientX - rect.left;
+ const deltaX = mx - startMX;
+ const deltaTime = deltaX / this.pixelsPerSecond;
+ block.timestamp = Math.max(0, startTime + deltaTime);
+ };
+
+ const onUp2 = () => {
+ document.removeEventListener('mousemove', onDrag2);
+ document.removeEventListener('mouseup', onUp2);
+ this.canvas.style.cursor = 'default';
+ const snapped = this.snapToBeat(Math.max(0, block.timestamp));
+ const track = this.tracks.find(t => t.device_id === block.device_id);
+ if (track && this._hasOverlap(track, block, snapped, block.duration)) {
+ block.timestamp = oldTimestamp;
+ return;
+ }
+ block.timestamp = oldTimestamp; // revert for command
+ if (snapped !== oldTimestamp) {
+ const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, snapped);
+ this.history.execute(cmd);
+ }
+ };
+
+ document.addEventListener('mousemove', onDrag2);
+ document.addEventListener('mouseup', onUp2, { once: true });
+}
+```
+
+IMPORTANT: The above `_startMove` has redundant code from drafting. The executor MUST implement it cleanly as a single version. The logic is:
+1. Record `oldTimestamp = block.timestamp`
+2. On mousemove: update `block.timestamp` live (visual feedback only)
+3. On mouseup: compute `snapped = snapToBeat(max(0, block.timestamp))`, check overlap, revert `block.timestamp = oldTimestamp`, then if snapped !== oldTimestamp execute `MoveBlockCommand(tracks, block, oldTimestamp, snapped)`
+
+**Add `_startResize(e, block, handle, startMX)` method:**
+Same pattern. Record `oldTimestamp`, `oldDuration`. On mousemove: if handle is 'resize-right', adjust duration = max(0.5, (mx - blockStartX) / pixelsPerSecond). If handle is 'resize-left', adjust both timestamp and duration so the right edge stays fixed. On mouseup: snap, check overlap, revert, execute `ResizeBlockCommand`. Minimum duration is 0.5 seconds.
+
+**Add `handleDrop(animationType, mouseX, mouseY)` method** — called when an animation palette item is dropped on canvas:
+```javascript
+handleDrop(animationType, mouseX, mouseY) {
+ const trackIdx = this.yToTrackIndex(mouseY);
+ if (trackIdx < 0 || trackIdx >= this.tracks.length) return;
+ const track = this.tracks[trackIdx];
+ const rawTime = Math.max(0, this.xToTime(mouseX));
+ const time = this.snapToBeat(rawTime);
+ const duration = 4.0; // default duration
+
+ if (this._hasOverlap(track, {id: null}, time, duration)) return; // reject overlap
+
+ const block = {
+ id: crypto.randomUUID(),
+ device_id: track.device_id,
+ timestamp: time,
+ duration: duration,
+ animation: animationType,
+ params: { color: '#00ffff', speed: 1.0, direction: 'forward', length: 0.3 }
+ };
+ const cmd = new PlaceBlockCommand(this.tracks, block);
+ this.history.execute(cmd);
+ this.selectedBlock = block;
+ if (this._onSelectBlock) this._onSelectBlock(block);
+}
+```
+
+**2. Wire drag-from-palette in app.js:**
+
+In the `initTimeline()` function (after palette creation), add drag handling for the animation palette items:
+
+```javascript
+// Drag from palette to canvas
+const palette = document.getElementById('animation-palette');
+const canvasEl = document.getElementById('timeline-canvas');
+
+palette?.addEventListener('dragstart', (e) => {
+ const tile = e.target.closest('.anim-tile');
+ if (!tile) return;
+ e.dataTransfer.setData('text/plain', tile.dataset.anim);
+ e.dataTransfer.effectAllowed = 'copy';
+});
+
+canvasEl?.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ // Draw ghost preview — update drag state for rendering
+ const rect = canvasEl.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ const trackIdx = timeline.yToTrackIndex(my);
+ const time = timeline.xToTime(mx);
+ timeline._dragState = {
+ ghost: true,
+ trackIdx: trackIdx,
+ time: Math.max(0, time),
+ duration: 4.0
+ };
+});
+
+canvasEl?.addEventListener('dragleave', () => {
+ timeline._dragState = null;
+});
+
+canvasEl?.addEventListener('drop', (e) => {
+ e.preventDefault();
+ const animType = e.dataTransfer.getData('text/plain');
+ if (!animType) return;
+ const rect = canvasEl.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ timeline.handleDrop(animType, mx, my);
+ timeline._dragState = null;
+});
+```
+
+Also set `draggable="true"` on palette items (already done in Plan 01 HTML generation).
+
+
+ cd /home/claude/led2 && grep -q "hitTest" lightsync/frontend/timeline/timeline.js && grep -q "handleDrop" lightsync/frontend/timeline/timeline.js && grep -q "_startMove" lightsync/frontend/timeline/timeline.js && grep -q "_startResize" lightsync/frontend/timeline/timeline.js && grep -q "snapToBeat" lightsync/frontend/timeline/timeline.js && grep -q "_hasOverlap" lightsync/frontend/timeline/timeline.js && grep -q "_setupInteractions" lightsync/frontend/timeline/timeline.js && grep -q "Ctrl.*Z\|ctrlKey" lightsync/frontend/timeline/timeline.js && grep -q "dragstart\|dragover\|drop" lightsync/frontend/app.js && echo "ALL PASS"
+
+
+ - timeline.js imports CommandHistory from history.js
+ - timeline.js imports all 4 command classes from commands.js
+ - timeline.js constructor creates `this.history = new CommandHistory(50)`
+ - timeline.js has hitTest() method with 8px EDGE for resize handles
+ - timeline.js has _hasOverlap() method checking time range collisions
+ - timeline.js has snapToBeat() with 0.1s threshold and calibrationOffset
+ - timeline.js has _setupInteractions() with mousedown, mousemove, keydown listeners
+ - timeline.js has _startMove() attaching mousemove/mouseup to document (not canvas)
+ - timeline.js has _startResize() with minimum 0.5s duration
+ - timeline.js has handleDrop() creating new block with crypto.randomUUID()
+ - Keydown listener handles Delete key, Ctrl+Z (undo), Ctrl+Shift+Z (redo), Ctrl+Y (redo)
+ - app.js has dragstart listener on palette, dragover/dragleave/drop on canvas
+ - Ghost block state set during dragover, cleared on dragleave/drop
+
+ Full block interaction: drag-and-place from palette, move by dragging center, resize by dragging edges, delete with Delete key, undo/redo with Ctrl+Z/Ctrl+Shift+Z. Overlap prevention on same track. All mutations go through CommandHistory.
+
+
+
+
+
+1. Docker rebuild and restart
+2. Add a device via sidebar, load audio
+3. Drag animation from ANIMATIONS palette onto a track — block appears
+4. Click block center and drag — block moves, snaps to nearest beat on release
+5. Drag block right edge — block resizes
+6. Press Delete with block selected — block disappears
+7. Press Ctrl+Z — deleted block reappears (undo)
+8. Press Ctrl+Shift+Z — block disappears again (redo)
+9. Attempt to place block overlapping existing — placement rejected
+10. Rapidly perform 50+ operations, then undo all — verify all revert correctly
+
+
+
+- Blocks can be placed on tracks via drag-and-drop from palette
+- Blocks can be moved and resized with snap-to-beat
+- Delete key removes selected block
+- Undo/redo works for all operations with at least 50 steps
+- Block overlap is prevented
+- Backend block CRUD endpoints accept requests
+
+
+
diff --git a/.planning/phases/04-timeline-editor/04-03-PLAN.md b/.planning/phases/04-timeline-editor/04-03-PLAN.md
new file mode 100644
index 0000000..1127226
--- /dev/null
+++ b/.planning/phases/04-timeline-editor/04-03-PLAN.md
@@ -0,0 +1,290 @@
+---
+phase: 04-timeline-editor
+plan: 03
+type: execute
+wave: 3
+depends_on: [04-01, 04-02]
+files_modified:
+ - lightsync/frontend/timeline/beats.js
+ - lightsync/frontend/timeline/timeline.js
+ - lightsync/frontend/app.js
+autonomous: true
+requirements: [TL-07, TL-08, SYNC-01, SYNC-02]
+
+must_haves:
+ truths:
+ - "Beat marks from librosa appear as thin cyan lines on the timeline canvas"
+ - "Blocks snap to calibrated beat positions when placed, moved, or resized"
+ - "Beat calibration offset slider adjusts beat mark display in realtime"
+ - "Snap toggle enables/disables snapping behavior"
+ - "DETECTING BEATS... indicator shows while beat analysis is loading"
+ artifacts:
+ - path: "lightsync/frontend/timeline/beats.js"
+ provides: "BeatGrid class for beat data management, calibration, and snap logic"
+ exports: ["BeatGrid"]
+ min_lines: 40
+ key_links:
+ - from: "lightsync/frontend/timeline/beats.js"
+ to: "/api/audio/beats"
+ via: "fetch beat_times and onset_times from backend"
+ pattern: "api/audio/beats"
+ - from: "lightsync/frontend/timeline/timeline.js"
+ to: "lightsync/frontend/timeline/beats.js"
+ via: "timeline.beatGrid.snap() called during block placement/move/resize"
+ pattern: "beatGrid\\.snap"
+---
+
+
+Extract beat management into a dedicated BeatGrid module, enhance beat mark rendering with loading states and onset marks, and ensure snap-to-beat is properly integrated with all block interactions.
+
+Purpose: Beat detection and snap are core requirements (TL-07, TL-08, SYNC-01, SYNC-02). Extracting into a module makes calibration offset management clean and testable.
+Output: BeatGrid module with snap logic, enhanced beat rendering, loading indicator.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/04-timeline-editor/04-CONTEXT.md
+@.planning/phases/04-timeline-editor/04-RESEARCH.md
+@.planning/phases/04-timeline-editor/04-UI-SPEC.md
+@.planning/phases/04-timeline-editor/04-01-SUMMARY.md
+@.planning/phases/04-timeline-editor/04-02-SUMMARY.md
+
+@lightsync/frontend/timeline/timeline.js
+@lightsync/frontend/app.js
+
+
+
+export class TimelineCanvas {
+ beatTimes: float[]
+ calibrationOffset: float
+ snapEnabled: boolean
+ snapToBeat(t): float // basic snap method from Plan 02
+ loadBeats(audioPath): Promise
+ render(): void // draws beat marks in layer 4
+}
+
+
+GET /api/audio/beats?path=/app/shows/song.mp3
+Response: { beat_times: float[], onset_times: float[], tempo_bpm: float }
+
+
+
+
+
+
+ Task 1: BeatGrid module + enhanced timeline integration
+ lightsync/frontend/timeline/beats.js, lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js
+ lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js, lightsync/api/audio.py
+
+**1. Create `lightsync/frontend/timeline/beats.js` — BeatGrid class:**
+
+```javascript
+export class BeatGrid {
+ constructor() {
+ this.beatTimes = []; // raw beat times from librosa (never mutated)
+ this.onsetTimes = []; // raw onset times
+ this.tempoBpm = null;
+ this.calibrationOffset = 0.03; // seconds, default 30ms
+ this.snapEnabled = true;
+ this.loading = false;
+ this.error = null;
+ }
+
+ /** Load beat data from backend API */
+ async load(audioPath) {
+ this.loading = true;
+ this.error = null;
+ this.beatTimes = [];
+ this.onsetTimes = [];
+ this.tempoBpm = null;
+ try {
+ const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ this.beatTimes = data.beat_times || [];
+ this.onsetTimes = data.onset_times || [];
+ this.tempoBpm = data.tempo_bpm || null;
+ } catch (e) {
+ this.error = 'BEAT DETECTION FAILED';
+ console.warn('[beats]', e);
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ /** Get calibrated beat times for display */
+ getCalibratedBeats() {
+ return this.beatTimes.map(t => t - this.calibrationOffset);
+ }
+
+ /** Get calibrated onset times for display */
+ getCalibratedOnsets() {
+ return this.onsetTimes.map(t => t - this.calibrationOffset);
+ }
+
+ /** Snap a time value to nearest calibrated beat within threshold */
+ snap(t) {
+ if (!this.snapEnabled || this.beatTimes.length === 0) return t;
+ const SNAP_THRESHOLD = 0.1; // 100ms
+ const calibrated = this.getCalibratedBeats();
+ let nearest = null;
+ let minDist = Infinity;
+ for (const beat of calibrated) {
+ const d = Math.abs(t - beat);
+ if (d < minDist) { minDist = d; nearest = beat; }
+ }
+ return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t;
+ }
+
+ /** Check if beats are loaded and available */
+ get hasBeats() {
+ return this.beatTimes.length > 0;
+ }
+}
+```
+
+**2. Refactor timeline.js to use BeatGrid:**
+
+At the top of timeline.js, add import:
+```javascript
+import { BeatGrid } from './beats.js';
+```
+
+In the constructor, replace the individual beat-related properties:
+- Remove: `this.beatTimes = []`, `this.calibrationOffset = 0.03`, `this.snapEnabled = true`
+- Add: `this.beatGrid = new BeatGrid();`
+
+Update `snapToBeat(t)` method to delegate to beatGrid:
+```javascript
+snapToBeat(t) {
+ return this.beatGrid.snap(t);
+}
+```
+
+Update `loadBeats(audioPath)` to delegate:
+```javascript
+async loadBeats(audioPath) {
+ await this.beatGrid.load(audioPath);
+}
+```
+
+Update beat mark rendering in `render()` (layer 4) to use `this.beatGrid.getCalibratedBeats()` instead of `this.beatTimes`:
+```javascript
+// Beat marks
+if (this.beatGrid.hasBeats) {
+ const calibrated = this.beatGrid.getCalibratedBeats();
+ ctx.strokeStyle = 'rgba(0, 255, 255, 0.4)';
+ ctx.lineWidth = 1;
+ for (const bt of calibrated) {
+ const x = this.timeToX(bt);
+ if (x < HEADER_WIDTH || x > width) continue;
+ ctx.beginPath();
+ ctx.moveTo(x, TIME_AXIS_HEIGHT);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ }
+ // Also draw onset marks as dimmer, shorter ticks
+ const onsets = this.beatGrid.getCalibratedOnsets();
+ ctx.strokeStyle = 'rgba(0, 255, 255, 0.15)';
+ for (const ot of onsets) {
+ const x = this.timeToX(ot);
+ if (x < HEADER_WIDTH || x > width) continue;
+ ctx.beginPath();
+ ctx.moveTo(x, TIME_AXIS_HEIGHT);
+ ctx.lineTo(x, TIME_AXIS_HEIGHT + 8); // short tick at top
+ ctx.stroke();
+ }
+} else if (this.beatGrid.loading) {
+ // Loading indicator
+ ctx.fillStyle = '#555555';
+ ctx.font = '10px monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText('DETECTING BEATS...', HEADER_WIDTH + (width - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14);
+} else if (this.beatGrid.error) {
+ ctx.fillStyle = '#555555';
+ ctx.font = '10px monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(this.beatGrid.error, HEADER_WIDTH + (width - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14);
+}
+```
+
+Add getters/setters to TimelineCanvas for backward compatibility with app.js toolbar wiring:
+```javascript
+get snapEnabled() { return this.beatGrid.snapEnabled; }
+set snapEnabled(v) { this.beatGrid.snapEnabled = v; }
+get calibrationOffset() { return this.beatGrid.calibrationOffset; }
+set calibrationOffset(v) { this.beatGrid.calibrationOffset = v; }
+```
+
+**3. Update app.js:**
+
+The existing toolbar wiring (zoom-slider, beat-offset, btn-snap) from Plan 01 should continue to work through the getters/setters. No changes needed for those.
+
+If tempo is available after beat loading, show it in the header or toolbar. Add after `timeline.loadBeats(path)`:
+```javascript
+// After beats load, update tempo display if available
+timeline.loadBeats(path).then(() => {
+ if (timeline.beatGrid.tempoBpm) {
+ const label = document.getElementById('beat-label');
+ if (label) label.textContent = `${Math.round(timeline.beatGrid.tempoBpm)} BPM`;
+ }
+});
+```
+
+Replace the direct `timeline.loadBeats(path)` call in the audio loadedmetadata handler with the above pattern (call loadBeats and chain the tempo display update).
+
+
+ cd /home/claude/led2 && test -f lightsync/frontend/timeline/beats.js && grep -q "export class BeatGrid" lightsync/frontend/timeline/beats.js && grep -q "getCalibratedBeats" lightsync/frontend/timeline/beats.js && grep -q "snap(" lightsync/frontend/timeline/beats.js && grep -q "SNAP_THRESHOLD" lightsync/frontend/timeline/beats.js && grep -q "calibrationOffset" lightsync/frontend/timeline/beats.js && grep -q "import.*BeatGrid" lightsync/frontend/timeline/timeline.js && grep -q "beatGrid" lightsync/frontend/timeline/timeline.js && grep -q "DETECTING BEATS" lightsync/frontend/timeline/timeline.js && grep -q "getCalibratedBeats\|getCalibratedOnsets" lightsync/frontend/timeline/timeline.js && echo "ALL PASS"
+
+
+ - lightsync/frontend/timeline/beats.js exports BeatGrid class
+ - BeatGrid has load(audioPath) async method fetching from /api/audio/beats
+ - BeatGrid has getCalibratedBeats() returning beat_times adjusted by calibrationOffset
+ - BeatGrid has getCalibratedOnsets() returning onset_times adjusted by calibrationOffset
+ - BeatGrid has snap(t) method with 0.1s threshold using calibrated beats
+ - BeatGrid has loading, error state properties
+ - BeatGrid stores raw beatTimes unmodified (calibration applied only at display/snap time)
+ - timeline.js imports BeatGrid and creates this.beatGrid in constructor
+ - timeline.js render() draws calibrated beat marks in rgba(0,255,255,0.4)
+ - timeline.js render() draws onset marks in rgba(0,255,255,0.15) as short ticks
+ - timeline.js render() shows "DETECTING BEATS..." when beatGrid.loading is true
+ - timeline.js render() shows beatGrid.error message when error is set
+ - timeline.js snapToBeat() delegates to this.beatGrid.snap()
+ - timeline.js has snapEnabled and calibrationOffset getters/setters proxying to beatGrid
+ - BPM display updates in header after beat analysis completes
+
+ BeatGrid module manages all beat data with calibration offset. Beat marks and onset marks render on timeline. Snap-to-beat works through BeatGrid. Loading/error states displayed. BPM shown in header.
+
+
+
+
+
+1. Docker rebuild and restart
+2. Load an audio file
+3. Verify "DETECTING BEATS..." appears briefly during analysis
+4. After analysis: cyan beat marks appear as vertical lines on timeline
+5. Shorter/dimmer onset ticks appear at top of tracks
+6. BPM label updates in header (e.g. "120 BPM")
+7. Adjust BEAT OFFSET slider: beat marks shift left/right in realtime
+8. Place a block near a beat: block snaps to beat position
+9. Toggle SNAP off: block places at exact drop position without snapping
+10. Toggle SNAP on: snapping resumes
+
+
+
+- Beat marks render on timeline from librosa data
+- Calibration offset shifts beat display without modifying raw data
+- Snap-to-beat works for place, move, and resize operations
+- Snap can be toggled on/off
+- Loading and error states shown for beat detection
+
+
+
diff --git a/.planning/phases/04-timeline-editor/04-04-PLAN.md b/.planning/phases/04-timeline-editor/04-04-PLAN.md
new file mode 100644
index 0000000..175163b
--- /dev/null
+++ b/.planning/phases/04-timeline-editor/04-04-PLAN.md
@@ -0,0 +1,423 @@
+---
+phase: 04-timeline-editor
+plan: 04
+type: execute
+wave: 3
+depends_on: [04-02]
+files_modified:
+ - lightsync/frontend/timeline/inspector.js
+ - lightsync/frontend/timeline/timeline.js
+ - lightsync/frontend/timeline/commands.js
+ - lightsync/frontend/app.js
+autonomous: true
+requirements: [TL-04]
+
+must_haves:
+ truths:
+ - "Selecting a block shows the inspector strip between timeline and transport bar"
+ - "Inspector displays animation type label, color picker, speed, direction, and length controls"
+ - "Changing color in inspector updates the block params and is visible on canvas"
+ - "Changing speed/direction/length updates block params through command pattern (undoable)"
+ - "Deselecting a block hides the inspector"
+ - "Remove button in inspector deletes the block"
+ artifacts:
+ - path: "lightsync/frontend/timeline/inspector.js"
+ provides: "BlockInspector class rendering the inspector strip"
+ exports: ["BlockInspector"]
+ min_lines: 60
+ key_links:
+ - from: "lightsync/frontend/timeline/inspector.js"
+ to: "lightsync/frontend/timeline/timeline.js"
+ via: "timeline._onSelectBlock callback triggers inspector show/hide"
+ pattern: "_onSelectBlock"
+ - from: "lightsync/frontend/timeline/inspector.js"
+ to: "lightsync/frontend/timeline/commands.js"
+ via: "param changes create UpdateParamsCommand through history"
+ pattern: "UpdateParamsCommand\|history\\.execute"
+---
+
+
+Build the block inspector panel — a compact horizontal strip that shows animation type, color picker, and parameter controls for the selected block. All parameter changes go through the command pattern for undo/redo support. Per D-01/D-02/D-03 from CONTEXT.md.
+
+Purpose: Without the inspector, users cannot edit block parameters (color, speed, direction). This is the last piece of the timeline editing UX.
+Output: A working inspector strip with color picker and parameter controls, fully integrated with undo/redo.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/04-timeline-editor/04-CONTEXT.md
+@.planning/phases/04-timeline-editor/04-RESEARCH.md
+@.planning/phases/04-timeline-editor/04-UI-SPEC.md
+@.planning/phases/04-timeline-editor/04-02-SUMMARY.md
+
+@lightsync/frontend/timeline/timeline.js
+@lightsync/frontend/timeline/commands.js
+@lightsync/frontend/timeline/history.js
+@lightsync/frontend/index.html
+@lightsync/frontend/style.css
+@lightsync/frontend/app.js
+
+
+
+class TimelineCanvas {
+ selectedBlock: object | null // {id, timestamp, duration, animation, params, device_id}
+ _onSelectBlock: function | null // callback(block) called on select/deselect
+ history: CommandHistory
+ tracks: [{device_id, device_name, strip_type, cues: []}]
+}
+
+
+block.params = {
+ color: '#00ffff', // hex string
+ speed: 1.0, // float 0.1-10
+ direction: 'forward', // 'forward' | 'reverse'
+ length: 0.3 // float 0.05-1.0
+}
+
+
+.block-inspector { height: 40px; display: flex; ... }
+.inspector-label { color: var(--text-accent); font-size: 14px; font-weight: 600; }
+.inspector-field { display: flex; align-items: center; gap: 4px; font-size: 10px; }
+.inspector-remove { margin-left: auto; }
+
+
+
+
+
+
+
+
+
+ Task 1: UpdateParamsCommand + BlockInspector module
+ lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/inspector.js
+ lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/index.html, lightsync/frontend/style.css
+
+**1. Add UpdateParamsCommand to commands.js:**
+
+Add a new command class and export it alongside the existing 4 commands:
+
+```javascript
+export class UpdateParamsCommand {
+ constructor(tracks, block, oldParams, newParams) {
+ this.tracks = tracks;
+ this.block = block;
+ this.oldParams = { ...oldParams }; // shallow copy
+ this.newParams = { ...newParams }; // shallow copy
+ }
+ execute() {
+ Object.assign(this.block.params, this.newParams);
+ syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
+ }
+ undo() {
+ Object.assign(this.block.params, this.oldParams);
+ syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
+ }
+}
+```
+
+Add `UpdateParamsCommand` to the export list.
+
+**2. Create `lightsync/frontend/timeline/inspector.js` — BlockInspector class:**
+
+```javascript
+import { UpdateParamsCommand } from './commands.js';
+
+export class BlockInspector {
+ constructor(containerEl, history) {
+ this.el = containerEl; // #block-inspector div
+ this.history = history; // CommandHistory reference
+ this.tracks = null; // set by timeline
+ this.currentBlock = null;
+ this._colorInput = null; // hidden
+ }
+
+ show(block) {
+ this.currentBlock = block;
+ if (!block) {
+ this.el.style.display = 'none';
+ return;
+ }
+ this.el.style.display = 'flex';
+ this._render(block);
+ }
+
+ hide() {
+ this.currentBlock = null;
+ this.el.style.display = 'none';
+ }
+
+ _render(block) {
+ const animName = (block.animation || 'BLOCK').toUpperCase().replace(/_/g, ' ');
+ const color = block.params.color || '#00ffff';
+ const speed = block.params.speed ?? 1.0;
+ const direction = block.params.direction || 'forward';
+ const length = block.params.length ?? 0.3;
+
+ this.el.innerHTML = `
+ ${animName}
+
+
+
+
+
+
+ `;
+
+ this._wireEvents(block);
+ }
+
+ _wireEvents(block) {
+ // Color swatch → open hidden color input
+ const swatch = this.el.querySelector('#color-swatch');
+ const colorInput = this.el.querySelector('#inspector-color');
+ swatch?.addEventListener('click', () => colorInput?.click());
+
+ colorInput?.addEventListener('input', (e) => {
+ swatch.style.background = e.target.value;
+ });
+ colorInput?.addEventListener('change', (e) => {
+ const oldParams = { ...block.params };
+ const newParams = { ...block.params, color: e.target.value };
+ const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
+ this.history.execute(cmd);
+ });
+
+ // Speed
+ this.el.querySelector('#inspector-speed')?.addEventListener('change', (e) => {
+ const val = parseFloat(e.target.value);
+ if (isNaN(val) || val < 0.1 || val > 10) return;
+ const oldParams = { ...block.params };
+ const newParams = { ...block.params, speed: val };
+ const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
+ this.history.execute(cmd);
+ });
+
+ // Direction
+ this.el.querySelector('#inspector-direction')?.addEventListener('change', (e) => {
+ const oldParams = { ...block.params };
+ const newParams = { ...block.params, direction: e.target.value };
+ const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
+ this.history.execute(cmd);
+ });
+
+ // Length
+ this.el.querySelector('#inspector-length')?.addEventListener('change', (e) => {
+ const val = parseFloat(e.target.value);
+ if (isNaN(val) || val < 0.05 || val > 1) return;
+ const oldParams = { ...block.params };
+ const newParams = { ...block.params, length: val };
+ const cmd = new UpdateParamsCommand(this.tracks, block, oldParams, newParams);
+ this.history.execute(cmd);
+ });
+
+ // Remove button
+ this.el.querySelector('.inspector-remove')?.addEventListener('click', () => {
+ if (this._onDelete) this._onDelete(block);
+ });
+ }
+}
+```
+
+The `_onDelete` callback will be set by app.js to fire a DeleteBlockCommand through the timeline's history.
+
+
+ cd /home/claude/led2 && test -f lightsync/frontend/timeline/inspector.js && grep -q "export class BlockInspector" lightsync/frontend/timeline/inspector.js && grep -q "inspector-color" lightsync/frontend/timeline/inspector.js && grep -q "inspector-speed" lightsync/frontend/timeline/inspector.js && grep -q "inspector-direction" lightsync/frontend/timeline/inspector.js && grep -q "inspector-length" lightsync/frontend/timeline/inspector.js && grep -q "inspector-remove" lightsync/frontend/timeline/inspector.js && grep -q "UpdateParamsCommand" lightsync/frontend/timeline/commands.js && grep -q "UpdateParamsCommand" lightsync/frontend/timeline/inspector.js && echo "ALL PASS"
+
+
+ - lightsync/frontend/timeline/commands.js exports UpdateParamsCommand with execute()/undo() storing old/new params
+ - lightsync/frontend/timeline/inspector.js exports BlockInspector class
+ - BlockInspector.show(block) renders inspector HTML with animation label, color swatch, speed, direction, length inputs
+ - BlockInspector.show(null) or hide() sets display:none
+ - Color swatch click opens hidden ``
+ - Speed input fires UpdateParamsCommand on change
+ - Direction select fires UpdateParamsCommand on change
+ - Length input fires UpdateParamsCommand on change
+ - Remove button calls _onDelete callback
+ - All inspector inputs use font-size 12px, monospace font, var(--bg-panel) background
+ - Color input change event (not input) creates command (avoids flood during drag)
+
+ UpdateParamsCommand added to command suite. BlockInspector renders compact strip with all parameter controls. All changes are undoable.
+
+
+
+ Task 2: Wire inspector into timeline and app.js
+ lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js
+ lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/inspector.js, lightsync/frontend/app.js, lightsync/frontend/index.html
+
+**1. Update app.js initTimeline() to create and wire BlockInspector:**
+
+Add import at top of app.js:
+```javascript
+import { BlockInspector } from './timeline/inspector.js';
+import { DeleteBlockCommand } from './timeline/commands.js';
+```
+
+In initTimeline(), after creating the TimelineCanvas instance, add:
+```javascript
+// Inspector
+const inspectorEl = document.getElementById('block-inspector');
+const inspector = new BlockInspector(inspectorEl, timeline.history);
+inspector.tracks = timeline.tracks;
+
+// Wire timeline selection → inspector
+timeline._onSelectBlock = (block) => {
+ inspector.show(block);
+};
+
+// Wire inspector delete → timeline
+inspector._onDelete = (block) => {
+ const cmd = new DeleteBlockCommand(timeline.tracks, block);
+ timeline.history.execute(cmd);
+ timeline.selectedBlock = null;
+ inspector.hide();
+};
+```
+
+Also, after `timeline.loadTracks(devices)` resolves, update inspector.tracks:
+```javascript
+fetch('/api/devices').then(r => r.json()).then(devices => {
+ timeline.loadTracks(devices);
+ inspector.tracks = timeline.tracks; // keep reference in sync
+}).catch(e => console.warn('[timeline]', e));
+```
+
+**2. Update timeline.js render() to use block color for block fill:**
+
+In the block rendering layer (layer 5 of render()), when drawing block fill, use the block's `params.color` if available to add a color accent to the block. Instead of flat `#0a2a2a`, use a dim version of the block's color:
+
+```javascript
+// In _drawBlocks or block rendering section:
+for (const track of this.tracks) {
+ const trackIdx = this.tracks.indexOf(track);
+ for (const cue of track.cues) {
+ const x = this.timeToX(cue.timestamp);
+ const w = cue.duration * this.pixelsPerSecond;
+ const y = this.trackIndexToY(trackIdx);
+
+ // Skip if off-screen
+ if (x + w < HEADER_WIDTH || x > width) continue;
+
+ const isSelected = this.selectedBlock && this.selectedBlock.id === cue.id;
+
+ // Block fill — use block color at low opacity for visual distinction
+ const blockColor = cue.params?.color || '#00ffff';
+ if (isSelected) {
+ ctx.fillStyle = '#0d3a3a';
+ } else {
+ // Dim the block color: parse hex, set low alpha
+ ctx.fillStyle = blockColor + '1a'; // ~10% opacity hex suffix
+ }
+ ctx.fillRect(x, y + 1, w, TRACK_HEIGHT - 2);
+
+ // Border
+ ctx.strokeStyle = isSelected ? '#00ffff' : '#1a4a4a';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2);
+
+ // Label
+ ctx.fillStyle = '#00ffff';
+ ctx.font = '10px monospace';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ const label = (cue.animation || '').toUpperCase().replace(/_/g, ' ');
+ // Clip label to block width
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x + 4, y, w - 8, TRACK_HEIGHT);
+ ctx.clip();
+ ctx.fillText(label, x + 6, y + TRACK_HEIGHT / 2);
+ ctx.restore();
+ }
+}
+```
+
+This makes color changes immediately visible on the canvas since the block fill incorporates the selected color.
+
+**3. Ensure undo of param change re-renders inspector:**
+
+After undo/redo in the keydown handler (already in timeline.js from Plan 02), if `this.selectedBlock` is still set, re-trigger `_onSelectBlock` to refresh the inspector display:
+
+```javascript
+// In the undo keydown handler:
+if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
+ e.preventDefault();
+ this.history.undo();
+ if (this._onSelectBlock) this._onSelectBlock(this.selectedBlock);
+}
+// Same for redo:
+if ((e.ctrlKey && e.shiftKey && e.key === 'Z') || (e.ctrlKey && e.key === 'y')) {
+ e.preventDefault();
+ this.history.redo();
+ if (this._onSelectBlock) this._onSelectBlock(this.selectedBlock);
+}
+```
+
+This ensures the inspector values refresh when undo/redo changes the selected block's params.
+
+
+ cd /home/claude/led2 && grep -q "import.*BlockInspector" lightsync/frontend/app.js && grep -q "_onSelectBlock" lightsync/frontend/app.js && grep -q "inspector\\.show\|inspector\\.hide" lightsync/frontend/app.js && grep -q "_onDelete" lightsync/frontend/app.js && grep -q "params.*color\|blockColor" lightsync/frontend/timeline/timeline.js && grep -q "_onSelectBlock.*selectedBlock" lightsync/frontend/timeline/timeline.js && echo "ALL PASS"
+
+
+ - app.js imports BlockInspector and DeleteBlockCommand
+ - app.js creates BlockInspector with #block-inspector element and timeline.history
+ - app.js sets timeline._onSelectBlock callback to call inspector.show(block)
+ - app.js sets inspector._onDelete callback to execute DeleteBlockCommand and hide inspector
+ - inspector.tracks is set to timeline.tracks reference
+ - timeline.js block rendering uses block's params.color for fill accent
+ - timeline.js undo/redo handlers re-trigger _onSelectBlock to refresh inspector
+ - Clicking a block shows inspector strip with correct animation name, color, speed, direction, length
+ - Clicking canvas background hides inspector
+ - Changing color in inspector updates block on canvas
+ - Remove button in inspector deletes block and hides inspector
+
+ Inspector fully wired: shows on block select, hides on deselect, all param changes undoable, remove button works, block color visible on canvas, undo/redo refreshes inspector values.
+
+
+
+
+
+1. Docker rebuild and restart
+2. Place a block on timeline — click it — inspector appears between timeline and transport bar (per D-03)
+3. Inspector shows: animation label in cyan uppercase, color swatch, SPEED input, DIR select, LEN input, remove button (per D-02)
+4. Click color swatch — native color picker opens
+5. Change color — block on canvas updates to show new color accent
+6. Change speed to 2.0 — press Ctrl+Z — speed reverts to 1.0 (undo works for params)
+7. Click remove button — block deleted, inspector hides
+8. Ctrl+Z — block reappears, click it — inspector shows with original params
+9. Click empty canvas area — inspector hides (per D-01: hidden when nothing selected)
+
+
+
+- Inspector panel shows/hides based on block selection (D-01)
+- Inspector displays label, color, speed, direction, length in horizontal strip (D-02)
+- Layout is TIMELINE > INSPECTOR > TRANSPORT (D-03)
+- All parameter changes go through command pattern (undoable)
+- Block color is visually reflected on canvas
+- Remove button deletes block via command pattern
+
+
+