Files
led2/.planning/phases/04-timeline-editor/04-02-PLAN.md
2026-04-06 23:24:11 +00:00

30 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-timeline-editor 02 execute 2
04-01
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
true
TL-02
TL-03
TL-06
truths artifacts key_links
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
path provides exports min_lines
lightsync/frontend/timeline/commands.js Command classes: PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand
PlaceBlockCommand
MoveBlockCommand
ResizeBlockCommand
DeleteBlockCommand
80
path provides exports min_lines
lightsync/frontend/timeline/history.js CommandHistory with undoStack, redoStack, execute(), undo(), redo()
CommandHistory
30
path provides contains
lightsync/api/timeline.py Block CRUD REST endpoints router = APIRouter
from to via pattern
lightsync/frontend/timeline/commands.js timeline.js tracks state Commands mutate track.cues arrays directly track.cues
from to via pattern
lightsync/frontend/timeline/timeline.js lightsync/frontend/timeline/history.js timeline calls history.execute(cmd) for all mutations history.execute
from to via pattern
lightsync/frontend/timeline/commands.js /api/shows/{show_id}/tracks/{device_id}/blocks async fetch calls on execute/undo for backend sync api/shows
from to via pattern
lightsync/api/timeline.py lightsync/models/show.py CueModel creation and mutation 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/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:**
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):

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:

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.

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).

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:

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:

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" <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> 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:

import { CommandHistory } from './history.js';
import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js';

Add to constructor:

this.history = new CommandHistory(50);
this.showId = null;  // set when a show is loaded

Add hitTest(mouseX, mouseY) method:

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:

_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:

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():

_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:

_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:

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:

// 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" <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> 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

<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>
After completion, create `.planning/phases/04-timeline-editor/04-02-SUMMARY.md`