Files
yellowjacket/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-PLAN.md
T

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
09-scan-cancellation-keyboard-shortcuts 02 execute 1
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
true
KEY-01
KEY-04
KEY-05
truths artifacts key_links
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
path provides exports
backend/shortcuts/config.go Shortcuts config package with defaults and validation
Config
ApplyDefaults
Validate
DefaultBindings
path provides exports
frontend/src/services/keyboard-shortcut-service.ts Singleton keyboard shortcut service with scope resolution
keyboardShortcutService
KeyboardShortcutService
path provides exports
frontend/src/store/shortcuts-store.ts Shortcuts store persisting bindings via Wails config
shortcutsStore
ShortcutsStore
from to via pattern
frontend/src/services/keyboard-shortcut-service.ts frontend/src/store/shortcuts-store.ts Service reads bindings from store to resolve key combos to actions shortcutsStore
from to via pattern
frontend/src/store/shortcuts-store.ts backend/config/config.go Wails bindings GetShortcuts/SetShortcuts for persistence GetShortcuts|SetShortcuts
from to via pattern
frontend/src/services/keyboard-shortcut-service.ts frontend/src/store/player-store.ts Action dispatch calls store methods for player controls 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.

<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.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`:
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
}
  1. In backend/config/config.go:

    • Add import: "yellowjacket/backend/shortcuts"
    • Add field to Config struct: Shortcuts *shortcuts.Config \toml:"Shortcuts"``
    • In applyDefaults(), add:
      if c.Shortcuts == nil {
          c.Shortcuts = &shortcuts.Config{}
      }
      c.Shortcuts.ApplyDefaults()
      
    • In Validate(), add validation for Shortcuts (after the Favorites block):
      if c.Shortcuts != nil {
          if err := c.Shortcuts.Validate(); err != nil {
              configErrs = errors.Join(configErrs, err)
          }
      }
      
    • Add Wails binding methods:
      // 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
      }
      
  2. Add ShortcutsConfigChanged event to backend/events/events.go in the Config events block:

    ShortcutsConfigChanged = "ShortcutsConfigChanged"
    
  3. 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).

  1. Create frontend/src/store/shortcuts-store.ts:

    Follow existing store pattern (class-based singleton with subscribe/notify):

    interface ShortcutBinding {
        action: string;
        key: string;
        scope: 'global' | string;  // 'global' or 'panel:track-list' etc.
        category: 'Player' | 'Navigation' | 'App';
    }
    
    interface ShortcutsState {
        bindings: Map<string, string>;  // action → key combo
        loaded: boolean;
    }
    
    • Constructor: call GetShortcuts() Wails binding to load initial state. Listen for ShortcutsConfigChanged event to update.
    • getBindings(): Map<string, string> — 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<void> — calls SetShortcut() Wails binding
    • resetAll(): Promise<void> — 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).

  2. Create frontend/src/store/controllers/shortcuts-controller.ts:

    Follow existing controller pattern (ReactiveController bridging store to LitElement):

    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?.();
        }
    }
    
  3. Update frontend/src/store/index.ts — add exports:

    export { shortcutsStore } from './shortcuts-store';
    export { ShortcutsController } from './controllers/shortcuts-controller';
    
  4. 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:

    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.

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md`