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

506 lines
20 KiB
Markdown

---
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"
---
<objective>
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.
</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
@.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
<interfaces>
<!-- From Plan 02: shortcuts store API -->
class ShortcutsStore {
getBindings(): Map<string, string>; // action → key combo
getKeyForAction(action: string): string;
updateBinding(action: string, key: string): Promise<void>;
resetAll(): Promise<void>;
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; }
<!-- From Plan 02: buildKeyString export -->
export function buildKeyString(e: KeyboardEvent): string;
<!-- From Plan 02: default bindings with scope metadata -->
// 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
<!-- Existing config-page rendering pattern -->
// Currently renders 4 sections vertically: Theme, Favorites, Track List Columns, Library
// Each section uses <config-section> 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 <config-section> alongside the existing ones.
// If/when tabs are needed, that's a layout change beyond this phase.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create shortcut-capture web component</name>
<files>frontend/src/components/config-page/shortcut-capture.ts</files>
<action>
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`
<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;
}
}
```
</action>
<verify>
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Add Keyboard Shortcuts section to config page with conflict detection</name>
<files>frontend/src/components/config-page/config-page.ts</files>
<action>
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<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' },
};
```
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 `<config-section>` 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`
<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>
`;
}
```
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;
}
```
</action>
<verify>
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<verification>
```bash
cd frontend && npx tsc --noEmit
```
TypeScript compiles. shortcut-capture component and shortcuts section are properly wired.
</verification>
<success_criteria>
- `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
</success_criteria>
<output>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md`
</output>