docs(05): create phase plan — 3 plans across 3 waves

This commit is contained in:
Claude
2026-04-07 09:16:17 +00:00
parent 02826b2c89
commit 6a1ec4e3b6
4 changed files with 1125 additions and 3 deletions

View File

@@ -0,0 +1,255 @@
---
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"
---
<objective>
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.
</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/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
<interfaces>
<!-- Existing app.js globals (after Plan 02 modifications) -->
```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)
```
<!-- Keyboard bindings from CONTEXT.md + UI-SPEC.md -->
| 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 |
<!-- Save endpoint (existing from shows.py) -->
GET /api/shows/{id} returns full ShowModel
POST /api/shows (body: ShowModel) saves/creates
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add wireKeyboard() function and call it at init</name>
<files>lightsync/frontend/app.js</files>
<read_first>
- lightsync/frontend/app.js (current state after Plan 02)
- .planning/phases/05-live-show-execution/05-UI-SPEC.md (Keyboard Shortcut Bindings section)
</read_first>
<action>
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 `<button>` elements is explicitly skipped to prevent the double-toggle issue (Pitfall 6)
- Arrow seek sends `{ type: 'seek' }` to the server so the cue scheduler rebuilds `fired_ids`
- Arrow seek also clears preview strips (same pattern as seek bar mouseup from Plan 02)
- Space play/pause sends `{ type: 'play' }` or `{ type: 'pause' }` to the server so the scheduler's `is_playing` flag updates
- Ctrl+S does a GET then POST round-trip (GET current show state → POST to save) — this is a manual trigger, the primary persistence is auto-save on block mutations
- `timeline?.history.undo()` uses optional chaining since timeline might not be initialized yet
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
with open('lightsync/frontend/app.js') as f:
js = f.read()
checks = {
'wireKeyboard function': 'function wireKeyboard' in js,
'wireKeyboard called': 'wireKeyboard()' in js,
'Space handler': \"e.code === 'Space'\" in js,
'ArrowLeft handler': \"e.code === 'ArrowLeft'\" in js,
'ArrowRight handler': \"e.code === 'ArrowRight'\" in js,
'Ctrl+Z handler': \"e.code === 'KeyZ'\" in js,
'Ctrl+Y handler': \"e.code === 'KeyY'\" in js,
'Ctrl+S handler': \"e.code === 'KeyS'\" in js,
'preventDefault': js.count('e.preventDefault()') >= 6,
'input guard': \"'input', 'select', 'textarea'\" in js or \"input.*select.*textarea\" in js,
'history.undo': 'history.undo()' in js,
'history.redo': 'history.redo()' in js,
'play message': \"type: 'play'\" in js or 'type: \"play\"' in js,
'pause message': \"type: 'pause'\" in js or 'type: \"pause\"' in js,
}
for name, ok in checks.items():
print(f'{name}: {\"PASS\" if ok else \"FAIL\"}')
import sys; sys.exit(0 if all(checks.values()) else 1)
"</automated>
</verify>
<acceptance_criteria>
- app.js contains `function wireKeyboard()` definition
- app.js calls `wireKeyboard()` in the init section (before or after wireAudio)
- app.js Space handler calls `e.preventDefault()` and toggles `audio.play()/pause()`
- app.js Space handler skips when `tag === 'button'`
- app.js ArrowLeft handler sets `audio.currentTime = Math.max(0, audio.currentTime - 5)`
- app.js ArrowRight handler sets `audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 5)`
- app.js Arrow handlers send `{ type: 'seek', position: audio.currentTime }` via client.send
- app.js Ctrl+Z handler calls `timeline?.history.undo()`
- app.js Ctrl+Shift+Z and Ctrl+Y both call `timeline?.history.redo()`
- app.js Ctrl+S handler calls `e.preventDefault()` and fetches `/api/shows/${timeline.showId}` then POSTs
- app.js input element guard checks for `input`, `select`, `textarea` tag names
- `e.preventDefault()` appears at least 6 times in the keydown handler (one per key combo)
</acceptance_criteria>
<done>All 7 keyboard shortcuts are wired: Space (play/pause), ArrowLeft (seek -5s), ArrowRight (seek +5s), Ctrl+Z (undo), Ctrl+Shift+Z (redo), Ctrl+Y (redo alt), Ctrl+S (save). All shortcuts are suppressed in form elements. Browser defaults are prevented.</done>
</task>
</tasks>
<verification>
1. `grep -c 'wireKeyboard' lightsync/frontend/app.js` returns at least 2 (definition + call)
2. `grep 'preventDefault' lightsync/frontend/app.js | wc -l` returns at least 6
3. `grep -c "e.code === 'Space'" lightsync/frontend/app.js` returns at least 1
4. `grep -c "e.code === 'KeyS'" lightsync/frontend/app.js` returns at least 1
5. `grep -c 'history.undo' lightsync/frontend/app.js` returns at least 1
6. `grep -c 'history.redo' lightsync/frontend/app.js` returns at least 1
</verification>
<success_criteria>
- Space toggles play/pause and sends play/pause message to server
- Arrow keys seek +/- 5 seconds and send seek message to server
- Ctrl+Z undoes last timeline mutation via history.undo()
- Ctrl+Shift+Z and Ctrl+Y both redo via history.redo()
- Ctrl+S saves current show (no-op if no show loaded, prevents browser dialog)
- All shortcuts are disabled when typing in input/select/textarea fields
- Space does not trigger button clicks when a button is focused
</success_criteria>
<output>
After completion, create `.planning/phases/05-live-show-execution/05-03-SUMMARY.md`
</output>