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

@@ -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 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 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 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: Plans:
- [ ] 04-01: Timeline canvas — per-device tracks, time axis, block rendering, playback cursor - [ ] 04-01: Timeline canvas — per-device tracks, time axis, block rendering, playback cursor

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>