--- phase: 05-live-show-execution plan: 03 type: execute wave: 3 depends_on: - 05-02 files_modified: - lightsync/frontend/app.js autonomous: true requirements: - UI-03 must_haves: truths: - "Space bar toggles play/pause without triggering button clicks or scrolling" - "Arrow keys seek audio forward/backward by 5 seconds and send seek message to server" - "Ctrl+Z undoes the last timeline mutation" - "Ctrl+Shift+Z and Ctrl+Y both redo" - "Ctrl+S prevents browser Save dialog and triggers show save (no-op if no show loaded)" - "All shortcuts are suppressed when focus is on input/select/textarea elements" artifacts: - path: "lightsync/frontend/app.js" provides: "wireKeyboard() function with all 7 keyboard bindings" contains: "function wireKeyboard" key_links: - from: "lightsync/frontend/app.js wireKeyboard" to: "HTML5 audio element" via: "audio.play()/pause()/currentTime manipulation" pattern: "audio\\.paused \\? audio\\.play" - from: "lightsync/frontend/app.js wireKeyboard" to: "timeline.history" via: "undo()/redo() calls" pattern: "timeline.*history\\.undo" --- Keyboard shortcuts: wire all primary operations (play/pause, seek, undo/redo, save) to keyboard bindings for mouse-free operation. Purpose: Implements UI-03 (all primary operations accessible without mouse). Standard DAW keyboard conventions make the app usable during live show execution without reaching for the mouse. Output: Modified `lightsync/frontend/app.js` with a `wireKeyboard()` function called at init time. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-live-show-execution/05-CONTEXT.md @.planning/phases/05-live-show-execution/05-UI-SPEC.md @.planning/phases/05-live-show-execution/05-01-SUMMARY.md @.planning/phases/05-live-show-execution/05-02-SUMMARY.md ```javascript const client = new LightSyncClient(); // WebSocket client let timeline = null; // TimelineCanvas instance, set in initTimeline() // Audio element: document.getElementById('player') // audio.play(), audio.pause(), audio.paused, audio.currentTime, audio.duration // Timeline history: timeline.history.undo(), timeline.history.redo() // Show ID: timeline.showId (null if no show loaded) ``` | Key | Action | |-----|--------| | Space | Play/Pause toggle | | ArrowLeft | Seek -5s | | ArrowRight | Seek +5s | | Ctrl+Z | Undo | | Ctrl+Shift+Z | Redo | | Ctrl+Y | Redo (alt) | | Ctrl+S | Explicit save | GET /api/shows/{id} returns full ShowModel POST /api/shows (body: ShowModel) saves/creates Task 1: Add wireKeyboard() function and call it at init lightsync/frontend/app.js - lightsync/frontend/app.js (current state after Plan 02) - .planning/phases/05-live-show-execution/05-UI-SPEC.md (Keyboard Shortcut Bindings section) Add the `wireKeyboard` function to `lightsync/frontend/app.js`. Place it after the `wireAudio()` function definition (around line 148) and before the file list section. ```javascript // ── Keyboard shortcuts ────────────────────────────────────────────────────── function wireKeyboard() { const audio = document.getElementById('player'); document.addEventListener('keydown', (e) => { // Skip shortcuts when typing in form elements const tag = document.activeElement?.tagName?.toLowerCase(); if (['input', 'select', 'textarea'].includes(tag)) return; // Also skip Space on focused buttons to prevent double-toggle (Pitfall 6) if (e.code === 'Space' && tag === 'button') return; if (e.code === 'Space') { e.preventDefault(); if (!audio) return; if (audio.paused) { audio.play(); client.send({ type: 'play', position: audio.currentTime }); } else { audio.pause(); client.send({ type: 'pause', position: audio.currentTime }); } } else if (e.code === 'ArrowLeft') { e.preventDefault(); if (!audio) return; audio.currentTime = Math.max(0, audio.currentTime - 5); client.send({ type: 'seek', position: audio.currentTime }); // Clear preview strips on seek document.querySelectorAll('.device-strip').forEach(s => { s.classList.remove('active'); const colorEl = s.querySelector('.strip-color'); if (colorEl) colorEl.style.backgroundColor = ''; const animEl = s.querySelector('.strip-anim'); if (animEl) animEl.textContent = '\u2014'; }); } else if (e.code === 'ArrowRight') { e.preventDefault(); if (!audio) return; audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 5); client.send({ type: 'seek', position: audio.currentTime }); // Clear preview strips on seek document.querySelectorAll('.device-strip').forEach(s => { s.classList.remove('active'); const colorEl = s.querySelector('.strip-color'); if (colorEl) colorEl.style.backgroundColor = ''; const animEl = s.querySelector('.strip-anim'); if (animEl) animEl.textContent = '\u2014'; }); } else if (e.ctrlKey && !e.shiftKey && e.code === 'KeyZ') { e.preventDefault(); timeline?.history.undo(); } else if ((e.ctrlKey && e.shiftKey && e.code === 'KeyZ') || (e.ctrlKey && e.code === 'KeyY')) { e.preventDefault(); timeline?.history.redo(); } else if (e.ctrlKey && e.code === 'KeyS') { e.preventDefault(); // Prevent browser Save dialog (Pitfall 7) if (timeline?.showId) { fetch(`/api/shows/${timeline.showId}`) .then(r => r.json()) .then(show => fetch('/api/shows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(show), })) .then(() => console.log('[save] show saved via Ctrl+S')) .catch(e => console.warn('[save]', e)); } } }); } ``` Call `wireKeyboard()` at the bottom of the Init section, after `wireAudio()` and before `initTimeline()`: ```javascript wireAudio(); wireKeyboard(); // NEW — keyboard shortcuts (Phase 5) ``` Key implementation details: - `e.preventDefault()` is called FIRST in every branch to suppress browser defaults (Space scroll, Ctrl+S save dialog, Arrow key scroll) - Space on `