docs(04): create phase plan

This commit is contained in:
Claude
2026-04-06 23:24:11 +00:00
parent 5f46f6f8a1
commit e8e0532972
5 changed files with 2007 additions and 1 deletions

View File

@@ -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"
---
<objective>
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.
</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/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
<interfaces>
<!-- From lightsync/models/show.py -->
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)
<!-- From lightsync/frontend/app.js -->
// WebSocket client: client.send({ type, ... })
// Audio: document.getElementById('player') — HTML5 audio element
// API: apiPost(path, data), apiDelete(path)
// Devices: loadDevices() populates #device-list
<!-- CSS variables from style.css -->
--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
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Data model changes + HTML layout restructure</name>
<files>lightsync/models/show.py, lightsync/frontend/index.html</files>
<read_first>lightsync/models/show.py, lightsync/frontend/index.html, lightsync/frontend/style.css</read_first>
<action>
**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 `<main class="main-area">` (the `.section-header` ANIMATIONS div and the `#animation-grid` div) with this structure:
```html
<main class="main-area">
<div id="timeline-toolbar" class="timeline-toolbar">
<label class="toolbar-field">
<span class="toolbar-label">ZOOM</span>
<input type="range" id="zoom-slider" min="20" max="400" step="10" value="100">
</label>
<div class="toolbar-sep"></div>
<label class="toolbar-field">
<span class="toolbar-label">BEAT OFFSET</span>
<input type="number" id="beat-offset" step="0.005" min="-0.2" max="0.2" value="0.030">
<span class="toolbar-unit">s</span>
</label>
<div class="toolbar-sep"></div>
<button id="btn-snap" class="transport-btn snap-btn active" title="Snap to beat">SNAP</button>
</div>
<canvas id="timeline-canvas"></canvas>
<div id="block-inspector" class="block-inspector" style="display:none;"></div>
</main>
```
Move the ANIMATIONS section into the sidebar. Add a second panel BELOW the existing DEVICES panel (inside `<aside class="sidebar">`), after the closing `</div>` of the first `.panel`:
```html
<div class="panel" style="flex: 0 0 auto;">
<div class="panel-header">ANIMATIONS</div>
<div class="panel-content animation-list" id="animation-palette">
</div>
</div>
```
The animation palette will be populated dynamically by JS (Task 2 wires this). For now the empty container is sufficient. Remove the old hardcoded `.anim-tile` buttons from `main-area`.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>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</done>
</task>
<task type="auto">
<name>Task 2: TimelineCanvas class + CSS + app.js wiring</name>
<files>lightsync/frontend/timeline/timeline.js, lightsync/frontend/style.css, lightsync/frontend/app.js</files>
<read_first>lightsync/frontend/index.html, lightsync/frontend/style.css, lightsync/frontend/app.js, lightsync/api/audio.py</read_first>
<action>
**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 => `
<button class="anim-tile" data-anim="${name}" draggable="true">
<span class="anim-cursor">&gt;</span>
<span class="anim-name">${name.toUpperCase().replace('_', ' ')}</span>
</button>
`).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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-timeline-editor/04-01-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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.
</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/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
<interfaces>
<!-- From timeline.js (Plan 01 output) -->
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
}
<!-- From show.py -->
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]
<!-- From shows.py -->
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)
<!-- From main.py -->
# app.include_router(shows.router, prefix="/api/shows")
# Module-level: show_store, registry
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Command pattern + history + backend block CRUD</name>
<files>lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/api/timeline.py, lightsync/main.py</files>
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/models/show.py, lightsync/api/shows.py, lightsync/main.py, lightsync/frontend/app.js</read_first>
<action>
**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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>Command pattern (4 command classes) + CommandHistory + backend block CRUD endpoints registered and importable</done>
</task>
<task type="auto">
<name>Task 2: Wire mouse interactions into TimelineCanvas</name>
<files>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/app.js, lightsync/frontend/index.html</read_first>
<action>
**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).
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-timeline-editor/04-02-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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.
</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/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
<interfaces>
<!-- From timeline.js (after Plan 01+02) -->
export class TimelineCanvas {
beatTimes: float[]
calibrationOffset: float
snapEnabled: boolean
snapToBeat(t): float // basic snap method from Plan 02
loadBeats(audioPath): Promise<void>
render(): void // draws beat marks in layer 4
}
<!-- From /api/audio/beats endpoint (Phase 2) -->
GET /api/audio/beats?path=/app/shows/song.mp3
Response: { beat_times: float[], onset_times: float[], tempo_bpm: float }
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: BeatGrid module + enhanced timeline integration</name>
<files>lightsync/frontend/timeline/beats.js, lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js, lightsync/api/audio.py</read_first>
<action>
**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).
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-timeline-editor/04-03-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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.
</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/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
<interfaces>
<!-- From timeline.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 structure (from CueModel) -->
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
}
<!-- From style.css (Plan 01) -->
.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; }
<!-- Inspector HTML container in index.html -->
<div id="block-inspector" class="block-inspector" style="display:none;"></div>
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: UpdateParamsCommand + BlockInspector module</name>
<files>lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/inspector.js</files>
<read_first>lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/index.html, lightsync/frontend/style.css</read_first>
<action>
**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 <input type="color">
}
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 = `
<span class="inspector-label">${animName}</span>
<label class="inspector-field">
COLOR
<div class="inspector-color-swatch" id="color-swatch" style="background:${color};" title="Click to change color"></div>
<input type="color" id="inspector-color" value="${color}" style="display:none;">
</label>
<label class="inspector-field">
SPEED
<input type="number" id="inspector-speed" step="0.1" min="0.1" max="10" value="${speed}">
</label>
<label class="inspector-field">
DIR
<select id="inspector-direction">
<option value="forward" ${direction === 'forward' ? 'selected' : ''}>FWD</option>
<option value="reverse" ${direction === 'reverse' ? 'selected' : ''}>REV</option>
</select>
</label>
<label class="inspector-field">
LEN
<input type="number" id="inspector-length" step="0.05" min="0.05" max="1" value="${length}">
</label>
<div style="flex:1"></div>
<button class="inspector-remove" title="Remove block">&#10005;</button>
`;
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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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 `<input type="color">`
- 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)
</acceptance_criteria>
<done>UpdateParamsCommand added to command suite. BlockInspector renders compact strip with all parameter controls. All changes are undoable.</done>
</task>
<task type="auto">
<name>Task 2: Wire inspector into timeline and app.js</name>
<files>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/inspector.js, lightsync/frontend/app.js, lightsync/frontend/index.html</read_first>
<action>
**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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
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)
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-timeline-editor/04-04-SUMMARY.md`
</output>