docs(05): create phase plan — 3 plans across 3 waves
This commit is contained in:
255
.planning/phases/05-live-show-execution/05-03-PLAN.md
Normal file
255
.planning/phases/05-live-show-execution/05-03-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user