18 KiB
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.
<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-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
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 = { color: '#00ffff', // hex string speed: 1.0, // float 0.1-10 direction: 'forward', // 'forward' | 'reverse' length: 0.3 // float 0.05-1.0 }
.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; }
Task 1: UpdateParamsCommand + BlockInspector module lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/inspector.js lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/index.html, lightsync/frontend/style.css **1. Add UpdateParamsCommand to commands.js:**Add a new command class and export it alongside the existing 4 commands:
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:
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.
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"
<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>
UpdateParamsCommand added to command suite. BlockInspector renders compact strip with all parameter controls. All changes are undoable.
Add import at top of app.js:
import { BlockInspector } from './timeline/inspector.js';
import { DeleteBlockCommand } from './timeline/commands.js';
In initTimeline(), after creating the TimelineCanvas instance, add:
// 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:
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:
// 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:
// 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. 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" <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> 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.
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)<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>