20 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 | 04 | execute | 2 |
|
|
true |
|
|
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.
<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 @.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-changeCustomEvent with{ action, key }detail - On bare modifier press (Ctrl alone, etc.): stays in recording mode (buildKeyString returns '')
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`
<button
class=${this.recording ? 'recording' : this.currentKey ? '' : 'not-set'}
@click=${this.handleClick}
@keydown=${this.handleKeydown}
@blur=${this.handleBlur}
>
${this.recording
? 'Press a key combo\u2026'
: this.currentKey || 'Not set'}
</button>
${showReset ? html`
<button class="reset-btn" @click=${this.handleReset}
title="Reset to default (${this.defaultKey})">
\u21BA
</button>
` : ''}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'shortcut-capture': ShortcutCapture;
}
}
-
Add ShortcutsController to the component class:
private shortcutsCtrl = new ShortcutsController(this); -
Define shortcut metadata — a static map of action IDs to human-readable labels and categories. Add as a class property or module-level const:
private static readonly SHORTCUT_META: Record<string, { label: string; category: string; scope: string; defaultKey: string }> = { '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' }, }; -
Add conflict detection state:
@state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string } | null = null; -
Add shortcut change handler:
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(); } -
Render the Keyboard Shortcuts section. Add a new method
renderShortcutsSection()and call it from the main render method. Place it as a new<config-section>after the existing sections (before or after Library section — find the natural insertion point):private renderShortcutsSection() { const bindings = this.shortcutsCtrl.state.bindings; const categories = ['Player', 'Navigation', 'App']; return html` <config-section label="Keyboard Shortcuts"> ${categories.map(cat => { const actions = Object.entries(ConfigPage.SHORTCUT_META) .filter(([_, meta]) => meta.category === cat); if (actions.length === 0) return ''; return html` <div class="shortcut-category"> <div class="shortcut-category-header">${cat}</div> ${actions.map(([action, meta]) => html` <div class="shortcut-row"> <span class="shortcut-label"> ${meta.label} ${meta.scope !== 'global' ? html` <span class="shortcut-scope">(${meta.scope.replace('panel:', '')})</span> ` : ''} </span> <shortcut-capture .action=${action} .currentKey=${bindings.get(action) ?? ''} .defaultKey=${meta.defaultKey} @shortcut-change=${this.handleShortcutChange} ></shortcut-capture> </div> `)} </div> `; })} <div class="shortcut-actions"> <button class="btn-ghost" @click=${this.handleResetAllShortcuts}> Reset All to Defaults </button> </div> ${this.shortcutConflict ? html` <div class="conflict-banner"> <span class="conflict-text"> <strong>${this.shortcutConflict.newKey}</strong> is already bound to <strong>${ConfigPage.SHORTCUT_META[this.shortcutConflict.existingAction]?.label ?? this.shortcutConflict.existingAction}</strong>. </span> <div class="conflict-actions"> <button class="btn-warning" @click=${this.handleConflictOverwrite}> Overwrite </button> <button class="btn-ghost" @click=${this.handleConflictCancel}> Cancel </button> </div> </div> ` : ''} </config-section> `; } -
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. -
Add CSS styles for the shortcuts section:
.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; }
<success_criteria>
shortcut-capturecomponent 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 </success_criteria>