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

@@ -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>