diff --git a/.planning/phases/04-timeline-editor/04-UI-SPEC.md b/.planning/phases/04-timeline-editor/04-UI-SPEC.md
new file mode 100644
index 0000000..128101d
--- /dev/null
+++ b/.planning/phases/04-timeline-editor/04-UI-SPEC.md
@@ -0,0 +1,318 @@
+---
+phase: 4
+slug: timeline-editor
+status: draft
+shadcn_initialized: false
+preset: none
+created: 2026-04-06
+---
+
+# Phase 4 — UI Design Contract
+
+> Visual and interaction contract for the Timeline Editor phase. Generated by gsd-ui-researcher.
+> Stack is plain HTML5/CSS/JS served by FastAPI — no React, no shadcn, no build step.
+
+---
+
+## Design System
+
+| Property | Value |
+|----------|-------|
+| Tool | none — hand-rolled CSS variables |
+| Preset | not applicable |
+| Component library | none — plain DOM elements |
+| Icon library | Unicode glyphs inline (▶, ■, ▼, ✕, >) — no icon font |
+| Font | JetBrains Mono, Fira Code, Consolas, monospace (system fallback chain) |
+
+Source: `lightsync/frontend/style.css` — CSS variable declarations at `:root`.
+
+---
+
+## Spacing Scale
+
+Declared values (must be multiples of 4):
+
+| Token | Value | Usage |
+|-------|-------|-------|
+| xs | 4px | Gap between inline inspector fields, scrollbar width |
+| sm | 8px | Panel content padding, element gaps |
+| md | 16px | Default spacing between grouped elements |
+| lg | 24px | Section-level padding |
+| xl | 32px | Layout gaps |
+| 2xl | 48px | TRACK_HEIGHT — per-device track row height on canvas |
+| 3xl | 64px | Reserved; not used in this phase |
+
+Exceptions:
+- TIME_AXIS_HEIGHT: 24px — time ruler height above tracks
+- HEADER_WIDTH: 140px — track label column width (device names)
+- Timeline toolbar height: 32px — compact single row above canvas
+- Block inspector strip height: 40px — compact horizontal strip (D-02)
+- Resize handle hit zone: 8px on left/right edge of each canvas block
+- Snap threshold: 100ms (not a pixel dimension, but a time threshold for beat snap)
+
+Source: RESEARCH.md Pattern 1 (coordinate system constants); CONTEXT.md D-02.
+
+---
+
+## Typography
+
+| Role | Size | Weight | Line Height | Usage |
+|------|------|--------|-------------|-------|
+| Body | 13px | 400 | 1.4 | Default text, form inputs, device names |
+| Label | 10px | 400 | 1.2 | Panel headers (DEVICES, ANIMATIONS, TIMELINE), toolbar labels, canvas track names |
+| Compact | 12px | 400 | 1.3 | Transport controls, animation names, inspector field values, beat tick labels |
+| Heading | 14px | 600 | 1.2 | Header title (LIGHTSYNC), block animation type label in inspector |
+
+All text: font-family `var(--font-mono)` — monospace throughout.
+Text transform: uppercase for all panel headers, labels, and inspector field names.
+Letter spacing: 0.14em on panel headers; 0.06em on animation names; 0.15em on header title.
+
+Source: `style.css` — `.panel-header` (10px/0.14em), `.header-title` (14px/600), `.transport-time` (12px), `:root` (13px base).
+
+---
+
+## Color
+
+| Role | Value | Usage |
+|------|-------|-------|
+| Dominant (60%) | `#0a0a0a` | Body background, main-area background, canvas background |
+| Secondary (30%) | `#0f0f0f` (`--bg-panel`) / `#080808` (`--bg-panel-dark`) | Sidebar, header, transport bar, block inspector strip, timeline toolbar |
+| Accent (10%) | `#00ffff` | See reserved list below |
+| Destructive | `#ff3333` | Delete block hover state on remove controls only |
+| Dim border | `#1a4a4a` (`--border-dim`) | All panel borders, track separator lines, time axis tick marks |
+| Waveform background | `#1a3a3a` | Waveform peaks drawn behind blocks (very dim cyan — not accent) |
+| Beat marks | `#00ffff` at 40% opacity | Thin vertical beat tick marks on timeline canvas |
+| Playback cursor | `#00ffff` | 1px vertical line spanning full canvas height |
+| Selected block | `#00ffff` border at 1px | Selected block outline |
+| Block fill (default) | `#0a2a2a` | Unselected animation block fill |
+| Block fill (selected) | `#0d3a3a` | Selected animation block fill |
+| Ghost block (drag) | `#00ffff` at 20% opacity | Block preview during drag-and-place |
+
+Accent (`#00ffff`) reserved for:
+- Playback cursor line
+- Selected block border
+- Active animation type label in inspector (`.inspector-label`)
+- Panel header text (`--text-accent`)
+- Transport button hover state (accent fills button bg, primary bg for text)
+- Beat indicator flash in header
+- Timeline toolbar labels in focus/active state
+- `>` cursor glyph in active animation palette row
+
+Accent is NOT used for:
+- Block fills (blocks use `#0a2a2a` / `#0d3a3a`)
+- Waveform strokes (use `#1a3a3a`)
+- Time axis background ticks (use `--border-dim` at reduced opacity)
+
+Source: `style.css` CSS variables; CONTEXT.md "terminal aesthetic carries forward"; RESEARCH.md Pattern 6 (waveform color `#1a3a3a`).
+
+---
+
+## Component Inventory
+
+### New in Phase 4
+
+| Component | Type | Description |
+|-----------|------|-------------|
+| `#timeline-toolbar` | HTML div (flex row) | 32px strip above canvas: zoom control, beat offset input, snap toggle |
+| `#timeline-canvas` | HTML5 Canvas | Fills remaining flex space in main-area; custom hit-testing, virtual scroll |
+| `#block-inspector` | HTML div (flex row) | 40px horizontal strip between canvas and transport-bar; hidden when no block selected |
+| Animation palette rows | HTML buttons in sidebar | Compact single-line rows below DEVICES panel; drag source for placing blocks |
+
+### Modified in Phase 4
+
+| Component | Change |
+|-----------|--------|
+| `.main-area` | Replaces animation tile grid with: toolbar + canvas + inspector (flex column) |
+| `.sidebar` | Adds ANIMATIONS panel below DEVICES; items are compact drag-source rows |
+
+### Retained Unchanged
+
+Transport bar, header, device panel, all CSS variables, all form element styles.
+
+---
+
+## Canvas Rendering Contract
+
+All canvas rendering uses HTML5 Canvas 2D API. No third-party canvas library.
+
+### Coordinate System
+
+```
+HEADER_WIDTH = 140px // track label column
+TIME_AXIS_HEIGHT = 24px
+TRACK_HEIGHT = 48px
+PIXELS_PER_SECOND = 100 // default zoom; adjustable via toolbar
+```
+
+### Layer Draw Order (back to front)
+
+1. Background fill `#0a0a0a`
+2. Track row alternating banding: even `#0a0a0a`, odd `#0c0c0c` (2px brightness difference only)
+3. Waveform peaks: `#1a3a3a` vertical bars behind blocks
+4. Beat marks: `#00ffff` at 40% opacity, 1px wide vertical lines at calibrated beat times
+5. Animation blocks: filled rects `#0a2a2a`, 1px border `#1a4a4a`; selected: `#0d3a3a` fill, `#00ffff` 1px border
+6. Block labels: animation name in `#00ffff`, 10px, uppercase, clipped to block width
+7. Drag ghost: 1px border `#00ffff`, `rgba(0,255,255,0.15)` fill
+8. Track headers: `#0f0f0f` fill, device name in `--text-primary` 12px
+9. Time axis: `#080808` fill, time labels `--text-dim` 10px, tick marks `--border-dim`
+10. Playback cursor: `#00ffff` 1px line, full height
+
+### devicePixelRatio Scaling
+
+Canvas must call `resizeCanvas()` on init and on `window resize`. Scale ctx by `window.devicePixelRatio` to prevent blur on HiDPI.
+
+---
+
+## Interaction Contract
+
+### Block Placement (TL-02)
+
+1. User drags from animation palette row in sidebar.
+2. Ghost block follows mouse across canvas at target track Y position.
+3. On `mouseup` over a track: snap to nearest beat if within 100ms threshold; place block at snapped time.
+4. On `mouseup` outside canvas: cancel drag, remove ghost.
+5. Attach `mousemove` and `mouseup` to `document` during drag; remove on `mouseup`. (Pitfall 2 prevention.)
+
+### Block Move (TL-03)
+
+1. `mousedown` on block center region (not within 8px edge): enter move mode.
+2. `mousemove`: update block start time live on canvas (visual only, no API call during drag).
+3. `mouseup`: snap to beat; fire `MoveBlockCommand.execute()` → updates local state → async PATCH to backend.
+
+### Block Resize (TL-03)
+
+1. `mousedown` within 8px of left or right block edge: enter resize mode; cursor changes to `ew-resize`.
+2. `mousemove`: update duration or start time live on canvas.
+3. `mouseup`: snap to beat; fire `ResizeBlockCommand.execute()`.
+
+### Block Delete (TL-03)
+
+- `Delete` key when block selected: fire `DeleteBlockCommand.execute()` immediately. No confirmation dialog for individual block delete.
+- Block remove button in inspector (`✕`): same behavior as Delete key.
+
+### Block Selection
+
+- `mousedown` on block body (not dragging): select block, show inspector strip.
+- `mousedown` on canvas background: deselect, hide inspector strip.
+- Only one block selected at a time (no multi-select in Phase 4).
+
+### Undo/Redo (TL-06)
+
+- `Ctrl+Z`: `CommandHistory.undo()`
+- `Ctrl+Shift+Z` or `Ctrl+Y`: `CommandHistory.redo()`
+- Minimum 50 undo steps (CommandHistory `maxSteps = 50`).
+- All mutations (place, move, resize, delete, param change) must go through `CommandHistory.execute()`.
+
+### Beat Snap (TL-08)
+
+- Snap active by default; toggle via "SNAP" button in toolbar (lit accent when on, dim when off).
+- Snap threshold: 100ms.
+- Apply calibration offset before snap: `displayBeat = rawBeat - calibrationOffset`.
+
+### Timeline Scroll
+
+- Horizontal scroll via mouse wheel on canvas: updates `scrollX` offset.
+- No native CSS overflow scrollbar on canvas — virtual scroll only.
+- Auto-scroll: when playback cursor would exit the visible window, scroll to keep cursor at 20% from left edge.
+
+---
+
+## Inspector Strip Contract (D-01, D-02, D-03)
+
+Height: 40px. Display: flex row. Background: `--bg-panel-dark`. Border-top: `1px solid var(--border-dim)`.
+Hidden (`display: none`) when no block selected. Shown (`display: flex`) when block selected.
+
+Field order (left to right):
+1. `LABEL` — animation type name, `--text-accent`, 14px, 600 weight, uppercase, `flex-shrink: 0`, min-width 120px
+2. `COLOR` — label + `` hidden, visible swatch 16×16px with 1px `--border-dim` border; click swatch triggers color input
+3. `SPEED` — label + ``, width 60px
+4. `DIR` — label + `