--- phase: 09-scan-cancellation-keyboard-shortcuts plan: 02 type: execute wave: 1 depends_on: [] files_modified: - backend/shortcuts/config.go - backend/config/config.go - frontend/src/services/keyboard-shortcut-service.ts - frontend/src/store/shortcuts-store.ts - frontend/src/store/controllers/shortcuts-controller.ts - frontend/src/store/index.ts autonomous: true requirements: - KEY-01 - KEY-04 - KEY-05 must_haves: truths: - "Default keyboard shortcuts work immediately — Space toggles play/pause, arrows adjust volume/seek, S/R/Q/M/N/P trigger actions" - "Shortcuts are suppressed when a text input is focused (except Escape which blurs)" - "Shortcuts are context-aware — panel-specific bindings (Enter/Delete in track list) only fire when that panel has focus" - "Shortcut config persists to TOML via Wails bindings and survives app restart" artifacts: - path: "backend/shortcuts/config.go" provides: "Shortcuts config package with defaults and validation" exports: ["Config", "ApplyDefaults", "Validate", "DefaultBindings"] - path: "frontend/src/services/keyboard-shortcut-service.ts" provides: "Singleton keyboard shortcut service with scope resolution" exports: ["keyboardShortcutService", "KeyboardShortcutService"] - path: "frontend/src/store/shortcuts-store.ts" provides: "Shortcuts store persisting bindings via Wails config" exports: ["shortcutsStore", "ShortcutsStore"] key_links: - from: "frontend/src/services/keyboard-shortcut-service.ts" to: "frontend/src/store/shortcuts-store.ts" via: "Service reads bindings from store to resolve key combos to actions" pattern: "shortcutsStore" - from: "frontend/src/store/shortcuts-store.ts" to: "backend/config/config.go" via: "Wails bindings GetShortcuts/SetShortcuts for persistence" pattern: "GetShortcuts|SetShortcuts" - from: "frontend/src/services/keyboard-shortcut-service.ts" to: "frontend/src/store/player-store.ts" via: "Action dispatch calls store methods for player controls" pattern: "playerStore|queueStore" --- Create the keyboard shortcuts backend config package and the frontend keyboard shortcut service with default bindings, scope resolution, and action dispatch. Purpose: Foundation for KEY-01/04/05 — shortcuts work out of the box. Settings UI (KEY-02/03) wires to this in Plan 04. Output: Go shortcuts config, frontend service singleton, shortcuts store with Wails persistence. @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md @.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md @backend/config/config.go @backend/theme/config.go @frontend/src/store/index.ts @frontend/src/store/theme-store.ts @frontend/src/store/player-store.ts @frontend/src/store/queue-store.ts type Config struct { ctx context.Context logger *slog.Logger filePath string Library *library.Config `toml:"Library"` Theme *theme.Config `toml:"Theme"` Window *WindowConfig `toml:"Window"` TrackList *tracklist.Config `toml:"TrackList"` Favorites *favorites.Config `toml:"Favorites"` } type Config struct { AccentColor string `toml:"AccentColor"` BackgroundShade BackgroundShade `toml:"BackgroundShade"` } func (c *Config) ApplyDefaults() { ... } func (c *Config) Validate() error { ... } class ThemeStore { private state: ThemeState; private subscribers = new Set<(state: ThemeState) => void>(); subscribe(cb: (state: ThemeState) => void): () => void { ... } private notify() { queueMicrotask(() => { ... }) } } export const themeStore = new ThemeStore(); // From player-store.ts: export const playerStore: { togglePlayback(), setVolume(v: number), seek(pos: number) } // From queue-store.ts: export const queueStore: { next(), previous(), toggleShuffle(), cycleRepeat() } export { playerStore } from './player-store'; export { queueStore } from './queue-store'; export { themeStore } from './theme-store'; export { searchStore } from './search-store'; const ShortcutsConfigChanged = "ShortcutsConfigChanged" // will be added in Plan 01 events or here Task 1: Create backend shortcuts config package and wire into main config backend/shortcuts/config.go, backend/config/config.go, backend/events/events.go, frontend/src/events.ts 1. Create `backend/shortcuts/config.go`: ```go package shortcuts // Config holds user-customized keyboard shortcut bindings. // Keys are action IDs (e.g. "player.playPause"), values are // key combo strings in canonical format (e.g. "Ctrl+F", "Space"). type Config struct { Bindings map[string]string `toml:"Bindings"` } // DefaultBindings returns the default keyboard shortcut bindings. // Follows hybrid style: Space/arrows for player, Ctrl+key for app actions. func DefaultBindings() map[string]string { return map[string]string{ // Player controls (Global scope, no modifier) "player.playPause": "Space", "player.next": "N", "player.previous": "P", "player.volumeUp": "Up", "player.volumeDown": "Down", "player.seekForward": "Right", "player.seekBack": "Left", "player.shuffle": "S", "player.repeat": "R", "player.mute": "M", // Navigation (Global scope) "nav.search": "/", "nav.searchAlt": "Ctrl+F", "nav.queue": "Q", // App actions (Global scope, Ctrl modifier) "app.selectAll": "Ctrl+A", // Panel-specific (track list) "tracklist.play": "Enter", "tracklist.delete": "Delete", } } // ApplyDefaults fills any missing bindings with defaults. // Existing user customizations are preserved. func (c *Config) ApplyDefaults() { if c.Bindings == nil { c.Bindings = DefaultBindings() return } defaults := DefaultBindings() for action, key := range defaults { if _, exists := c.Bindings[action]; !exists { c.Bindings[action] = key } } } // Validate checks that the config is well-formed. func (c *Config) Validate() error { c.ApplyDefaults() // No validation errors possible — any string is a valid binding. // Conflict detection is a frontend UX concern, not a config error. return nil } ``` 2. In `backend/config/config.go`: - Add import: `"yellowjacket/backend/shortcuts"` - Add field to Config struct: `Shortcuts *shortcuts.Config \`toml:"Shortcuts"\`` - In `applyDefaults()`, add: ```go if c.Shortcuts == nil { c.Shortcuts = &shortcuts.Config{} } c.Shortcuts.ApplyDefaults() ``` - In `Validate()`, add validation for Shortcuts (after the Favorites block): ```go if c.Shortcuts != nil { if err := c.Shortcuts.Validate(); err != nil { configErrs = errors.Join(configErrs, err) } } ``` - Add Wails binding methods: ```go // GetShortcuts returns the current shortcut bindings map. func (c *Config) GetShortcuts() map[string]string { if c.Shortcuts == nil { c.Shortcuts = &shortcuts.Config{} c.Shortcuts.ApplyDefaults() } return c.Shortcuts.Bindings } // SetShortcuts saves the entire shortcut bindings map. func (c *Config) SetShortcuts(bindings map[string]string) error { if c.Shortcuts == nil { c.Shortcuts = &shortcuts.Config{} } c.Shortcuts.Bindings = bindings if err := c.Save(); err != nil { return fmt.Errorf("could not save shortcuts config: %w", err) } if c.ctx != nil { runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, bindings) } c.logger.Info("shortcuts config updated") return nil } // SetShortcut saves a single shortcut binding. func (c *Config) SetShortcut(action string, key string) error { if c.Shortcuts == nil { c.Shortcuts = &shortcuts.Config{} c.Shortcuts.ApplyDefaults() } c.Shortcuts.Bindings[action] = key if err := c.Save(); err != nil { return fmt.Errorf("could not save shortcut: %w", err) } if c.ctx != nil { runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings) } c.logger.Info("shortcut updated", "action", action, "key", key) return nil } // ResetShortcuts resets all shortcuts to defaults. func (c *Config) ResetShortcuts() error { c.Shortcuts = &shortcuts.Config{ Bindings: shortcuts.DefaultBindings(), } if err := c.Save(); err != nil { return fmt.Errorf("could not save shortcuts reset: %w", err) } if c.ctx != nil { runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings) } c.logger.Info("shortcuts reset to defaults") return nil } ``` 3. Add `ShortcutsConfigChanged` event to `backend/events/events.go` in the Config events block: ```go ShortcutsConfigChanged = "ShortcutsConfigChanged" ``` 4. Run `go generate ./backend/events/...` to sync to TypeScript. cd backend && go build ./... && go vet ./shortcuts/... && go vet ./config/... && go generate ./events/... && grep -q "ShortcutsConfigChanged" ../frontend/src/events.ts Shortcuts config package exists with defaults matching user decisions. Config.go has Shortcuts field, getter/setter Wails bindings, and emits ShortcutsConfigChanged. Event synced to TypeScript. Task 2: Create frontend keyboard shortcut service, store, and controller frontend/src/services/keyboard-shortcut-service.ts, frontend/src/store/shortcuts-store.ts, frontend/src/store/controllers/shortcuts-controller.ts, frontend/src/store/index.ts 1. Create `frontend/src/services/keyboard-shortcut-service.ts`: This is the FIRST file in the `services/` directory — create the directory. The service is a singleton that: - Listens on `document.addEventListener('keydown', ...)` in constructor - Resolves the active scope by walking the shadow DOM active element chain - Looks up the key combo in the shortcuts store - Dispatches the action by calling the appropriate store method Key implementation details: - **Key string builder:** `buildKeyString(e: KeyboardEvent): string` - Modifiers in fixed order: Ctrl (includes Meta on Mac) + Alt + Shift - Skip bare modifier presses (return '' for Control, Alt, Shift, Meta) - Normalize: ArrowUp→Up, ArrowDown→Down, ArrowLeft→Left, ArrowRight→Right, ' '→Space - Single-char keys: uppercase (e.g., 's' → 'S') - **Shadow DOM active element:** `getDeepActiveElement(): Element | null` - Walk `el.shadowRoot.activeElement` chain recursively - **isTextInputFocused():** Check deep active element — if tagName is INPUT (type text/search/url/email/password/number/tel), TEXTAREA, or isContentEditable → true - **resolveScope():** Returns 'text-input' | 'panel:track-list' | 'panel:queue' | 'global' - First check isTextInputFocused → 'text-input' - Walk up from deep active element checking closest('[data-shortcut-scope]') attribute - If found, return `panel:${value}` - Default: 'global' - **handleKeydown logic:** 1. If scope is 'text-input': only allow Escape (blur the active element), suppress everything else — return early 2. Build key string 3. Get bindings from shortcutsStore 4. First try panel-specific match: find binding where action starts with panel prefix AND key matches 5. Then try global match: find binding where action does NOT start with any panel prefix AND key matches 6. If match found: preventDefault, dispatch action - **dispatch(action: string):** Switch on action ID to call store methods: - `player.playPause` → `playerStore.togglePlayback()` - `player.next` → `queueStore.next()` - `player.previous` → `queueStore.previous()` - `player.volumeUp` → `playerStore.adjustVolume(5)` (add adjustVolume method if not exists, or use setVolume with current + 5) - `player.volumeDown` → `playerStore.adjustVolume(-5)` - `player.seekForward` → `playerStore.seekRelative(5)` (add seekRelative if needed, or use seek with current + 5) - `player.seekBack` → `playerStore.seekRelative(-5)` - `player.shuffle` → `queueStore.toggleShuffle()` - `player.repeat` → `queueStore.cycleRepeat()` - `player.mute` → `playerStore.toggleMute()` - `nav.search`, `nav.searchAlt` → Focus search box: `document.querySelector('search-bar')?.shadowRoot?.querySelector('input')?.focus()` (walk shadow DOM to find the input) - `nav.queue` → Toggle queue visibility (dispatch a custom event or call a store method) - `app.selectAll` → `document.execCommand('selectAll')` or dispatch to active panel - `tracklist.play` → Dispatch custom event `shortcut:tracklist-play` on document - `tracklist.delete` → Dispatch custom event `shortcut:tracklist-delete` on document Export `buildKeyString` as a named export (needed by shortcut-capture widget in Plan 04). Export the singleton: `export const keyboardShortcutService = new KeyboardShortcutService();` Note on volume/seek: Check the actual player-store API. If `adjustVolume(delta)` doesn't exist, the service should read current volume from playerStore state, add the delta, clamp to 0-100, and call `SetVolume()` via Wails binding. Same for seek: read current position, add delta seconds, call `Seek()`. Use the Wails-generated bindings directly (e.g., `import { SetVolume, Seek } from '../../wailsjs/go/player/Player'` — check the actual import path). 2. Create `frontend/src/store/shortcuts-store.ts`: Follow existing store pattern (class-based singleton with subscribe/notify): ```typescript interface ShortcutBinding { action: string; key: string; scope: 'global' | string; // 'global' or 'panel:track-list' etc. category: 'Player' | 'Navigation' | 'App'; } interface ShortcutsState { bindings: Map; // action → key combo loaded: boolean; } ``` - Constructor: call `GetShortcuts()` Wails binding to load initial state. Listen for `ShortcutsConfigChanged` event to update. - `getBindings(): Map` — returns current bindings - `getKeyForAction(action: string): string` — lookup - `getActionForKey(key: string, scope?: string): string | undefined` — reverse lookup (for the service). Check panel-specific scope first, then global. - `updateBinding(action: string, key: string): Promise` — calls `SetShortcut()` Wails binding - `resetAll(): Promise` — calls `ResetShortcuts()` Wails binding - `findConflict(key: string, scope: string, excludeAction: string): { action: string, key: string } | null` — for conflict detection Use `queueMicrotask` coalescing for notify (match existing pattern). 3. Create `frontend/src/store/controllers/shortcuts-controller.ts`: Follow existing controller pattern (ReactiveController bridging store to LitElement): ```typescript import { ReactiveController, ReactiveControllerHost } from 'lit'; import { shortcutsStore, ShortcutsState } from '../shortcuts-store'; export class ShortcutsController implements ReactiveController { host: ReactiveControllerHost; state: ShortcutsState; private unsubscribe?: () => void; constructor(host: ReactiveControllerHost) { this.host = host; this.state = shortcutsStore.getState(); host.addController(this); } hostConnected() { this.unsubscribe = shortcutsStore.subscribe((state) => { this.state = state; this.host.requestUpdate(); }); } hostDisconnected() { this.unsubscribe?.(); } } ``` 4. Update `frontend/src/store/index.ts` — add exports: ```typescript export { shortcutsStore } from './shortcuts-store'; export { ShortcutsController } from './controllers/shortcuts-controller'; ``` 5. Initialize the keyboard shortcut service. The service must be created once at app startup. Find where other singletons are initialized (likely in `frontend/src/index.ts` or the main app component). Import and reference the singleton to ensure it's instantiated: ```typescript import { keyboardShortcutService } from './services/keyboard-shortcut-service'; ``` The import alone triggers instantiation since the module exports a `new KeyboardShortcutService()` at module scope. cd frontend && npx tsc --noEmit 2>&1 | head -30 Keyboard shortcut service listens for keydown events and dispatches actions based on scope. Shortcuts store loads bindings from Go config. Default shortcuts work: Space=play/pause, arrows=volume/seek, S/R/Q/M/N/P=player actions, /+Ctrl+F=search, Enter/Delete=tracklist panel. Text input suppression works (Escape only). Controller available for Lit components. ```bash cd backend && go build ./... && go vet ./... cd ../frontend && npx tsc --noEmit ``` Both backend and frontend compile. Shortcuts config persists through TOML. Service initializes at startup. - Go `shortcuts` package exists with `Config`, `ApplyDefaults`, `Validate`, `DefaultBindings` - Config.go has `Shortcuts` field, `GetShortcuts`, `SetShortcuts`, `SetShortcut`, `ResetShortcuts` methods - `ShortcutsConfigChanged` event exists and is synced to TypeScript - Frontend `KeyboardShortcutService` singleton listens on `document.keydown` - Shadow DOM active element resolution works (recursive walk) - Text input suppression: only Escape passes through - Scope resolution: text-input > panel-specific > global - Default bindings match user decisions: Space, arrows, S, R, Q, M, N, P, /, Ctrl+F, Ctrl+A, Enter, Delete - ShortcutsStore loads from Wails binding and subscribes to change events - ShortcutsController bridges store to Lit components After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md`