424 lines
18 KiB
Markdown
424 lines
18 KiB
Markdown
---
|
|
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">✕</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>
|