docs(09): research phase domain

This commit is contained in:
2026-03-06 11:39:48 -05:00
parent 170f6b1b84
commit 71c200bc5b
@@ -0,0 +1,555 @@
# Phase 9: Scan Cancellation & Keyboard Shortcuts - Research
**Researched:** 2026-03-06
**Domain:** Go context cancellation, frontend keyboard event management, Lit web component architecture
**Confidence:** HIGH
## Summary
This phase adds two independent feature sets to YellowJacket: scan control (cancel/pause/resume) on the Go backend with frontend buttons, and a full keyboard shortcut system on the Lit frontend with configurable bindings persisted via the existing TOML config.
**Scan cancellation** requires threading a cancellable `context.Context` through the existing scan pipeline. The current `Scan()` method already checks `l.ctx.Done()` in several `select` blocks within the directory walker and worker pool. The implementation adds a dedicated `scanCancel context.CancelFunc` field on `Library`, Pause/Resume via a sync-based mechanism (channel or mutex), and new Wails-bound methods (`CancelScan`, `PauseScan`, `ResumeScan`). The cancel confirmation dialog ("Keep X tracks found so far, or discard?") is a frontend concern — the backend simply stops and reports partial results vs rolls back.
**Keyboard shortcuts** are a pure frontend feature. No external libraries are needed — the browser's `KeyboardEvent` API is sufficient for a Wails desktop app. A central `KeyboardShortcutService` singleton listens on `document.keydown`, resolves the active scope (Global, Panel-specific, Text Input), looks up the action, and dispatches it. Bindings are stored in the Go config (new `Shortcuts` TOML section) and exposed via Wails bindings. The settings UI adds a "Keyboard Shortcuts" tab to the existing `config-page` component with record-style key capture.
**Primary recommendation:** Implement scan cancellation via `context.WithCancel` + a pause channel on the backend, and keyboard shortcuts as a frontend-only `KeyboardShortcutService` with Go config persistence. Both are zero-dependency — no new libraries needed on either side.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- Hybrid style: Space/arrows for player controls (no modifier), Ctrl+key for app actions
- Up/Down arrows adjust volume, Left/Right seek within track
- Both `/` and `Ctrl+F` focus the search box
- `Q` toggles the queue panel
- `S` for shuffle, `R` for repeat (single-key player controls)
- `Ctrl+A` for select-all in any multi-select context (track lists, etc.)
- All bindings are configurable — the above are defaults
- Claude fills in remaining defaults (mute, etc.) using common media player conventions
- Record-style key capture: click a shortcut row, press the new key combo, it captures live
- Conflicts show a warning with the conflicting action — user chooses to overwrite (old becomes unbound) or cancel
- Shortcuts grouped by category (Player, Navigation, App) in the settings view
- "Reset to defaults" button resets all shortcuts; individual per-shortcut reset also available
- Lives as a "Keyboard Shortcuts" tab within the existing settings dialog
- Three scopes: Global (always active), Panel-specific (when a panel has focus), Text Input (shortcuts suppressed)
- Global scope: player controls (Space, arrows, S, R, Q, etc.) fire regardless of which panel is focused
- Panel-specific scope: track list gets Enter-to-play and Delete-to-remove when focused
- Text Input scope: only Escape works (blurs the text input) — all other shortcuts suppressed
- No visual scope indicator — relies on natural browser focus behavior; users learn through use
- Pause and Cancel buttons placed next to the existing status label, above the existing progress bar in the scanner UI
- On cancel: prompt the user — "Keep X tracks found so far, or discard?" — gives user control over partial results
- On resume after pause: skip already-processed files and continue with remaining — no duplicate work
- Scan control is buttons-only — no keyboard shortcuts for cancel/pause (scans are infrequent)
### Claude's Discretion
- Remaining default key assignments not explicitly discussed (mute, volume step size, etc.)
- Scan progress detail level and error handling during scan
- Loading/disabled states for scan control buttons
- Visual design of the shortcut settings UI (spacing, grouping headers, etc.)
- How the cancel confirmation dialog looks and behaves
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| SCAN-01 | User can cancel an in-progress library scan via a cancel button | Go context cancellation pattern; new `CancelScan()` Wails binding; frontend cancel button in config-page scan section |
| SCAN-02 | Cancelled scan stops gracefully without corrupting the database | Batch-transactional writes already atomic; cancel skips orphan cleanup (STATE.md warning); partial results either kept or discarded per user choice |
| SCAN-03 | User can pause a library scan and resume it without re-scanning processed files | Pause channel blocks worker pool goroutines; resume unblocks; existingPaths sync.Map already tracks processed files |
| KEY-01 | Default keybindings work out of box | Frontend `KeyboardShortcutService` with hardcoded default map; Go config stores overrides |
| KEY-02 | User can customize all keyboard shortcuts via a visual settings UI | "Keyboard Shortcuts" tab in config-page; record-style key capture component; Wails config bindings for persistence |
| KEY-03 | Shortcut conflicts are detected and warned about when rebinding | Frontend conflict detection during key capture — compare against all bindings in same scope |
| KEY-04 | Shortcuts are scoped — different bindings apply based on focused component | Three-scope system (Global, Panel, TextInput); scope resolved by checking `document.activeElement` shadow DOM chain |
| KEY-05 | Shortcuts are disabled when text input has focus (except Escape to blur) | TextInput scope check: if active element is `<input>`, `<textarea>`, or `contenteditable`, suppress all except Escape |
</phase_requirements>
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Go `context` | stdlib | Scan cancellation via `context.WithCancel` | Standard Go cancellation pattern; already used in scan pipeline |
| `sync` | stdlib | Pause/resume via channel or conditional variable | No external dependency needed for goroutine coordination |
| Browser `KeyboardEvent` API | Web standard | Key capture, modifier detection, key identification | Native API, no library needed for desktop Wails app |
| Lit 3.x | 3.2.1 (existing) | Shortcut settings UI components | Already the project's component framework |
| BurntSushi/toml | existing | Config persistence for shortcut bindings | Already the project's config format |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `golang.org/x/sync/errgroup` | existing | Worker pool with context-aware cancellation | Already used in scan worker pool |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Custom key manager | `hotkeys-js` or `tinykeys` | Unnecessary dependency for a Wails app — no global OS hotkeys needed, browser events suffice |
| TOML config for shortcuts | JSON file or SQLite | TOML is the existing config format — consistency wins |
| sync.Cond for pause | Channel-based pause | Channels are simpler and more idiomatic in Go; sync.Cond is error-prone |
## Architecture Patterns
### Recommended Project Structure
```
backend/
├── library/
│ ├── library.go # Add scanCancel, scanPaused fields; modify Scan()
│ ├── scan_control.go # New: CancelScan(), PauseScan(), ResumeScan() methods
│ └── metrics.go # Add Cancelled bool field to ScanMetrics
├── config/
│ └── config.go # Add Shortcuts *shortcuts.Config section
├── shortcuts/ # New package
│ ├── config.go # ShortcutConfig struct, defaults, validation
│ └── config_test.go # Unit tests for config validation
└── events/
└── events.go # Add ScanCancelled, ScanPaused, ScanResumed events
frontend/src/
├── services/
│ └── keyboard-shortcut-service.ts # New: singleton, keydown listener, scope resolution, action dispatch
├── store/
│ └── shortcuts-store.ts # New: persisted shortcut bindings from config
├── components/
│ └── config-page/
│ ├── config-page.ts # Add "Keyboard Shortcuts" tab
│ └── shortcut-capture.ts # New: record-style key capture widget
```
### Pattern 1: Context Cancellation for Scan
**What:** Use `context.WithCancel` to create a per-scan context that propagates cancellation to all goroutines.
**When to use:** Every call to `Scan()` creates a child context from `l.ctx`.
```go
// In library.go — Scan() method modification
func (l *Library) Scan() (*ScanMetrics, error) {
// Create cancellable context for this scan
scanCtx, cancel := context.WithCancel(l.ctx)
l.mu.Lock()
l.scanCancel = cancel
l.scanActive = true
l.mu.Unlock()
defer func() {
l.mu.Lock()
l.scanCancel = nil
l.scanActive = false
l.mu.Unlock()
}()
// Pass scanCtx instead of l.ctx to all operations
// Workers check scanCtx.Done() for cancellation
// ...
}
```
### Pattern 2: Channel-Based Pause/Resume
**What:** Use a channel that workers check before processing each file. When paused, the channel blocks; when resumed, it's replaced with a closed channel (always readable).
**When to use:** Pause/resume scan control.
```go
type Library struct {
// ...
scanPauseCh chan struct{} // nil = not paused, non-nil closed = running, non-nil open = paused
}
// Workers call this before processing each file:
func (l *Library) waitIfPaused(ctx context.Context) error {
l.mu.Lock()
ch := l.scanPauseCh
l.mu.Unlock()
if ch == nil {
return nil
}
select {
case <-ch: // channel closed = unpaused, proceed
return nil
case <-ctx.Done():
return ctx.Err()
}
}
```
### Pattern 3: Frontend Keyboard Shortcut Service
**What:** A singleton service that listens on `document.keydown`, resolves scope, looks up binding, and dispatches action.
**When to use:** The service is created once at app startup and never destroyed.
```typescript
// keyboard-shortcut-service.ts
class KeyboardShortcutService {
private bindings: Map<string, ShortcutBinding>;
constructor() {
document.addEventListener('keydown', this.handleKeydown);
}
private handleKeydown = (e: KeyboardEvent) => {
// 1. Check if text input focused — suppress all except Escape
if (this.isTextInputFocused()) {
if (e.key === 'Escape') {
(document.activeElement as HTMLElement)?.blur();
e.preventDefault();
}
return;
}
// 2. Build key string: "Ctrl+Shift+K" format
const keyStr = this.buildKeyString(e);
// 3. Check panel-specific bindings first, then global
const scope = this.resolveScope();
const action = this.findAction(keyStr, scope);
if (action) {
e.preventDefault();
this.dispatch(action);
}
};
private isTextInputFocused(): boolean {
const el = this.getDeepActiveElement();
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea') return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
// Shadow DOM aware active element resolution
private getDeepActiveElement(): Element | null {
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) {
el = el.shadowRoot.activeElement;
}
return el;
}
}
```
### Pattern 4: Config Extension for Shortcuts
**What:** Add a `Shortcuts` section to the existing TOML config following the same pattern as Theme, TrackList, Favorites.
**When to use:** Persisting user-customized keyboard shortcuts.
```go
// backend/shortcuts/config.go
type Config struct {
Bindings map[string]string `toml:"Bindings"` // action -> key combo
}
func (c *Config) ApplyDefaults() {
if c.Bindings == nil {
c.Bindings = DefaultBindings()
}
}
// backend/config/config.go — add to Config struct
type Config struct {
// ... existing fields
Shortcuts *shortcuts.Config `toml:"Shortcuts"`
}
```
### Anti-Patterns to Avoid
- **Anti-pattern: Global mutable state for pause:** Don't use a global variable. Keep pause state on the Library struct, protected by the existing mutex.
- **Anti-pattern: Keyboard listeners on individual components:** Don't add `keydown` handlers to every component. Use a single document-level listener that delegates based on scope.
- **Anti-pattern: Storing shortcuts in localStorage:** Don't bypass the Go config system. All persistent config flows through the TOML config file via Wails bindings, consistent with existing patterns (theme, tracklist columns, favorites).
- **Anti-pattern: Using `e.keyCode` or `e.which`:** Use `e.key` and `e.code` — they're the modern standard and handle international keyboards correctly.
- **Anti-pattern: Cancelling scan inside a transaction:** The batch commit is already atomic. Cancellation should happen between batches, not mid-transaction.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Key event normalization | Custom key string builder from scratch | `e.key` + modifier booleans (`e.ctrlKey`, `e.shiftKey`, etc.) | The browser API is sufficient; `e.key` returns the logical key value |
| Context cancellation | Custom goroutine signaling | `context.WithCancel` | Standard Go pattern, already partially in use in the scan pipeline |
| Goroutine pause | Manual sync.Mutex lock/unlock cycling | Channel-based blocking | Channels compose naturally with `select` and context cancellation |
**Key insight:** Both features (scan control and keyboard shortcuts) are well-served by standard library/platform capabilities. No external dependencies are needed.
## Common Pitfalls
### Pitfall 1: Orphan Cleanup After Cancelled Scan
**What goes wrong:** The scan's orphan cleanup phase (Phase 5) iterates `existingPaths` and deletes DB entries for files not found on disk. If a scan is cancelled mid-way, `existingPaths` still contains files that weren't visited yet — they'd be incorrectly deleted as "orphans."
**Why it happens:** The scan loads all existing files into `existingPaths` at the start, then removes entries as they're found during the walk. A cancelled walk leaves legitimate files in the map.
**How to avoid:** Skip orphan cleanup entirely when the scan is cancelled. This is already called out as a warning in STATE.md: "Scan cancellation: skip orphan cleanup on cancelled scans."
**Warning signs:** Tracks disappearing from the library after cancelling a scan.
### Pitfall 2: Shadow DOM Active Element Detection
**What goes wrong:** `document.activeElement` returns the host element of a shadow root, not the actual focused element inside. Shortcut suppression during text input would fail because the check sees `<search-bar>` not `<input>`.
**Why it happens:** Lit components use Shadow DOM. The focused `<input>` inside `<search-bar>` shadow root isn't directly visible to `document.activeElement`.
**How to avoid:** Walk the `shadowRoot.activeElement` chain recursively until reaching the leaf focused element (shown in Pattern 3 above).
**Warning signs:** Keyboard shortcuts firing while typing in the search box.
### Pitfall 3: Race Between Cancel and Batch Commit
**What goes wrong:** Calling `CancelScan()` while a batch transaction is in progress could leave the database in an inconsistent state if the context is cancelled during `tx.Commit()`.
**Why it happens:** SQLite `Commit()` with modernc.org/sqlite checks context cancellation.
**How to avoid:** The scan context should be checked between batches, not during a commit. Use a separate check: after each `flushBatch()` call, check if `scanCtx` is done before processing more results. The batch commit itself should use the parent `l.ctx` (not the scan-specific cancellable context) so in-flight transactions always complete.
**Warning signs:** "database is locked" errors or partial batch commits.
### Pitfall 4: Key Combo String Normalization
**What goes wrong:** Different representations of the same key combo: "ctrl+f" vs "Ctrl+F" vs "Control+f" — lookups fail.
**Why it happens:** No consistent normalization of key strings.
**How to avoid:** Define a canonical format: modifiers in fixed order (Ctrl+Alt+Shift+Meta) + lowercase key name. Always normalize both when storing and when matching.
**Warning signs:** Shortcuts not firing after reassignment, or duplicate entries in settings.
### Pitfall 5: Space Key Conflicts with Scrollable Areas
**What goes wrong:** Space is the default browser scroll-down key. If Space is bound to play/pause globally, scrollable panels may stop scrolling.
**Why it happens:** `e.preventDefault()` on Space prevents the browser's native scroll behavior.
**How to avoid:** The scope system handles this — when a scrollable panel has focus and the user intends to scroll, the panel-specific scope should not have Space bound. The Global scope's Space binding calls `preventDefault()` which is acceptable since this is a desktop app (not a web page), and the primary use of Space is play/pause.
**Warning signs:** Users unable to scroll with keyboard in track lists.
### Pitfall 6: Partial Results Handling on Cancel
**What goes wrong:** When user cancels and chooses "discard," the backend has already committed batches to the database. Rolling back multiple committed transactions is complex.
**Why it happens:** Scan writes in batches of 50 that are committed as they go.
**How to avoid:** "Discard" means "delete the tracks added during this scan." Track which audio file IDs were added during the current scan (via the `added` counter mechanism — extend to track IDs). On discard, delete those specific records. Alternatively, simpler: "discard" triggers a FullRescan minus the cancel-interrupted data. Given complexity, the simpler approach is: "Keep" is the default, "Discard" just clears the entire library (same as FullRescan clear phase) since partial state is unreliable.
**Warning signs:** Stale or duplicate entries after cancel-and-discard.
## Code Examples
### Scan Control — Backend Methods
```go
// scan_control.go
// CancelScan cancels an in-progress scan. Returns immediately;
// the scan goroutines will stop at their next check point.
func (l *Library) CancelScan() {
l.mu.Lock()
defer l.mu.Unlock()
if l.scanCancel != nil {
l.scanCancel()
}
}
// PauseScan pauses an in-progress scan. Workers block at their
// next pause checkpoint until ResumeScan is called.
func (l *Library) PauseScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanActive || l.scanPaused {
return
}
l.scanPaused = true
l.scanPauseCh = make(chan struct{})
runtime.EventsEmit(l.ctx, events.LibraryScanPaused)
}
// ResumeScan unblocks a paused scan.
func (l *Library) ResumeScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanPaused {
return
}
l.scanPaused = false
close(l.scanPauseCh) // unblocks all waiting workers
runtime.EventsEmit(l.ctx, events.LibraryScanResumed)
}
// IsScanActive returns the current scan state for the frontend.
func (l *Library) IsScanActive() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.scanActive
}
// IsScanPaused returns whether the scan is currently paused.
func (l *Library) IsScanPaused() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.scanPaused
}
```
### Key String Builder
```typescript
// keyboard-shortcut-service.ts
function buildKeyString(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
// Normalize key name
let key = e.key;
// Skip standalone modifier presses
if (['Control', 'Alt', 'Shift', 'Meta'].includes(key)) {
return '';
}
// Normalize common key names
if (key === ' ') key = 'Space';
if (key === 'ArrowUp') key = 'Up';
if (key === 'ArrowDown') key = 'Down';
if (key === 'ArrowLeft') key = 'Left';
if (key === 'ArrowRight') key = 'Right';
// Single character keys: uppercase for display
if (key.length === 1) key = key.toUpperCase();
parts.push(key);
return parts.join('+');
}
```
### Default Bindings Map
```typescript
// Based on user decisions + common media player conventions
const DEFAULT_BINDINGS: Record<string, ShortcutBinding> = {
// Player controls (Global scope, no modifier)
'player.playPause': { key: 'Space', scope: 'global', category: 'Player' },
'player.volumeUp': { key: 'Up', scope: 'global', category: 'Player' },
'player.volumeDown': { key: 'Down', scope: 'global', category: 'Player' },
'player.seekForward': { key: 'Right', scope: 'global', category: 'Player' },
'player.seekBack': { key: 'Left', scope: 'global', category: 'Player' },
'player.shuffle': { key: 'S', scope: 'global', category: 'Player' },
'player.repeat': { key: 'R', scope: 'global', category: 'Player' },
'player.mute': { key: 'M', scope: 'global', category: 'Player' },
'player.next': { key: 'N', scope: 'global', category: 'Player' },
'player.previous': { key: 'P', scope: 'global', category: 'Player' },
// Navigation (Global scope)
'nav.search': { key: '/', scope: 'global', category: 'Navigation' },
'nav.searchAlt': { key: 'Ctrl+F', scope: 'global', category: 'Navigation' },
'nav.queue': { key: 'Q', scope: 'global', category: 'Navigation' },
// App actions (Global scope, Ctrl modifier)
'app.selectAll': { key: 'Ctrl+A', scope: 'global', category: 'App' },
// Panel-specific (track list focused)
'tracklist.play': { key: 'Enter', scope: 'panel:track-list', category: 'Navigation' },
'tracklist.delete': { key: 'Delete', scope: 'panel:track-list', category: 'Navigation' },
};
```
### Shortcut Settings Tab — Key Capture Widget
```typescript
// shortcut-capture.ts — Record-style key capture (VS Code inspired)
@customElement('shortcut-capture')
class ShortcutCapture extends LitElement {
@property() action = '';
@property() currentKey = '';
@state() private recording = false;
@state() private pendingKey = '';
private handleClick = () => {
this.recording = true;
this.pendingKey = '';
};
private handleKeydown = (e: KeyboardEvent) => {
if (!this.recording) return;
e.preventDefault();
e.stopPropagation();
const keyStr = buildKeyString(e);
if (!keyStr) return; // bare modifier press
if (keyStr === 'Escape') {
// Cancel recording
this.recording = false;
this.pendingKey = '';
return;
}
this.pendingKey = keyStr;
this.recording = false;
// Dispatch event for parent to handle conflict check + save
this.dispatchEvent(new CustomEvent('shortcut-change', {
detail: { action: this.action, key: keyStr },
bubbles: true, composed: true,
}));
};
override render() {
return html`
<button
class=${this.recording ? 'recording' : ''}
@click=${this.handleClick}
@keydown=${this.handleKeydown}
>
${this.recording
? 'Press a key combo...'
: this.pendingKey || this.currentKey || 'Not set'}
</button>
`;
}
}
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `KeyboardEvent.keyCode` | `KeyboardEvent.key` / `.code` | Deprecated for years | Use `.key` for logical key, `.code` for physical position |
| Manual goroutine cancellation with channels | `context.WithCancel` | Standard since Go 1.7 (2016) | Composes with existing context-aware APIs |
| Global keyboard shortcut libraries (mousetrap, hotkeys.js) | Native KeyboardEvent API | N/A | Desktop Wails app doesn't need library overhead |
**Deprecated/outdated:**
- `KeyboardEvent.keyCode` / `KeyboardEvent.which`: Deprecated. Use `.key` for the logical key value.
- `KeyboardEvent.charCode`: Removed. Not relevant for this use case.
## Open Questions
1. **Volume step size for arrow keys**
- What we know: Up/Down arrows should adjust volume. Player.SetVolume accepts 0-100 integer.
- What's unclear: Step size per keypress (5? 10?)
- Recommendation: Default to 5 units per keypress (matches common media player conventions). This is a Claude's Discretion item.
2. **Seek step size for arrow keys**
- What we know: Left/Right arrows should seek. Player.Seek accepts seconds.
- What's unclear: How many seconds per keypress.
- Recommendation: Default to 5 seconds per keypress. This is a Claude's Discretion item.
3. **"Discard" implementation on scan cancel**
- What we know: User can choose "Keep X tracks" or "Discard." Keeping is straightforward (do nothing).
- What's unclear: Precise discard mechanism — delete individual added IDs vs clear-and-rescan approach.
- Recommendation: Track added audio file IDs during the scan. On discard, batch-delete those IDs within a transaction. This avoids the nuclear option of a full library clear while being precise. If this proves too complex, a simpler fallback is to trigger the library clear tables operation (existing `clearLibraryTables()`) and leave the user with an empty library that they can rescan.
4. **N and P for next/previous vs typing**
- What we know: Single-key shortcuts (S, R, Q) work in global scope. N/P follow the same pattern.
- What's unclear: Whether N/P could conflict with other planned features (e.g., future search-as-you-type).
- Recommendation: Include N/P as defaults but since all bindings are configurable, users can remap if conflicts arise. The text input scope suppression ensures they don't fire during typing.
## Sources
### Primary (HIGH confidence)
- **Codebase analysis** — Direct reading of all scanner, config, events, and frontend component source files
- **Go `context` package** — Standard library documentation for `WithCancel` pattern
- **MDN `KeyboardEvent`** — `e.key`, `e.code`, modifier properties (`ctrlKey`, `altKey`, `shiftKey`, `metaKey`)
### Secondary (MEDIUM confidence)
- **VS Code keybinding UX** — Reference for record-style key capture interaction pattern (widely adopted UX pattern)
- **Wails v2 event system** — `runtime.EventsEmit` / `EventsOn` patterns verified from existing codebase usage
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — no new dependencies, all patterns verified from existing codebase and Go/Web standards
- Architecture: HIGH — extends existing patterns (config sections, Wails bindings, Lit components, event system)
- Pitfalls: HIGH — identified from direct codebase analysis (shadow DOM, orphan cleanup, batch commits)
**Research date:** 2026-03-06
**Valid until:** 2026-04-06 (stable domain — no rapidly changing dependencies)