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

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
09-02
frontend/src/components/config-page/shortcut-capture.ts
frontend/src/components/config-page/config-page.ts
true
KEY-02
KEY-03
truths artifacts key_links
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
path provides exports
frontend/src/components/config-page/shortcut-capture.ts Record-style key capture web component
ShortcutCapture
path provides contains
frontend/src/components/config-page/config-page.ts Keyboard Shortcuts tab in settings renderShortcutsSection
from to via pattern
frontend/src/components/config-page/shortcut-capture.ts frontend/src/services/keyboard-shortcut-service.ts Uses buildKeyString for consistent key combo normalization buildKeyString
from to via pattern
frontend/src/components/config-page/config-page.ts frontend/src/store/shortcuts-store.ts ShortcutsController for reactive state, store methods for persistence 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.

<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-change CustomEvent 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;
    }
}
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'; ```
  1. Add ShortcutsController to the component class:

    private shortcutsCtrl = new ShortcutsController(this);
    
  2. 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' },
    };
    
  3. Add conflict detection state:

    @state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string } | null = null;
    
  4. 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();
    }
    
  5. 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>
        `;
    }
    
  6. 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.

  7. 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;
    }
    
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.

<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>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md`