--- phase: 09-scan-cancellation-keyboard-shortcuts plan: 04 type: execute wave: 2 depends_on: - 09-02 files_modified: - frontend/src/components/config-page/shortcut-capture.ts - frontend/src/components/config-page/config-page.ts autonomous: true requirements: - KEY-02 - KEY-03 must_haves: truths: - "User can see all keyboard shortcuts grouped by category (Player, Navigation, App) in a Keyboard Shortcuts tab" - "User can click a shortcut row and press a new key combo to rebind it (record-style capture)" - "Conflicts are detected and shown — user can overwrite (old becomes unbound) or cancel" - "Reset to defaults button resets all shortcuts" - "Individual per-shortcut reset is available" artifacts: - path: "frontend/src/components/config-page/shortcut-capture.ts" provides: "Record-style key capture web component" exports: ["ShortcutCapture"] - path: "frontend/src/components/config-page/config-page.ts" provides: "Keyboard Shortcuts tab in settings" contains: "renderShortcutsSection" key_links: - from: "frontend/src/components/config-page/shortcut-capture.ts" to: "frontend/src/services/keyboard-shortcut-service.ts" via: "Uses buildKeyString for consistent key combo normalization" pattern: "buildKeyString" - from: "frontend/src/components/config-page/config-page.ts" to: "frontend/src/store/shortcuts-store.ts" via: "ShortcutsController for reactive state, store methods for persistence" pattern: "shortcutsStore|ShortcutsController" --- Create the Keyboard Shortcuts settings UI with record-style key capture, conflict detection, and category grouping. Purpose: Frontend UX for KEY-02/03 — visual shortcut customization with conflict warnings. Output: shortcut-capture.ts component, Keyboard Shortcuts tab added to config-page.ts. @/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 @.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md @frontend/src/components/config-page/config-page.ts @frontend/src/store/shortcuts-store.ts @frontend/src/services/keyboard-shortcut-service.ts class ShortcutsStore { getBindings(): Map; // action → key combo getKeyForAction(action: string): string; updateBinding(action: string, key: string): Promise; resetAll(): Promise; findConflict(key: string, scope: string, excludeAction: string): { action: string; key: string } | null; subscribe(cb: (state: ShortcutsState) => void): () => void; getState(): ShortcutsState; } export const shortcutsStore: ShortcutsStore; export class ShortcutsController implements ReactiveController { state: ShortcutsState; } export function buildKeyString(e: KeyboardEvent): string; // Action scopes (derived from action prefix): // - "player.*", "nav.*", "app.*" → global scope // - "tracklist.*" → panel:track-list scope // Action categories (for UI grouping): // - Player: player.playPause, player.next, player.previous, player.volumeUp, player.volumeDown, // player.seekForward, player.seekBack, player.shuffle, player.repeat, player.mute // - Navigation: nav.search, nav.searchAlt, nav.queue, tracklist.play, tracklist.delete // - App: app.selectAll // Currently renders 4 sections vertically: Theme, Favorites, Track List Columns, Library // Each section uses component // Per user decision: Shortcuts lives as a "Keyboard Shortcuts" tab within the settings dialog // Since the current layout is vertical sections (NOT tabbed), add "Keyboard Shortcuts" as // a new alongside the existing ones. // If/when tabs are needed, that's a layout change beyond this phase. Task 1: Create shortcut-capture web component frontend/src/components/config-page/shortcut-capture.ts Create `frontend/src/components/config-page/shortcut-capture.ts` — a record-style key capture widget inspired by VS Code's keybinding editor. The component: - Displays the current key binding as a styled button/badge - When clicked, enters "recording" mode — displays "Press a key combo..." prompt - Captures the next keydown event and normalizes it via `buildKeyString` - On Escape during recording: cancels, returns to display mode - On valid key: exits recording, dispatches `shortcut-change` CustomEvent with `{ action, key }` detail - On bare modifier press (Ctrl alone, etc.): stays in recording mode (buildKeyString returns '') ```typescript import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { buildKeyString } from '../../services/keyboard-shortcut-service'; @customElement('shortcut-capture') export class ShortcutCapture extends LitElement { @property() action = ''; @property() currentKey = ''; @property() defaultKey = ''; @state() private recording = false; static styles = css` :host { display: inline-block; } button { font-family: inherit; font-size: var(--yj-text-sm, 13px); padding: 4px 12px; border-radius: 4px; border: 1px solid var(--yj-border, #555); background: var(--yj-bg-input, #333); color: var(--yj-text-primary, #eee); cursor: pointer; min-width: 80px; text-align: center; transition: border-color 0.15s, background 0.15s; } button:hover { border-color: var(--yj-accent, #ffd43b); } button.recording { border-color: var(--yj-accent, #ffd43b); background: var(--yj-bg-active, #444); animation: pulse 1.2s ease-in-out infinite; } button.not-set { color: var(--yj-text-tertiary, #888); font-style: italic; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } .reset-btn { font-size: var(--yj-text-xs, 11px); padding: 2px 6px; margin-left: 4px; border: none; background: transparent; color: var(--yj-text-tertiary, #888); cursor: pointer; min-width: auto; opacity: 0; transition: opacity 0.15s; } :host(:hover) .reset-btn { opacity: 1; } .reset-btn:hover { color: var(--yj-accent, #ffd43b); } `; private handleClick = () => { this.recording = true; // Focus self so keydown events arrive this.shadowRoot?.querySelector('button')?.focus(); }; private handleKeydown = (e: KeyboardEvent) => { if (!this.recording) return; e.preventDefault(); e.stopPropagation(); const keyStr = buildKeyString(e); if (!keyStr) return; // bare modifier press — keep recording if (keyStr === 'Escape') { this.recording = false; return; } this.recording = false; this.dispatchEvent(new CustomEvent('shortcut-change', { detail: { action: this.action, key: keyStr }, bubbles: true, composed: true, })); }; private handleBlur = () => { // Cancel recording if focus leaves if (this.recording) { this.recording = false; } }; private handleReset = (e: Event) => { e.stopPropagation(); if (this.defaultKey && this.currentKey !== this.defaultKey) { this.dispatchEvent(new CustomEvent('shortcut-change', { detail: { action: this.action, key: this.defaultKey }, bubbles: true, composed: true, })); } }; render() { const showReset = this.defaultKey && this.currentKey !== this.defaultKey; return html` ${showReset ? html` ` : ''} `; } } declare global { interface HTMLElementTagNameMap { 'shortcut-capture': ShortcutCapture; } } ``` cd frontend && npx tsc --noEmit 2>&1 | head -20 shortcut-capture component renders a key badge, enters recording mode on click, captures keydown via buildKeyString, dispatches shortcut-change event, supports Escape cancel, and shows per-shortcut reset button when binding differs from default. Task 2: Add Keyboard Shortcuts section to config page with conflict detection frontend/src/components/config-page/config-page.ts 1. **Import required modules** at the top of config-page.ts: ```typescript import './shortcut-capture'; import { shortcutsStore } from '../../store/shortcuts-store'; import { ShortcutsController } from '../../store/controllers/shortcuts-controller'; ``` 2. **Add ShortcutsController** to the component class: ```typescript private shortcutsCtrl = new ShortcutsController(this); ``` 3. **Define shortcut metadata** — a static map of action IDs to human-readable labels and categories. Add as a class property or module-level const: ```typescript private static readonly SHORTCUT_META: Record = { 'player.playPause': { label: 'Play / Pause', category: 'Player', scope: 'global', defaultKey: 'Space' }, 'player.next': { label: 'Next Track', category: 'Player', scope: 'global', defaultKey: 'N' }, 'player.previous': { label: 'Previous Track', category: 'Player', scope: 'global', defaultKey: 'P' }, 'player.volumeUp': { label: 'Volume Up', category: 'Player', scope: 'global', defaultKey: 'Up' }, 'player.volumeDown': { label: 'Volume Down', category: 'Player', scope: 'global', defaultKey: 'Down' }, 'player.seekForward': { label: 'Seek Forward', category: 'Player', scope: 'global', defaultKey: 'Right' }, 'player.seekBack': { label: 'Seek Back', category: 'Player', scope: 'global', defaultKey: 'Left' }, 'player.shuffle': { label: 'Toggle Shuffle', category: 'Player', scope: 'global', defaultKey: 'S' }, 'player.repeat': { label: 'Cycle Repeat', category: 'Player', scope: 'global', defaultKey: 'R' }, 'player.mute': { label: 'Toggle Mute', category: 'Player', scope: 'global', defaultKey: 'M' }, 'nav.search': { label: 'Focus Search', category: 'Navigation', scope: 'global', defaultKey: '/' }, 'nav.searchAlt': { label: 'Focus Search (Alt)', category: 'Navigation', scope: 'global', defaultKey: 'Ctrl+F' }, 'nav.queue': { label: 'Toggle Queue', category: 'Navigation', scope: 'global', defaultKey: 'Q' }, 'app.selectAll': { label: 'Select All', category: 'App', scope: 'global', defaultKey: 'Ctrl+A' }, 'tracklist.play': { label: 'Play Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Enter' }, 'tracklist.delete': { label: 'Remove Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Delete' }, }; ``` 4. **Add conflict detection state:** ```typescript @state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string } | null = null; ``` 5. **Add shortcut change handler:** ```typescript private async handleShortcutChange(e: CustomEvent<{ action: string; key: string }>) { const { action, key } = e.detail; // Check for conflict — find any other action with the same key in the same or overlapping scope const meta = ConfigPage.SHORTCUT_META[action]; const conflict = shortcutsStore.findConflict(key, meta?.scope ?? 'global', action); if (conflict) { // Show conflict warning this.shortcutConflict = { newAction: action, newKey: key, existingAction: conflict.action, }; return; } // No conflict — save directly await shortcutsStore.updateBinding(action, key); } private async handleConflictOverwrite() { if (!this.shortcutConflict) return; const { newAction, newKey, existingAction } = this.shortcutConflict; // Unbind the existing action await shortcutsStore.updateBinding(existingAction, ''); // Set the new binding await shortcutsStore.updateBinding(newAction, newKey); this.shortcutConflict = null; } private handleConflictCancel() { this.shortcutConflict = null; } private async handleResetAllShortcuts() { await shortcutsStore.resetAll(); } ``` 6. **Render the Keyboard Shortcuts section.** Add a new method `renderShortcutsSection()` and call it from the main render method. Place it as a new `` after the existing sections (before or after Library section — find the natural insertion point): ```typescript private renderShortcutsSection() { const bindings = this.shortcutsCtrl.state.bindings; const categories = ['Player', 'Navigation', 'App']; return html` ${categories.map(cat => { const actions = Object.entries(ConfigPage.SHORTCUT_META) .filter(([_, meta]) => meta.category === cat); if (actions.length === 0) return ''; return html`
${cat}
${actions.map(([action, meta]) => html`
${meta.label} ${meta.scope !== 'global' ? html` (${meta.scope.replace('panel:', '')}) ` : ''}
`)}
`; })}
${this.shortcutConflict ? html`
${this.shortcutConflict.newKey} is already bound to ${ConfigPage.SHORTCUT_META[this.shortcutConflict.existingAction]?.label ?? this.shortcutConflict.existingAction}.
` : ''}
`; } ``` 7. **Call `renderShortcutsSection()`** from the main render method. Insert `${this.renderShortcutsSection()}` in the template — place it between "Track List Columns" and "Library" sections, or after Library. Look at the current render layout to find the best spot. 8. **Add CSS styles** for the shortcuts section: ```css .shortcut-category { margin-bottom: 16px; } .shortcut-category-header { font-size: var(--yj-text-sm, 13px); font-weight: 600; color: var(--yj-text-secondary, #aaa); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid var(--yj-border, #444); } .shortcut-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; gap: 16px; } .shortcut-label { font-size: var(--yj-text-sm, 13px); color: var(--yj-text-primary, #eee); } .shortcut-scope { font-size: var(--yj-text-xs, 11px); color: var(--yj-text-tertiary, #888); margin-left: 4px; } .shortcut-actions { margin-top: 16px; display: flex; justify-content: flex-end; } .conflict-banner { margin-top: 12px; padding: 12px; background: rgba(255, 165, 0, 0.1); border: 1px solid rgba(255, 165, 0, 0.4); border-radius: 6px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .conflict-text { font-size: var(--yj-text-sm, 13px); } .conflict-actions { display: flex; gap: 8px; flex-shrink: 0; } ```
cd frontend && npx tsc --noEmit 2>&1 | head -20 Keyboard Shortcuts section renders in the config page with shortcuts grouped by category (Player, Navigation, App). Each row shows label + shortcut-capture widget. Conflict detection warns before overwriting. "Reset All to Defaults" and per-shortcut reset work. Panel-specific shortcuts show their scope label.
```bash cd frontend && npx tsc --noEmit ``` TypeScript compiles. shortcut-capture component and shortcuts section are properly wired. - `shortcut-capture` component exists and handles recording, Escape cancel, blur cancel, reset - Config page has a "Keyboard Shortcuts" section with category headers - All 16 default shortcuts are listed with their labels - Clicking a capture widget enters recording mode, pressing a key updates the binding - Conflicts are detected and shown in a warning banner with Overwrite/Cancel options - "Reset All to Defaults" button calls store.resetAll() - Per-shortcut reset icon appears on hover when binding differs from default - Panel-specific shortcuts show their scope (e.g., "track-list") next to the label After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md`