--- phase: 04-timeline-editor plan: 02 type: execute wave: 2 depends_on: [04-01] files_modified: - lightsync/frontend/timeline/commands.js - lightsync/frontend/timeline/history.js - lightsync/frontend/timeline/timeline.js - lightsync/frontend/app.js - lightsync/api/timeline.py - lightsync/main.py autonomous: true requirements: [TL-02, TL-03, TL-06] must_haves: truths: - "Animation blocks can be dragged from the sidebar palette and placed on a track" - "Blocks can be moved by dragging the center region" - "Blocks can be resized by dragging left or right edge handles (8px hit zone)" - "Blocks can be deleted with the Delete key" - "Undo (Ctrl+Z) and Redo (Ctrl+Shift+Z) work for all mutations with at least 50 steps" - "Block overlap on the same track is rejected" artifacts: - path: "lightsync/frontend/timeline/commands.js" provides: "Command classes: PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand" exports: ["PlaceBlockCommand", "MoveBlockCommand", "ResizeBlockCommand", "DeleteBlockCommand"] min_lines: 80 - path: "lightsync/frontend/timeline/history.js" provides: "CommandHistory with undoStack, redoStack, execute(), undo(), redo()" exports: ["CommandHistory"] min_lines: 30 - path: "lightsync/api/timeline.py" provides: "Block CRUD REST endpoints" contains: "router = APIRouter" key_links: - from: "lightsync/frontend/timeline/commands.js" to: "timeline.js tracks state" via: "Commands mutate track.cues arrays directly" pattern: "track\\.cues" - from: "lightsync/frontend/timeline/timeline.js" to: "lightsync/frontend/timeline/history.js" via: "timeline calls history.execute(cmd) for all mutations" pattern: "history\\.execute" - from: "lightsync/frontend/timeline/commands.js" to: "/api/shows/{show_id}/tracks/{device_id}/blocks" via: "async fetch calls on execute/undo for backend sync" pattern: "api/shows" - from: "lightsync/api/timeline.py" to: "lightsync/models/show.py" via: "CueModel creation and mutation" pattern: "CueModel" --- Add block interaction capabilities — drag-and-place from palette, move, resize, delete — all wrapped in a command pattern with undo/redo support (min 50 steps). Includes backend block CRUD endpoints. Purpose: This is the core editing capability. Without it, the timeline is a static display. The command pattern ensures all mutations are reversible. Output: Fully interactive block manipulation with undo/redo, backed by REST API persistence. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-timeline-editor/04-CONTEXT.md @.planning/phases/04-timeline-editor/04-RESEARCH.md @.planning/phases/04-timeline-editor/04-UI-SPEC.md @.planning/phases/04-timeline-editor/04-01-SUMMARY.md @lightsync/models/show.py @lightsync/frontend/timeline/timeline.js @lightsync/frontend/app.js @lightsync/api/shows.py @lightsync/main.py export class TimelineCanvas { constructor(canvasEl, audioEl) timeToX(t), xToTime(x), trackIndexToY(i), yToTrackIndex(y) render() startRenderLoop() loadTracks(devices) tracks: [{device_id, device_name, strip_type, cues: []}] selectedBlock: CueModel | null _dragState: object | null _onSelectBlock: function | null snapEnabled: boolean beatTimes: float[] calibrationOffset: float pixelsPerSecond: number scrollX: number } class CueModel(BaseModel): id: UUID timestamp: float duration: float = 4.0 mode: Literal["animation", "frame_sequence"] = "animation" animation: str | None = None params: dict[str, Any] = {} class ShowModel(BaseModel): tracks: list[TrackModel] # TrackModel has device_id: UUID, cues: list[CueModel] router = APIRouter(tags=["shows"]) # Endpoints: GET/POST /api/shows, GET /api/shows/{show_id} # state.show_store.save(show), state.show_store.load(show_id) # app.include_router(shows.router, prefix="/api/shows") # Module-level: show_store, registry Task 1: Command pattern + history + backend block CRUD lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/api/timeline.py, lightsync/main.py lightsync/frontend/timeline/timeline.js, lightsync/models/show.py, lightsync/api/shows.py, lightsync/main.py, lightsync/frontend/app.js **1. Create `lightsync/frontend/timeline/history.js` — CommandHistory class:** ```javascript export class CommandHistory { constructor(maxSteps = 50) { this.undoStack = []; this.redoStack = []; this.maxSteps = maxSteps; } execute(cmd) { cmd.execute(); this.undoStack.push(cmd); if (this.undoStack.length > this.maxSteps) this.undoStack.shift(); this.redoStack = []; // clear redo on new action (prevents branching corruption) } undo() { const cmd = this.undoStack.pop(); if (!cmd) return false; cmd.undo(); this.redoStack.push(cmd); return true; } redo() { const cmd = this.redoStack.pop(); if (!cmd) return false; cmd.execute(); this.undoStack.push(cmd); return true; } get canUndo() { return this.undoStack.length > 0; } get canRedo() { return this.redoStack.length > 0; } } ``` **2. Create `lightsync/frontend/timeline/commands.js` — Command classes:** Each command stores the data needed for execute and undo. Commands mutate the `tracks` array (passed by reference from TimelineCanvas). Backend sync is fire-and-forget fetch (no await during rapid undo/redo). Generate a UUID for new blocks using `crypto.randomUUID()`. Helper function for backend sync (top of file): ```javascript function syncBlock(showId, deviceId, block, method) { if (!showId) return; // no show loaded yet — skip sync const base = `/api/shows/${showId}/tracks/${deviceId}/blocks`; const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (method === 'POST' || method === 'PATCH') { opts.body = JSON.stringify(block); } const url = method === 'POST' ? base : `${base}/${block.id}`; fetch(url, opts).catch(e => console.warn('[sync]', e)); } ``` **PlaceBlockCommand:** ```javascript export class PlaceBlockCommand { constructor(tracks, block) { this.tracks = tracks; this.block = block; // {id, timestamp, duration, animation, params, device_id} } execute() { const track = this.tracks.find(t => t.device_id === this.block.device_id); if (track) track.cues.push(this.block); syncBlock(this._showId, this.block.device_id, this.block, 'POST'); } undo() { const track = this.tracks.find(t => t.device_id === this.block.device_id); if (track) track.cues = track.cues.filter(c => c.id !== this.block.id); syncBlock(this._showId, this.block.device_id, this.block, 'DELETE'); } } ``` **MoveBlockCommand:** Stores `oldTimestamp` and `newTimestamp`. ```javascript export class MoveBlockCommand { constructor(tracks, block, oldTimestamp, newTimestamp) { this.tracks = tracks; this.block = block; this.oldTimestamp = oldTimestamp; this.newTimestamp = newTimestamp; } execute() { this.block.timestamp = this.newTimestamp; syncBlock(this._showId, this.block.device_id, this.block, 'PATCH'); } undo() { this.block.timestamp = this.oldTimestamp; syncBlock(this._showId, this.block.device_id, this.block, 'PATCH'); } } ``` **ResizeBlockCommand:** Stores old/new values for both `timestamp` and `duration` (left-edge resize changes both, right-edge resize changes only duration). ```javascript export class ResizeBlockCommand { constructor(tracks, block, oldTimestamp, oldDuration, newTimestamp, newDuration) { this.tracks = tracks; this.block = block; this.oldTimestamp = oldTimestamp; this.oldDuration = oldDuration; this.newTimestamp = newTimestamp; this.newDuration = newDuration; } execute() { this.block.timestamp = this.newTimestamp; this.block.duration = this.newDuration; syncBlock(this._showId, this.block.device_id, this.block, 'PATCH'); } undo() { this.block.timestamp = this.oldTimestamp; this.block.duration = this.oldDuration; syncBlock(this._showId, this.block.device_id, this.block, 'PATCH'); } } ``` **DeleteBlockCommand:** ```javascript export class DeleteBlockCommand { constructor(tracks, block) { this.tracks = tracks; this.block = block; } execute() { const track = this.tracks.find(t => t.device_id === this.block.device_id); if (track) track.cues = track.cues.filter(c => c.id !== this.block.id); syncBlock(this._showId, this.block.device_id, this.block, 'DELETE'); } undo() { const track = this.tracks.find(t => t.device_id === this.block.device_id); if (track) track.cues.push(this.block); syncBlock(this._showId, this.block.device_id, this.block, 'POST'); } } ``` All command classes must also export a `setShowId(id)` module-level function that sets `_showId` on future commands (or store it as module-level variable used by `syncBlock`). Alternatively, make `_showId` a module-level `let currentShowId = null;` and export `setCurrentShowId(id)`. **3. Create `lightsync/api/timeline.py` — Block CRUD router:** ```python from uuid import UUID from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import Any, Optional import lightsync.main as state router = APIRouter(tags=["timeline"]) class BlockCreate(BaseModel): id: str # UUID as string from frontend crypto.randomUUID() timestamp: float duration: float = 4.0 animation: str | None = None params: dict[str, Any] = {} class BlockUpdate(BaseModel): timestamp: float | None = None duration: float | None = None animation: str | None = None params: dict[str, Any] | None = None @router.post("/shows/{show_id}/tracks/{device_id}/blocks", status_code=201) async def create_block(show_id: str, device_id: str, block: BlockCreate): show = await state.show_store.load(show_id) if show is None: raise HTTPException(404, "Show not found") track = next((t for t in show.tracks if str(t.device_id) == device_id), None) if track is None: # Auto-create track for this device from lightsync.models.show import TrackModel, CueModel cue = CueModel( id=UUID(block.id), timestamp=block.timestamp, duration=block.duration, animation=block.animation, params=block.params ) track = TrackModel(device_id=UUID(device_id), cues=[cue]) show.tracks.append(track) else: from lightsync.models.show import CueModel cue = CueModel( id=UUID(block.id), timestamp=block.timestamp, duration=block.duration, animation=block.animation, params=block.params ) track.cues.append(cue) await state.show_store.save(show) return {"status": "ok"} @router.patch("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}") async def update_block(show_id: str, device_id: str, block_id: str, update: BlockUpdate): show = await state.show_store.load(show_id) if show is None: raise HTTPException(404, "Show not found") track = next((t for t in show.tracks if str(t.device_id) == device_id), None) if track is None: raise HTTPException(404, "Track not found") cue = next((c for c in track.cues if str(c.id) == block_id), None) if cue is None: raise HTTPException(404, "Block not found") if update.timestamp is not None: cue.timestamp = update.timestamp if update.duration is not None: cue.duration = update.duration if update.animation is not None: cue.animation = update.animation if update.params is not None: cue.params = update.params await state.show_store.save(show) return {"status": "ok"} @router.delete("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}") async def delete_block(show_id: str, device_id: str, block_id: str): show = await state.show_store.load(show_id) if show is None: raise HTTPException(404, "Show not found") track = next((t for t in show.tracks if str(t.device_id) == device_id), None) if track is None: raise HTTPException(404, "Track not found") track.cues = [c for c in track.cues if str(c.id) != block_id] await state.show_store.save(show) return {"status": "ok"} ``` **4. Register router in `lightsync/main.py`:** Add import: `from lightsync.api import timeline` Add router: `app.include_router(timeline.router, prefix="/api")` Place the import alongside existing `from lightsync.api import devices, shows, audio, ws` and the `include_router` alongside existing router registrations. cd /home/claude/led2 && test -f lightsync/frontend/timeline/commands.js && test -f lightsync/frontend/timeline/history.js && test -f lightsync/api/timeline.py && grep -q "PlaceBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "MoveBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "ResizeBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "DeleteBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "class CommandHistory" lightsync/frontend/timeline/history.js && grep -q "undoStack" lightsync/frontend/timeline/history.js && grep -q "redoStack" lightsync/frontend/timeline/history.js && grep -q "timeline" lightsync/main.py && python -c "from lightsync.api.timeline import router; print('router OK')" && echo "ALL PASS" - lightsync/frontend/timeline/history.js exports CommandHistory with execute(), undo(), redo(), canUndo, canRedo - history.js constructor takes maxSteps=50 and enforces it via shift() - history.js redo clears redoStack on new execute (branching protection) - lightsync/frontend/timeline/commands.js exports PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand - Each command has execute() and undo() methods - commands.js contains syncBlock helper that does fire-and-forget fetch to /api/shows/{showId}/tracks/{deviceId}/blocks - lightsync/api/timeline.py contains POST /shows/{show_id}/tracks/{device_id}/blocks endpoint - lightsync/api/timeline.py contains PATCH /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint - lightsync/api/timeline.py contains DELETE /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint - lightsync/main.py imports timeline router and includes it with prefix="/api" - `python -c "from lightsync.api.timeline import router"` succeeds Command pattern (4 command classes) + CommandHistory + backend block CRUD endpoints registered and importable Task 2: Wire mouse interactions into TimelineCanvas lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/app.js, lightsync/frontend/index.html **1. Add interaction methods to `timeline.js`:** Import at top of timeline.js: ```javascript import { CommandHistory } from './history.js'; import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js'; ``` Add to constructor: ```javascript this.history = new CommandHistory(50); this.showId = null; // set when a show is loaded ``` **Add `hitTest(mouseX, mouseY)` method:** ```javascript hitTest(mouseX, mouseY) { const EDGE = 8; for (const track of this.tracks) { const trackIdx = this.tracks.indexOf(track); const y = this.trackIndexToY(trackIdx); if (mouseY < y || mouseY > y + TRACK_HEIGHT) continue; // Iterate cues in reverse (last placed = on top) for (let i = track.cues.length - 1; i >= 0; i--) { const cue = track.cues[i]; const x = this.timeToX(cue.timestamp); const w = cue.duration * this.pixelsPerSecond; if (mouseX < x || mouseX > x + w) continue; if (mouseX < x + EDGE) return { block: cue, handle: 'resize-left', trackIdx }; if (mouseX > x + w - EDGE) return { block: cue, handle: 'resize-right', trackIdx }; return { block: cue, handle: 'move', trackIdx }; } } return null; } ``` **Add overlap check method:** ```javascript _hasOverlap(track, block, startTime, duration) { const end = startTime + duration; return track.cues.some(c => { if (c.id === block.id) return false; // skip self const cEnd = c.timestamp + c.duration; return startTime < cEnd && end > c.timestamp; }); } ``` **Add snap helper:** ```javascript snapToBeat(t) { if (!this.snapEnabled || this.beatTimes.length === 0) return t; const SNAP_THRESHOLD = 0.1; // 100ms let nearest = null, minDist = Infinity; for (const beat of this.beatTimes) { const adjusted = beat - this.calibrationOffset; const d = Math.abs(t - adjusted); if (d < minDist) { minDist = d; nearest = adjusted; } } return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t; } ``` **Add `_setupInteractions()` method** — call from constructor AFTER _resizeCanvas(): ```javascript _setupInteractions() { const canvas = this.canvas; canvas.addEventListener('mousedown', (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // Check if in header or time axis area — ignore if (mx < HEADER_WIDTH || my < TIME_AXIS_HEIGHT) return; const hit = this.hitTest(mx, my); if (!hit) { // Clicked on empty canvas — deselect this.selectedBlock = null; if (this._onSelectBlock) this._onSelectBlock(null); return; } // Select the clicked block this.selectedBlock = hit.block; if (this._onSelectBlock) this._onSelectBlock(hit.block); if (hit.handle === 'move') { this._startMove(e, hit.block, mx); } else if (hit.handle === 'resize-left' || hit.handle === 'resize-right') { this._startResize(e, hit.block, hit.handle, mx); } }); // Update cursor on hover canvas.addEventListener('mousemove', (e) => { if (this._dragState) return; // during drag, document handles mousemove const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const hit = this.hitTest(mx, my); if (hit && (hit.handle === 'resize-left' || hit.handle === 'resize-right')) { canvas.style.cursor = 'ew-resize'; } else if (hit && hit.handle === 'move') { canvas.style.cursor = 'grab'; } else { canvas.style.cursor = 'default'; } }); // Delete key document.addEventListener('keydown', (e) => { if (e.key === 'Delete' && this.selectedBlock) { const cmd = new DeleteBlockCommand(this.tracks, this.selectedBlock); this.history.execute(cmd); this.selectedBlock = null; if (this._onSelectBlock) this._onSelectBlock(null); } // Undo: Ctrl+Z if (e.ctrlKey && !e.shiftKey && e.key === 'z') { e.preventDefault(); this.history.undo(); } // Redo: Ctrl+Shift+Z or Ctrl+Y if ((e.ctrlKey && e.shiftKey && e.key === 'Z') || (e.ctrlKey && e.key === 'y')) { e.preventDefault(); this.history.redo(); } }); } ``` **Add `_startMove(e, block, startMX)` method:** ```javascript _startMove(e, block, startMX) { const oldTimestamp = block.timestamp; const startTime = block.timestamp; const startScrollX = this.scrollX; this.canvas.style.cursor = 'grabbing'; const onDrag = (ev) => { const rect = this.canvas.getBoundingClientRect(); const mx = ev.clientX - rect.left; const deltaX = mx - startMX; const deltaTime = deltaX / this.pixelsPerSecond; block.timestamp = Math.max(0, startTime + deltaTime); }; const onUp = () => { document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', onUp); this.canvas.style.cursor = 'default'; // Snap block.timestamp = this.snapToBeat(block.timestamp); block.timestamp = Math.max(0, block.timestamp); // Check overlap const track = this.tracks.find(t => t.device_id === block.device_id); if (track && this._hasOverlap(track, block, block.timestamp, block.duration)) { block.timestamp = oldTimestamp; // revert return; } if (block.timestamp !== oldTimestamp) { // Reset to old for command to execute properly block.timestamp = oldTimestamp; const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, this.snapToBeat(Math.max(0, startTime + (block.timestamp - oldTimestamp) || (this.snapToBeat(Math.max(0, startTime + (0))) )))); // Simpler: just compute final time const finalTime = this.snapToBeat(Math.max(0, startTime + ((e.clientX - e.clientX) / this.pixelsPerSecond))); // Actually: just record the snapped value before reverting } }; // Simpler approach — store snapped result: let finalTimestamp = oldTimestamp; const onDrag2 = (ev) => { const rect = this.canvas.getBoundingClientRect(); const mx = ev.clientX - rect.left; const deltaX = mx - startMX; const deltaTime = deltaX / this.pixelsPerSecond; block.timestamp = Math.max(0, startTime + deltaTime); }; const onUp2 = () => { document.removeEventListener('mousemove', onDrag2); document.removeEventListener('mouseup', onUp2); this.canvas.style.cursor = 'default'; const snapped = this.snapToBeat(Math.max(0, block.timestamp)); const track = this.tracks.find(t => t.device_id === block.device_id); if (track && this._hasOverlap(track, block, snapped, block.duration)) { block.timestamp = oldTimestamp; return; } block.timestamp = oldTimestamp; // revert for command if (snapped !== oldTimestamp) { const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, snapped); this.history.execute(cmd); } }; document.addEventListener('mousemove', onDrag2); document.addEventListener('mouseup', onUp2, { once: true }); } ``` IMPORTANT: The above `_startMove` has redundant code from drafting. The executor MUST implement it cleanly as a single version. The logic is: 1. Record `oldTimestamp = block.timestamp` 2. On mousemove: update `block.timestamp` live (visual feedback only) 3. On mouseup: compute `snapped = snapToBeat(max(0, block.timestamp))`, check overlap, revert `block.timestamp = oldTimestamp`, then if snapped !== oldTimestamp execute `MoveBlockCommand(tracks, block, oldTimestamp, snapped)` **Add `_startResize(e, block, handle, startMX)` method:** Same pattern. Record `oldTimestamp`, `oldDuration`. On mousemove: if handle is 'resize-right', adjust duration = max(0.5, (mx - blockStartX) / pixelsPerSecond). If handle is 'resize-left', adjust both timestamp and duration so the right edge stays fixed. On mouseup: snap, check overlap, revert, execute `ResizeBlockCommand`. Minimum duration is 0.5 seconds. **Add `handleDrop(animationType, mouseX, mouseY)` method** — called when an animation palette item is dropped on canvas: ```javascript handleDrop(animationType, mouseX, mouseY) { const trackIdx = this.yToTrackIndex(mouseY); if (trackIdx < 0 || trackIdx >= this.tracks.length) return; const track = this.tracks[trackIdx]; const rawTime = Math.max(0, this.xToTime(mouseX)); const time = this.snapToBeat(rawTime); const duration = 4.0; // default duration if (this._hasOverlap(track, {id: null}, time, duration)) return; // reject overlap const block = { id: crypto.randomUUID(), device_id: track.device_id, timestamp: time, duration: duration, animation: animationType, params: { color: '#00ffff', speed: 1.0, direction: 'forward', length: 0.3 } }; const cmd = new PlaceBlockCommand(this.tracks, block); this.history.execute(cmd); this.selectedBlock = block; if (this._onSelectBlock) this._onSelectBlock(block); } ``` **2. Wire drag-from-palette in app.js:** In the `initTimeline()` function (after palette creation), add drag handling for the animation palette items: ```javascript // Drag from palette to canvas const palette = document.getElementById('animation-palette'); const canvasEl = document.getElementById('timeline-canvas'); palette?.addEventListener('dragstart', (e) => { const tile = e.target.closest('.anim-tile'); if (!tile) return; e.dataTransfer.setData('text/plain', tile.dataset.anim); e.dataTransfer.effectAllowed = 'copy'; }); canvasEl?.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; // Draw ghost preview — update drag state for rendering const rect = canvasEl.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const trackIdx = timeline.yToTrackIndex(my); const time = timeline.xToTime(mx); timeline._dragState = { ghost: true, trackIdx: trackIdx, time: Math.max(0, time), duration: 4.0 }; }); canvasEl?.addEventListener('dragleave', () => { timeline._dragState = null; }); canvasEl?.addEventListener('drop', (e) => { e.preventDefault(); const animType = e.dataTransfer.getData('text/plain'); if (!animType) return; const rect = canvasEl.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; timeline.handleDrop(animType, mx, my); timeline._dragState = null; }); ``` Also set `draggable="true"` on palette items (already done in Plan 01 HTML generation). cd /home/claude/led2 && grep -q "hitTest" lightsync/frontend/timeline/timeline.js && grep -q "handleDrop" lightsync/frontend/timeline/timeline.js && grep -q "_startMove" lightsync/frontend/timeline/timeline.js && grep -q "_startResize" lightsync/frontend/timeline/timeline.js && grep -q "snapToBeat" lightsync/frontend/timeline/timeline.js && grep -q "_hasOverlap" lightsync/frontend/timeline/timeline.js && grep -q "_setupInteractions" lightsync/frontend/timeline/timeline.js && grep -q "Ctrl.*Z\|ctrlKey" lightsync/frontend/timeline/timeline.js && grep -q "dragstart\|dragover\|drop" lightsync/frontend/app.js && echo "ALL PASS" - timeline.js imports CommandHistory from history.js - timeline.js imports all 4 command classes from commands.js - timeline.js constructor creates `this.history = new CommandHistory(50)` - timeline.js has hitTest() method with 8px EDGE for resize handles - timeline.js has _hasOverlap() method checking time range collisions - timeline.js has snapToBeat() with 0.1s threshold and calibrationOffset - timeline.js has _setupInteractions() with mousedown, mousemove, keydown listeners - timeline.js has _startMove() attaching mousemove/mouseup to document (not canvas) - timeline.js has _startResize() with minimum 0.5s duration - timeline.js has handleDrop() creating new block with crypto.randomUUID() - Keydown listener handles Delete key, Ctrl+Z (undo), Ctrl+Shift+Z (redo), Ctrl+Y (redo) - app.js has dragstart listener on palette, dragover/dragleave/drop on canvas - Ghost block state set during dragover, cleared on dragleave/drop Full block interaction: drag-and-place from palette, move by dragging center, resize by dragging edges, delete with Delete key, undo/redo with Ctrl+Z/Ctrl+Shift+Z. Overlap prevention on same track. All mutations go through CommandHistory. 1. Docker rebuild and restart 2. Add a device via sidebar, load audio 3. Drag animation from ANIMATIONS palette onto a track — block appears 4. Click block center and drag — block moves, snaps to nearest beat on release 5. Drag block right edge — block resizes 6. Press Delete with block selected — block disappears 7. Press Ctrl+Z — deleted block reappears (undo) 8. Press Ctrl+Shift+Z — block disappears again (redo) 9. Attempt to place block overlapping existing — placement rejected 10. Rapidly perform 50+ operations, then undo all — verify all revert correctly - Blocks can be placed on tracks via drag-and-drop from palette - Blocks can be moved and resized with snap-to-beat - Delete key removes selected block - Undo/redo works for all operations with at least 50 steps - Block overlap is prevented - Backend block CRUD endpoints accept requests After completion, create `.planning/phases/04-timeline-editor/04-02-SUMMARY.md`