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 |
|
|
true |
|
|
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
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:
- Record
oldTimestamp = block.timestamp - On mousemove: update
block.timestamplive (visual feedback only) - On mouseup: compute
snapped = snapToBeat(max(0, block.timestamp)), check overlap, revertblock.timestamp = oldTimestamp, then if snapped !== oldTimestamp executeMoveBlockCommand(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.
<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>