Files
led2/.planning/phases/04-timeline-editor/04-04-PLAN.md
2026-04-06 23:24:11 +00:00

18 KiB

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" 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.

<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">&#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. 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.

Task 2: Wire inspector into timeline and app.js lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/inspector.js, lightsync/frontend/app.js, lightsync/frontend/index.html **1. Update app.js initTimeline() to create and wire BlockInspector:**

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>
After completion, create `.planning/phases/04-timeline-editor/04-04-SUMMARY.md`