docs(09): create phase plan — 5 plans in 3 waves for scan cancellation & keyboard shortcuts
This commit is contained in:
1 parent
71c200bc5b
commit
08ff00f029
6 files changed
+1793
-2
No files matched your search
@@ -0,0 +1,460 @@
|
||||
---
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
<!-- Config struct pattern (config.go:24-33) -->
|
||||
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"`
|
||||
}
|
||||
|
||||
<!-- Config section pattern (theme/config.go) — follow this exactly -->
|
||||
type Config struct {
|
||||
AccentColor string `toml:"AccentColor"`
|
||||
BackgroundShade BackgroundShade `toml:"BackgroundShade"`
|
||||
}
|
||||
func (c *Config) ApplyDefaults() { ... }
|
||||
func (c *Config) Validate() error { ... }
|
||||
|
||||
<!-- Store pattern (from existing stores) -->
|
||||
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();
|
||||
|
||||
<!-- Player store actions that shortcuts will call -->
|
||||
// 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() }
|
||||
|
||||
<!-- Store index exports (store/index.ts) -->
|
||||
export { playerStore } from './player-store';
|
||||
export { queueStore } from './queue-store';
|
||||
export { themeStore } from './theme-store';
|
||||
export { searchStore } from './search-store';
|
||||
|
||||
<!-- Events pattern for config changes -->
|
||||
const ShortcutsConfigChanged = "ShortcutsConfigChanged" // will be added in Plan 01 events or here
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create backend shortcuts config package and wire into main config</name>
|
||||
<files>backend/shortcuts/config.go, backend/config/config.go, backend/events/events.go, frontend/src/events.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && go build ./... && go vet ./shortcuts/... && go vet ./config/... && go generate ./events/... && grep -q "ShortcutsConfigChanged" ../frontend/src/events.ts</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create frontend keyboard shortcut service, store, and controller</name>
|
||||
<files>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</files>
|
||||
<action>
|
||||
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<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).
|
||||
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```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.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in new issue
Block a user