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,505 @@
|
||||
---
|
||||
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>
|
||||
Reference in new issue
Block a user