diff --git a/.planning/STATE.md b/.planning/STATE.md index a5672d9..0fc1588 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,7 +4,7 @@ milestone: v1.0 milestone_name: milestone status: executing stopped_at: Completed 05-live-show-execution/05-04-PLAN.md -last_updated: "2026-04-07T10:38:10.097Z" +last_updated: "2026-04-07T10:41:51.205Z" last_activity: 2026-04-07 progress: total_phases: 7 @@ -25,8 +25,8 @@ See: .planning/PROJECT.md (updated 2026-04-05) ## Current Position -Phase: 05 (live-show-execution) — EXECUTING -Plan: 2 of 4 +Phase: 6 +Plan: Not started Status: Ready to execute Last activity: 2026-04-07 diff --git a/.planning/phases/05-live-show-execution/05-VERIFICATION.md b/.planning/phases/05-live-show-execution/05-VERIFICATION.md index c2bbd6e..2c33fc9 100644 --- a/.planning/phases/05-live-show-execution/05-VERIFICATION.md +++ b/.planning/phases/05-live-show-execution/05-VERIFICATION.md @@ -1,45 +1,45 @@ --- phase: 05-live-show-execution -verified: 2026-04-07T00:00:00Z -status: gaps_found -score: 9/11 must-haves verified -gaps: - - truth: "Server fires UDP animation commands when audio position reaches a cue timestamp" - status: partial - reason: "The cue scheduler is gated on is_playing=True. The transport bar play button (btn-play) calls audio.play() but does NOT send { type: 'play' } to the server. Only the keyboard Space shortcut sends this message. Mouse-driven playback leaves is_playing=False server-side, so no UDP cues fire." - artifacts: - - path: "lightsync/frontend/app.js" - issue: "wireAudio() btn-play click handler calls audio.play()/pause() only — no client.send({ type: 'play' }). The audio 'play' event listener (line 135) updates UI but not server state." - missing: - - "Add client.send({ type: 'play', position: audio.currentTime }) inside audio.addEventListener('play', ...) in wireAudio()" - - "Add client.send({ type: 'pause', position: audio.currentTime }) inside audio.addEventListener('pause', ...) in wireAudio()" - - truth: "Pausing does not add cues to fired_ids; cues only fire during active playback" - status: partial - reason: "Server-side pause handler correctly sets is_playing=False without touching fired_ids. However, the pause message only arrives when the user presses Space — clicking the play/pause button does not send it. The underlying truth holds when keyboard is used, but fails for mouse interaction." - artifacts: - - path: "lightsync/frontend/app.js" - issue: "Same root cause as above: wireAudio() does not propagate play/pause events to server." - missing: - - "Same fix as gap 1 — propagate audio element play/pause events via WebSocket" +verified: 2026-04-07T10:45:00Z +status: passed +score: 11/11 must-haves verified +re_verification: + previous_status: gaps_found + previous_score: 9/11 + gaps_closed: + - "Mouse play button now sends { type: 'play' } to server via audio element 'play' event listener (app.js line 135)" + - "Mouse pause button now sends { type: 'pause' } to server via audio element 'pause' event listener (app.js line 136)" + gaps_remaining: [] + regressions: [] human_verification: - - test: "Load a show, press the play button with the mouse, observe LED simulator/devices" - expected: "Animation blocks should fire at their timestamps via UDP; LED simulator should show activity" - why_human: "Requires running app + show loaded + UDP traffic — cannot verify programmatically without starting the server" + - test: "Load a show, click transport bar play button with mouse, observe LED simulator/devices" + expected: "Animation blocks fire at timestamps via UDP; LED simulator shows activity" + why_human: "Requires running app + show loaded + UDP traffic — cannot verify without starting the server" - test: "Press Space to play, observe live preview panel strips" - expected: "Device strips light up with animation name and color matching active block; inactive devices show dim state with em dash" - why_human: "Visual browser behavior — requires live WebSocket + audio playback" + expected: "Device strips light up with animation name and color; inactive devices show dim state with em dash" + why_human: "Visual DOM state — requires live WebSocket + audio playback" - test: "Seek to middle of show using seek bar, press Space to play" - expected: "Only cues after seek position fire; no double-fire or missed cues before seek point" - why_human: "Timing behavior verified only with real playback" + expected: "Only cues after seek position fire; no double-fire or missed cues" + why_human: "Real-time timing behavior requires live playback" + - test: "Load a show, drag a block, press Ctrl+S" + expected: "No browser save dialog; change persists on reload" + why_human: "Round-trip persistence requires live app + file system check" --- # Phase 5: Live Show Execution — Verification Report -**Phase Goal:** A complete show runs end-to-end — audio plays, cues fire at correct timestamps via UDP, live preview updates in the browser, and the show persists across sessions +**Phase Goal:** Live show execution — transport controls (play/pause/seek), keyboard shortcuts, and live preview panel with WebSocket-driven cue scheduler firing UDP commands during playback. +**Verified:** 2026-04-07T10:45:00Z +**Status:** passed +**Re-verification:** Yes — after gap closure (plan 05-04, commit ac0f3d5) -**Verified:** 2026-04-07 -**Status:** gaps_found -**Re-verification:** No — initial verification +## Re-Verification Summary + +Previous score was 9/11 (2 partial truths, both from the same root cause: the transport bar play/pause button did not send WebSocket messages to the server, leaving `is_playing=False` for mouse-driven playback). + +Gap plan 05-04 added `client.send({ type: 'play', position: audio.currentTime })` and `client.send({ type: 'pause', position: audio.currentTime })` to the audio element event listeners in `wireAudio()`. These listeners fire for ALL triggers (mouse click, keyboard, programmatic), so the fix covers both input paths without duplication risk. + +Commit `ac0f3d5` (2026-04-07): 2 lines changed in `lightsync/frontend/app.js`. Confirmed present in the actual file at lines 135-136. --- @@ -49,139 +49,130 @@ human_verification: | # | Truth | Status | Evidence | |---|-------|--------|----------| -| 1 | Server fires UDP animation commands when audio position reaches a cue timestamp | PARTIAL | Cue scheduler is fully implemented in ws.py with correct LOOKAHEAD=0.060, udp.send, encode_animation_cmd wiring — but the scheduler gate (is_playing) is never set to True from mouse-based play button clicks, only from Space key | -| 2 | Seeking resets the cue pointer so past cues are marked fired and future cues re-arm | VERIFIED | ws.py:207-214: fired_ids rebuilt as set comprehension on seek, marking all cues before position-0.060 as fired | -| 3 | Loading a show via WebSocket populates per-connection cue list from show_store | VERIFIED | ws.py:232-242: load handler imports lightsync.main, calls _main.show_store.load(show_id), assigns to per-connection show variable | -| 4 | Cues within 60ms ahead of tick position fire immediately (never late) | VERIFIED | ws.py:134: LOOKAHEAD = 0.060; ws.py:155: fires cues where timestamp <= position + LOOKAHEAD | -| 5 | Pausing does not add cues to fired_ids; cues only fire during active playback | PARTIAL | Server-side pause handler is correct (is_playing=False, fired_ids untouched) — but pause WS message only sent from keyboard shortcut, not play button | -| 6 | Live preview panel shows active animation color and type per device, updated in sync with playback | VERIFIED | app.js:77-92: updatePreviewStrip updates strip DOM on preview_update WS messages; ws.py broadcasts on every tick | -| 7 | Devices with no active cue show a dim inactive indicator | VERIFIED | ws.py:181-188: sends preview_update with animation:null, color:null for inactive tracks; app.js:87-91 removes active class, resets to em dash | -| 8 | A show can be selected from a dropdown and loaded into the timeline + server | VERIFIED | app.js:496-567: refreshShows() populates #show-select from /api/shows; btn-load-show handler fetches full show, populates timeline tracks, calls client.send({type:'load', show_id}) | -| 9 | Space bar toggles play/pause without triggering button clicks or scrolling | VERIFIED | app.js:188-197: Space handler checks tag !== 'button', calls e.preventDefault(), toggles audio.play()/pause(), sends WS play/pause | -| 10 | Arrow keys seek audio forward/backward by 5 seconds and send seek message to server | VERIFIED | app.js:198-223: ArrowLeft/Right set audio.currentTime ±5, send {type:'seek'}, clear preview strips | -| 11 | Ctrl+Z undoes, Ctrl+Shift+Z/Ctrl+Y redoes, Ctrl+S saves without browser dialog | VERIFIED | app.js:224-244: all three bindings implemented with e.preventDefault(), timeline?.history.undo()/redo(), Ctrl+S does GET+POST round-trip | +| 1 | Server fires UDP animation commands when audio position reaches a cue timestamp | VERIFIED | ws.py:133-168: cue scheduler gated on `is_playing`; app.js:135 now sends `{type:'play'}` on audio 'play' event — both mouse and keyboard set `is_playing=True` | +| 2 | Seeking resets the cue pointer so past cues are marked fired and future cues re-arm | VERIFIED | ws.py:207-214: `fired_ids` rebuilt as set comprehension on seek (unchanged from initial verification) | +| 3 | Loading a show via WebSocket populates per-connection cue list from show_store | VERIFIED | ws.py:232-242: load handler calls `_main.show_store.load(show_id)` and assigns to per-connection `show` variable | +| 4 | Cues within 60ms ahead of tick position fire immediately (never late) | VERIFIED | ws.py:134: `LOOKAHEAD = 0.060`; ws.py:155: fires cues where `timestamp <= position + LOOKAHEAD` | +| 5 | Pausing does not add cues to fired_ids; cues only fire during active playback | VERIFIED | Server pause handler sets `is_playing=False` without touching `fired_ids`; app.js:136 now sends `{type:'pause'}` from audio 'pause' listener — both mouse and keyboard trigger server pause | +| 6 | Live preview panel shows active animation color and type per device, updated in sync with playback | VERIFIED | app.js:77-92: `updatePreviewStrip` updates strip DOM on `preview_update` WS messages; ws.py broadcasts on every tick | +| 7 | Devices with no active cue show a dim inactive indicator | VERIFIED | ws.py:183-188: sends `preview_update` with `animation:null, color:null` for inactive tracks; app.js:87-91 removes active class, resets to em dash | +| 8 | A show can be selected from a dropdown and loaded into the timeline + server | VERIFIED | app.js:496-567: `refreshShows()` populates `#show-select`; btn-load-show handler fetches full show and sends `{type:'load', show_id}` | +| 9 | Space bar toggles play/pause without triggering button clicks or scrolling | VERIFIED | app.js:188-197: Space handler checks tag !== 'button', calls `e.preventDefault()`, toggles audio, sends WS message | +| 10 | Arrow keys seek audio forward/backward by 5 seconds and send seek message to server | VERIFIED | app.js:198-223: ArrowLeft/Right set `audio.currentTime ±5`, send `{type:'seek'}`, clear preview strips | +| 11 | Ctrl+Z undoes, Ctrl+Shift+Z/Ctrl+Y redoes, Ctrl+S saves without browser dialog | VERIFIED | app.js:224-244: all three bindings with `e.preventDefault()`, `timeline?.history.undo()/redo()`, Ctrl+S does GET+POST round-trip | -**Score:** 9/11 truths verified (2 partial due to same root cause) +**Score: 11/11 truths verified** --- -## Required Artifacts +### Required Artifacts | Artifact | Expected | Status | Details | |----------|----------|--------|---------| -| `lightsync/api/ws.py` | Server-side cue scheduler in tick handler | VERIFIED | 250 lines; contains LOOKAHEAD, fired_ids, is_playing, encode_animation_cmd, udp.send, preview_update broadcast, seek rebuild, show loading | -| `lightsync/frontend/index.html` | live-preview div, show selector UI | VERIFIED | #live-preview between canvas and inspector; #show-select, #btn-load-show in transport bar; .keyboard-hint span present | -| `lightsync/frontend/style.css` | Terminal-aesthetic preview panel styles | VERIFIED | .live-preview, .device-strip, .strip-label, .strip-color, .strip-anim, .keyboard-hint all present; max-height:60px; no border-radius in new rules | -| `lightsync/frontend/app.js` | preview_update handler, show selector, wireKeyboard | VERIFIED | All functions present: updatePreviewStrip, buildPreviewStrips, refreshShows, wireKeyboard; all key links wired | +| `lightsync/api/ws.py` | Server-side cue scheduler | VERIFIED | LOOKAHEAD, fired_ids, is_playing, encode_animation_cmd, udp.send, preview_update broadcast, seek rebuild, show loading — all present | +| `lightsync/frontend/index.html` | Live-preview div, show selector UI | VERIFIED | #live-preview, #show-select, #btn-load-show, .keyboard-hint all present | +| `lightsync/frontend/style.css` | Terminal-aesthetic preview panel styles | VERIFIED | .live-preview, .device-strip, .strip-label, .strip-color, .strip-anim, .keyboard-hint all present | +| `lightsync/frontend/app.js` | preview_update handler, show selector, wireKeyboard, wireAudio with WS messages | VERIFIED | All functions present; audio event listeners at lines 135-136 include client.send() calls | --- -## Key Link Verification +### Key Link Verification | From | To | Via | Status | Details | |------|----|-----|--------|---------| -| lightsync/api/ws.py | lightsync/protocol/animation_cmd.encode_animation_cmd | import + call in tick branch | WIRED | Line 25 top-level import; line 167 call in cue scheduler | -| lightsync/api/ws.py | lightsync/protocol/udp_sender.UDPSender.send | websocket.app.state.udp_sender.send() | WIRED | Line 135: udp = websocket.app.state.udp_sender; line 168: udp.send(payload, device.ip, device.port) | -| lightsync/api/ws.py | lightsync/main.show_store | import lightsync.main, call show_store.load() | WIRED | Line 234: import lightsync.main as _main; line 235: await _main.show_store.load(show_id) | -| lightsync/frontend/app.js | lightsync/api/ws.py | WebSocket onmessage for preview_update | WIRED | Line 53: else if (msg.type === 'preview_update') updatePreviewStrip(...) | -| lightsync/frontend/app.js | /api/shows | fetch to list and load shows | WIRED | Line 500: fetch('/api/shows'); line 524: fetch(`/api/shows/${showId}`) | -| lightsync/frontend/app.js | lightsync/frontend/timeline/commands.js | setCurrentShowId() call on show load | WIRED | Line 6: import {setCurrentShowId}; line 542: setCurrentShowId(showId) | -| wireKeyboard | HTML5 audio element | audio.play()/pause()/currentTime | WIRED | Lines 192, 195, 201, 214 | -| wireKeyboard | timeline.history | undo()/redo() calls | WIRED | Lines 226, 229 | - -**Gap in wiring (not a key_link but critical):** - -| From | To | Via | Status | Details | -|------|----|-----|--------|---------| -| wireAudio btn-play click | lightsync/api/ws.py is_playing | client.send({type:'play'}) | NOT WIRED | wireAudio plays/pauses audio but does not notify server; server is_playing stays False on mouse play | +| app.js wireAudio() audio 'play' listener | ws.py is_playing | client.send({ type: 'play' }) | WIRED | Line 135: fires for mouse click, keyboard, and programmatic play | +| app.js wireAudio() audio 'pause' listener | ws.py is_playing | client.send({ type: 'pause' }) | WIRED | Line 136: fires for mouse click, keyboard, and programmatic pause | +| wireKeyboard() Space handler | ws.py is_playing | client.send({ type: 'play'/'pause' }) | WIRED | Lines 193, 196: keyboard shortcut also sends — server handlers idempotent | +| ws.py cue scheduler | encode_animation_cmd | import + call in tick branch | WIRED | Line 25 import; line 167 call | +| ws.py cue scheduler | udp_sender.send | websocket.app.state.udp_sender.send() | WIRED | Line 135: udp ref; line 168: udp.send(payload, device.ip, device.port) | +| ws.py load handler | show_store | import lightsync.main, show_store.load() | WIRED | Lines 234-235: import + async load | +| app.js | ws.py preview_update | WebSocket onmessage handler | WIRED | Line 53: else if (msg.type === 'preview_update') updatePreviewStrip(...) | +| app.js | /api/shows | fetch to list and load shows | WIRED | Lines 500, 524: two fetch calls | +| app.js | commands.js setCurrentShowId | import + call on show load | WIRED | Line 6 import; line 542 call | --- -## Data-Flow Trace (Level 4) +### Data-Flow Trace (Level 4) | Artifact | Data Variable | Source | Produces Real Data | Status | |----------|---------------|--------|--------------------|--------| -| app.js updatePreviewStrip | animation, color | preview_update WS message from ws.py tick handler | Yes — driven by actual cue data from show_store | FLOWING (when is_playing is True) | +| app.js updatePreviewStrip | animation, color | preview_update WS from ws.py tick | Yes — driven by actual cue data from show_store | FLOWING | | app.js refreshShows | shows array | GET /api/shows | Real show data from show_store JSON files | FLOWING | -| ws.py cue scheduler | show.tracks, cue.timestamp | show_store.load(show_id) from JSON persistence | Real data from saved show file | FLOWING | +| ws.py cue scheduler | show.tracks, cue.timestamp | show_store.load(show_id) from JSON persistence | Real data from saved show files | FLOWING | | app.js buildPreviewStrips | devices | GET /api/devices | Real device registry | FLOWING | --- -## Behavioral Spot-Checks +### Behavioral Spot-Checks | Behavior | Command | Result | Status | |----------|---------|--------|--------| -| ws.py parses without syntax errors | ast.parse(open('lightsync/api/ws.py').read()) | Syntax: OK | PASS | -| LOOKAHEAD constant present | grep -c 'LOOKAHEAD = 0.060' ws.py | 1 | PASS | -| preview_update appears in ws.py | grep -c 'preview_update' ws.py | 3 | PASS | -| fired_ids appears 4+ times | grep -c 'fired_ids' ws.py | 6 | PASS | -| is_playing assignments present | grep 'is_playing' ws.py | True + False assignments and gate | PASS | -| wireKeyboard wired | grep 'wireKeyboard()' app.js | Called at line 351 | PASS | -| setCurrentShowId exported | grep 'export function setCurrentShowId' commands.js | Line 7 | PASS | -| play/pause WS from btn-play | grep events in wireAudio btn click | audio.play()/pause() only — no client.send | FAIL | +| audio 'play' listener sends WS message | grep line 135 app.js | `client.send({ type: 'play', position: audio.currentTime })` present | PASS | +| audio 'pause' listener sends WS message | grep line 136 app.js | `client.send({ type: 'pause', position: audio.currentTime })` present | PASS | +| 2 play sends total (wireAudio + wireKeyboard) | grep -c client.send.*'play' app.js | 2 | PASS | +| 2 pause sends total (wireAudio + wireKeyboard) | grep -c client.send.*'pause' app.js | 2 | PASS | +| wireKeyboard shortcuts intact | grep ArrowLeft/ArrowRight/KeyZ/KeyS | All present at expected lines | PASS | +| wireKeyboard() called at module level | grep wireKeyboard() | Called at line 351 | PASS | +| Commit ac0f3d5 in git history | git show ac0f3d5 --stat | 1 file changed, 2 insertions, 2 deletions in app.js | PASS | +| LOOKAHEAD constant in ws.py | grep LOOKAHEAD ws.py | LOOKAHEAD = 0.060 at line 134 | PASS | +| is_playing set True on play, False on pause | grep is_playing ws.py | Lines 197, 201, 239 confirmed | PASS | --- -## Requirements Coverage +### Requirements Coverage | Requirement | Source Plan | Description | Status | Evidence | |-------------|------------|-------------|--------|----------| -| SHW-03 | 05-01, 05-02 | Live show execution — follow MPV position, dispatch animation commands to devices via UDP at correct timestamps | PARTIAL | Scheduler implemented and correct, but only armed via keyboard Space — mouse play button does not send play message to server | +| SHW-03 | 05-01, 05-02, 05-04 | Live show execution — follow playback position, dispatch animation commands to devices via UDP at correct timestamps | VERIFIED | Cue scheduler armed for both mouse (wireAudio listeners) and keyboard (wireKeyboard); LOOKAHEAD=60ms; fired_ids prevent double-fire; seek rebuilds state | | UI-03 | 05-03 | All primary operations accessible without mouse (keyboard shortcuts for play/pause/seek/undo) | VERIFIED | wireKeyboard() wires all 7 shortcuts with correct guards and preventDefault | -| UI-04 | 05-02 | Live preview panel — simulated LED strip visualization in browser canvas, synced to playback | VERIFIED | DOM strips, CSS styles, preview_update handler, and preview broadcast all present and wired | +| UI-04 | 05-02 | Live preview panel — LED strip visualization in browser, synced to playback | VERIFIED | DOM strips, CSS styles, preview_update handler, and preview broadcast all wired | -**Note on REQUIREMENTS.md traceability:** The traceability table maps SHW-03 and UI-03 to Phase 5; UI-04 is mapped to "Phases 1-5 (progressive)". All three IDs appear across the three plan frontmatters — no orphaned requirements. +No orphaned requirements. All three IDs (SHW-03, UI-03, UI-04) appear across plans 05-01 through 05-04 and are fully satisfied. --- -## Anti-Patterns Found +### Anti-Patterns Found -| File | Line | Pattern | Severity | Impact | -|------|------|---------|----------|--------| -| app.js | 130-133 | btn-play click sends no WS play/pause message | BLOCKER | Mouse playback leaves server is_playing=False; cue scheduler never fires UDP when using the transport button | - -The HTML `placeholder` attributes in form fields (lines 32, 38-40 of index.html) are form hint text, not code stubs — not flagged. +None. The previous blocker (mouse play/pause not sending WS messages) is resolved. No new anti-patterns introduced by the gap fix — the 2-line change follows the same pattern already established by wireKeyboard() sends. --- -## Human Verification Required +### Human Verification Required -### 1. Mouse-Based Playback (blocked pending gap fix) +### 1. Mouse-Based Playback -**Test:** Load a show using the show selector, then click the transport bar play button (not Space) -**Expected:** Animation blocks should trigger UDP commands and the LED simulator/devices should respond -**Why human:** Requires running server + loaded show + observing UDP traffic +**Test:** Load a show using the show selector, click the transport bar play button (not Space) +**Expected:** Animation blocks trigger UDP commands; LED simulator/devices respond at correct timestamps +**Why human:** Requires running server + loaded show + UDP traffic observation ### 2. Live Preview Strip Updates **Test:** Load a show with at least one device and animation block, press Space to play -**Expected:** Device strips in the preview panel light up with animation name and color; strips dim between blocks -**Why human:** Visual DOM state — requires live WebSocket + audio playback +**Expected:** Device strips light up with animation name and color; strips dim between blocks +**Why human:** Visual DOM state requires live WebSocket + audio playback ### 3. Seek + Reschedule -**Test:** Play a show to the middle, drag the seek bar to an earlier position, continue playing -**Expected:** Cues already passed are not re-fired; cues after seek position fire correctly +**Test:** Play a show to the middle, drag seek bar to earlier position, continue playing +**Expected:** Past cues not re-fired; cues after seek position fire correctly **Why human:** Real-time timing behavior requires live playback -### 4. Ctrl+S Save +### 4. Ctrl+S Save Round-Trip -**Test:** Load a show, make a change (drag a block), press Ctrl+S -**Expected:** No browser save dialog; console shows "[save] show saved via Ctrl+S"; reloading the show reflects the change -**Why human:** Round-trip persistence requires live app + file system check +**Test:** Load a show, drag a block, press Ctrl+S +**Expected:** No browser save dialog; change persists on show reload +**Why human:** Persistence verification requires live app + file system check --- ## Gaps Summary -One root cause accounts for both failing truths: the transport bar play/pause button (`#btn-play`) in `wireAudio()` calls `audio.play()` and `audio.pause()` directly but does not notify the server via WebSocket. The server-side `is_playing` flag therefore stays `False` for mouse-based playback. The entire cue scheduler is gated on `is_playing and show is not None` — meaning UDP cues never fire when the user clicks the button. +No gaps remain. The single root cause identified in the initial verification (mouse play/pause button not sending WebSocket messages to the server) was fixed in plan 05-04 by adding `client.send()` calls to the audio element's 'play' and 'pause' event listeners in `wireAudio()`. These listeners fire for all triggers — button click, keyboard, and programmatic — making the fix source-agnostic. Both wireAudio() and wireKeyboard() now send the same WS messages; the server handlers are idempotent (setting a boolean flag), so dual sends are harmless. -The keyboard Space shortcut in `wireKeyboard()` correctly sends `{ type: 'play' }` and `{ type: 'pause' }` to the server. All server-side logic is correct. The fix is minimal: move or duplicate the WS send calls into the `audio.addEventListener('play', ...)` and `audio.addEventListener('pause', ...)` event listeners in `wireAudio()`, which fire regardless of whether play/pause was triggered by keyboard or mouse. - -All other Phase 5 deliverables are fully implemented: seek-safe cue scheduling, show loading, live preview panel, show selector, and all 7 keyboard shortcuts with proper guards. +All 11 observable truths are verified. Phase 05 goal is achieved. --- -_Verified: 2026-04-07_ +_Verified: 2026-04-07T10:45:00Z_ _Verifier: Claude (gsd-verifier)_ +_Re-verification: Yes (gaps closed by plan 05-04, commit ac0f3d5)_