diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 3a70eef..c8bb791 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -124,7 +124,12 @@ Plans:
2. Store notifications during rapid updates (e.g., library scan) are debounced via `queueMicrotask()` to prevent layout thrashing
3. Visual inconsistencies (spacing, colors, typography, icon sizing) are audited and follow a consistent pattern across all components
4. Scrolling, view switching, and search filtering in a 10k+ track library are smooth with no visible jank or dropped frames
-**Plans:** TBD
+**Plans:** 4 plans
+Plans:
+- [ ] 08-01-PLAN.md — Store debouncing (queueMicrotask), search debounce, design token definitions
+- [ ] 08-02-PLAN.md — Virtualizer repeat() directive migration (all 5 components)
+- [ ] 08-03-PLAN.md — Track-list/queue-panel render optimization (classMap, search highlight short-circuit)
+- [ ] 08-04-PLAN.md — Visual consistency audit & token application across all components
## Progress
@@ -137,7 +142,7 @@ Plans:
| 5. Database & Library Tests | 2/2 | Complete | 2026-03-04 |
| 6. SQL Consolidation & Code Quality | 2/3 | In Progress | — |
| 7. Backend Performance | 0/2 | Not started | — |
-| 8. Frontend Performance & UX | 0/? | Not started | — |
+| 8. Frontend Performance & UX | 0/4 | Not started | — |
---
*Roadmap created: 2026-02-27*
diff --git a/.planning/phases/08-frontend-performance-ux/08-01-PLAN.md b/.planning/phases/08-frontend-performance-ux/08-01-PLAN.md
new file mode 100644
index 0000000..b4f4734
--- /dev/null
+++ b/.planning/phases/08-frontend-performance-ux/08-01-PLAN.md
@@ -0,0 +1,232 @@
+---
+phase: 08-frontend-performance-ux
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - frontend/src/store/library-store.ts
+ - frontend/src/components/search-bar/search-bar.ts
+ - frontend/src/styles/tokens.css.ts
+autonomous: true
+requirements:
+ - PERF-05
+ - UX-01
+
+must_haves:
+ truths:
+ - "Library store notifications during rapid updates (scan, invalidation) are coalesced into a single subscriber notification per microtask tick"
+ - "CSS custom properties for icon sizing (--yj-icon-sm, --yj-icon-md, --yj-icon-lg) and type scale (--yj-text-xs through --yj-text-lg) are defined and available to all components"
+ - "Search input is debounced ~150ms before triggering filter/rank computation"
+ artifacts:
+ - path: "frontend/src/store/library-store.ts"
+ provides: "queueMicrotask-based notification coalescing"
+ contains: "queueMicrotask"
+ - path: "frontend/src/styles/tokens.css.ts"
+ provides: "Design token definitions for icon sizes and type scale"
+ contains: "--yj-icon-sm"
+ - path: "frontend/src/components/search-bar/search-bar.ts"
+ provides: "Debounced search input"
+ contains: "debounce"
+ key_links:
+ - from: "frontend/src/store/library-store.ts"
+ to: "subscribers"
+ via: "queueMicrotask coalescing in notify()"
+ pattern: "queueMicrotask"
+ - from: "frontend/src/styles/tokens.css.ts"
+ to: "all components"
+ via: "CSS custom property inheritance from :host or adopted stylesheets"
+ pattern: "--yj-icon-sm|--yj-text-xs"
+---
+
+
+Add performance plumbing (store debouncing, search debounce) and define the design token foundation (icon sizes, type scale) that all subsequent plans depend on.
+
+Purpose: Library store fires 8+ notifications during scan invalidation (4 parallel fetches × 2 notifications each). Coalescing via queueMicrotask prevents layout thrashing. Design tokens establish the visual vocabulary that Plan 04 will systematically apply.
+Output: Debounced store, debounced search, design token CSS file.
+
+
+
+@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
+@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/08-frontend-performance-ux/08-CONTEXT.md
+
+@frontend/src/store/library-store.ts
+@frontend/src/components/search-bar/search-bar.ts
+
+
+
+
+From frontend/src/store/library-store.ts:
+```typescript
+type Subscriber = () => void;
+
+class LibraryStore {
+ private subscribers = new Set();
+
+ // Current notify — called ~12 times during invalidate→eagerFetch cycle:
+ private notify(): void {
+ this.subscribers.forEach((callback) => callback());
+ }
+
+ // Called from: getTracks/getAlbums/getArtists/getGenres (loading start + end),
+ // invalidate(), setCoverSize()
+
+ subscribe(callback: Subscriber): () => void {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+}
+
+export const libraryStore = new LibraryStore();
+```
+
+From frontend/src/store/search-store.ts:
+```typescript
+class SearchStore {
+ private term = '';
+ setTerm(term: string): void {
+ if (term === this.term) return;
+ this.term = term;
+ this.notify();
+ }
+}
+export const searchStore = new SearchStore();
+```
+
+From frontend/src/components/search-bar/search-bar.ts:
+```typescript
+// Current: directly sets search term on every input event
+// searchCtrl is a SearchController with a `term` setter
+this.searchCtrl.term = input.value;
+```
+
+Existing CSS custom properties (already defined, DO NOT redefine):
+- --yj-text-primary, --yj-text-secondary, --yj-text-tertiary
+- --yj-bg-surface, --yj-bg-elevated, --yj-bg-overlay, --yj-bg-base
+- --yj-border, --yj-border-subtle
+- --yj-accent, --yj-accent-bg
+- --yj-hover-overlay, --yj-selection-bg, --yj-error
+
+
+
+
+
+
+ Task 1: Add queueMicrotask debouncing to library store and search input debounce
+ frontend/src/store/library-store.ts, frontend/src/components/search-bar/search-bar.ts
+
+ **Library store debouncing (library-store.ts):**
+
+ Replace the current `notify()` method with a queueMicrotask-based coalescing pattern:
+
+ 1. Add a private boolean field `private notifyScheduled = false;`
+ 2. Replace `notify()` implementation:
+ ```typescript
+ private notify(): void {
+ if (this.notifyScheduled) return;
+ this.notifyScheduled = true;
+ queueMicrotask(() => {
+ this.notifyScheduled = false;
+ this.subscribers.forEach((callback) => callback());
+ });
+ }
+ ```
+
+ This coalesces ALL notify() calls within the same microtask tick into a single subscriber notification round. During invalidate() → eagerFetch() → 4 parallel fetches × 2 notifications each = 8+ calls → 1 actual notification.
+
+ The subscribe() API is unchanged — this is transparent to subscribers.
+
+ **Search input debounce (search-bar.ts):**
+
+ Add a ~150ms debounce to the search input handler so that rapid typing doesn't trigger expensive filter/rank computation on every keystroke.
+
+ 1. Add a private timer field: `private searchDebounceTimer: ReturnType | null = null;`
+ 2. In the input handler, instead of immediately setting `this.searchCtrl.term = input.value`:
+ - Clear any existing timer
+ - If the input is empty, set term immediately (instant clear feedback)
+ - Otherwise, set a 150ms timeout that sets `this.searchCtrl.term`
+
+ Do NOT debounce the visual update of the input field itself — only debounce the propagation to the search store. The input should still show characters as typed.
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ Library store notify() uses queueMicrotask to coalesce multiple calls per tick. Search input debounces store propagation by 150ms while maintaining instant visual feedback on the input element.
+
+
+
+ Task 2: Define design token CSS custom properties for icon sizes and type scale
+ frontend/src/styles/tokens.css.ts
+
+ Create a new file `frontend/src/styles/tokens.css.ts` that exports a Lit `css` tagged template with design token definitions.
+
+ Use the same pattern as other style files in the project — export a `css` tagged template literal from `lit`.
+
+ ```typescript
+ import { css } from 'lit';
+
+ /**
+ * Design tokens for consistent sizing across all components.
+ * Import and include in a component's static styles array:
+ *
+ * import { designTokens } from '../../styles/tokens.css';
+ * static styles = [designTokens, css`...`];
+ */
+ export const designTokens = css`
+ :host {
+ /* ── Icon sizes ── */
+ --yj-icon-sm: 14px;
+ --yj-icon-md: 18px;
+ --yj-icon-lg: 24px;
+
+ /* ── Type scale ── */
+ --yj-text-xs: 11px;
+ --yj-text-sm: 12px;
+ --yj-text-md: 13px;
+ --yj-text-lg: 15px;
+ --yj-text-xl: 18px;
+ }
+ `;
+ ```
+
+ **Design rationale:**
+ - Icon sizes: sm=14px covers small inline icons (favorites, sort indicators), md=18px covers standard toolbar/sidebar icons, lg=24px covers feature icons (now-playing placeholder, large action icons)
+ - Type scale: xs=11px for smallest text (cover-grid small cards), sm=12px for secondary info and labels, md=13px for body text and inputs, lg=15px for headings and emphasis, xl=18px for large titles
+ - These values are derived from the actual pixel values already scattered across the codebase — this consolidates them rather than inventing new sizes
+ - :host scope means tokens are available within each component that imports the stylesheet
+
+ Verify the file path exists: check for a `frontend/src/styles/` directory. If it doesn't exist, create it.
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ Design token file exists at frontend/src/styles/tokens.css.ts, exports `designTokens` css template with --yj-icon-sm/md/lg and --yj-text-xs/sm/md/lg/xl custom properties on :host.
+
+
+
+
+
+1. `cd frontend && npx tsc --noEmit` compiles without errors
+2. library-store.ts contains `queueMicrotask` in the notify method
+3. search-bar.ts has debounce logic with ~150ms delay
+4. frontend/src/styles/tokens.css.ts exists and exports designTokens
+5. No behavioral regressions — subscribe() API is unchanged, search still works
+
+
+
+- Library store notify() coalesces multiple calls within a microtask tick into one notification round
+- Search input propagation to store is debounced by ~150ms (empty input clears immediately)
+- Design token file defines --yj-icon-sm/md/lg and --yj-text-xs/sm/md/lg/xl
+- TypeScript compiles without errors
+
+
+
diff --git a/.planning/phases/08-frontend-performance-ux/08-02-PLAN.md b/.planning/phases/08-frontend-performance-ux/08-02-PLAN.md
new file mode 100644
index 0000000..b589111
--- /dev/null
+++ b/.planning/phases/08-frontend-performance-ux/08-02-PLAN.md
@@ -0,0 +1,311 @@
+---
+phase: 08-frontend-performance-ux
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - frontend/src/components/track-list/track-list.ts
+ - frontend/src/components/queue-panel/queue-panel.ts
+ - frontend/src/components/cover-grid/cover-grid.ts
+ - frontend/src/components/artists-view/artists-view.ts
+ - frontend/src/components/genres-view/genres-view.ts
+autonomous: true
+requirements:
+ - PERF-05
+ - UX-02
+
+must_haves:
+ truths:
+ - "All virtualizer components use repeat() directive with stable keys instead of .items/.renderItem"
+ - "Track list uses FilePath as key, cover grid uses album.ID, queue panel uses QueueTrack.id"
+ - "Artists and genres views use their entity ID as repeat() key"
+ - "Scrolling through 10k+ tracks reuses DOM nodes efficiently via keyed repeat()"
+ artifacts:
+ - path: "frontend/src/components/track-list/track-list.ts"
+ provides: "repeat() with FilePath key for track virtualizer"
+ contains: "repeat("
+ - path: "frontend/src/components/queue-panel/queue-panel.ts"
+ provides: "repeat() with QueueTrack.id key for queue virtualizer"
+ contains: "repeat("
+ - path: "frontend/src/components/cover-grid/cover-grid.ts"
+ provides: "repeat() with album.ID key for all 3 cover grid virtualizers"
+ contains: "repeat("
+ - path: "frontend/src/components/artists-view/artists-view.ts"
+ provides: "repeat() with artist entry key"
+ contains: "repeat("
+ - path: "frontend/src/components/genres-view/genres-view.ts"
+ provides: "repeat() with genre entry key"
+ contains: "repeat("
+ key_links:
+ - from: "track-list.ts"
+ to: "lit-virtualizer"
+ via: "repeat() directive as child of lit-virtualizer"
+ pattern: "repeat\\(.*FilePath"
+ - from: "cover-grid.ts"
+ to: "lit-virtualizer"
+ via: "repeat() directive replacing .items/.renderItem/.keyFunction"
+ pattern: "repeat\\(.*album\\.ID"
+---
+
+
+Migrate all virtualizer components from the `.items/.renderItem` property pattern to Lit's `repeat()` directive with stable keys for efficient DOM reuse during scrolling and filtering.
+
+Purpose: The repeat() directive with stable keys enables Lit's DOM recycling — when items are reordered, added, or removed, Lit moves existing DOM nodes instead of destroying and recreating them. This eliminates jank during scrolling and filtering in large libraries.
+Output: All 5 virtualizer components use repeat() with appropriate stable keys.
+
+
+
+@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
+@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/08-frontend-performance-ux/08-CONTEXT.md
+
+@frontend/src/components/track-list/track-list.ts
+@frontend/src/components/queue-panel/queue-panel.ts
+@frontend/src/components/cover-grid/cover-grid.ts
+@frontend/src/components/artists-view/artists-view.ts
+@frontend/src/components/genres-view/genres-view.ts
+
+
+
+
+track-list.ts (1 virtualizer):
+```html
+
+```
+Key: track.FilePath (unique per track, string)
+renderTrackRow signature: (track: library.Track, index: number) => TemplateResult
+
+cover-grid.ts (3 virtualizers — main grid, before-split, after-split):
+```html
+
+```
+Current gridKeyFunction: `(entry: GridEntry) => \`a-${entry.album.ID}\``
+Key: entry.album.ID (number, use as string in repeat key)
+renderGridEntry signature: (entry: GridEntry, index: number) => TemplateResult
+
+queue-panel.ts (1 virtualizer):
+```html
+
+```
+Key: QueueTrack.id (string field, unique per queue entry even for duplicate tracks)
+renderTrackItem signature: (track: QueueTrack, index: number) => TemplateResult
+
+artists-view.ts (1 virtualizer):
+```html
+ this.renderArtistCard(entry)}
+>
+```
+Key: entry.artist.ID (number)
+
+genres-view.ts (1 virtualizer):
+```html
+ this.renderGenreCard(entry)}
+>
+```
+Key: entry.genre.Name (string, genres identified by name)
+
+Import needed:
+```typescript
+import { repeat } from 'lit/directives/repeat.js';
+```
+
+
+
+
+
+
+ Task 1: Migrate track-list and queue-panel virtualizers to repeat() directive
+ frontend/src/components/track-list/track-list.ts, frontend/src/components/queue-panel/queue-panel.ts
+
+ Both components use flow layout virtualizers with `.items` + `.renderItem`. Convert to repeat() directive.
+
+ **track-list.ts:**
+
+ 1. Add import: `import { repeat } from 'lit/directives/repeat.js';`
+ 2. Find the `` element (around line 1736-1741). Replace:
+ ```html
+
+ ```
+ With:
+ ```html
+
+ ${repeat(
+ visibleTracks,
+ (track) => track.FilePath,
+ (track, index) => this.renderTrackRow(track, index),
+ )}
+
+ ```
+ 3. Remove the `.renderItem` property but keep `.items` — lit-virtualizer still needs `.items` for scroll sizing/virtualization calculations even when using repeat() for rendering.
+ 4. Keep all other virtualizer properties unchanged (`.layout`, event handlers, etc.).
+
+ **queue-panel.ts:**
+
+ 1. Add import: `import { repeat } from 'lit/directives/repeat.js';`
+ 2. Find the `` element (around line 1282-1288). Replace the same pattern:
+ ```html
+
+ ```
+ With:
+ ```html
+
+ ${repeat(
+ tracks,
+ (track) => track.id,
+ (track, index) => this.renderTrackItem(track, index),
+ )}
+
+ ```
+ 3. Remove `.renderItem` property, keep `.items`.
+
+ **Important:** The `renderTrackRow` and `renderTrackItem` methods stay as-is. The repeat() directive wraps them — it provides the key function, while the existing render methods provide the template. Do NOT change render method signatures.
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ track-list.ts uses repeat() with FilePath key. queue-panel.ts uses repeat() with QueueTrack.id key. Both keep .items for virtualization sizing. TypeScript compiles.
+
+
+
+ Task 2: Migrate cover-grid, artists-view, and genres-view virtualizers to repeat() directive
+ frontend/src/components/cover-grid/cover-grid.ts, frontend/src/components/artists-view/artists-view.ts, frontend/src/components/genres-view/genres-view.ts
+
+ **cover-grid.ts (3 virtualizers):**
+
+ 1. Add import: `import { repeat } from 'lit/directives/repeat.js';`
+ 2. Cover-grid has THREE `` instances (main grid ~line 1853, before-split ~line 1880, after-split ~line 1909). ALL three currently use `.items`, `.renderItem`, and `.keyFunction`. Convert ALL three.
+
+ For each virtualizer, replace:
+ ```html
+
+ ```
+ With:
+ ```html
+
+ ${repeat(
+ items,
+ (entry) => entry.album.ID,
+ (entry, index) => this.renderGridEntry(entry, index),
+ )}
+
+ ```
+
+ 3. Remove both `.renderItem` and `.keyFunction` properties from all three virtualizers.
+ 4. The `gridKeyFunction` method can be removed since its logic is now inline in the repeat() calls. Alternatively, keep it as a private method and reference it: `(entry) => this.gridKeyFunction(entry)` — either approach is fine, but inline is cleaner.
+ 5. Keep `.items` on all three for virtualization sizing.
+ 6. Preserve all other properties (`.layout`, CSS classes, event handlers).
+
+ **artists-view.ts (1 virtualizer):**
+
+ 1. Add import: `import { repeat } from 'lit/directives/repeat.js';`
+ 2. Find the virtualizer (~line 1217-1227). Replace:
+ ```html
+ this.renderArtistCard(entry)}
+ >
+ ```
+ With:
+ ```html
+
+ ${repeat(
+ entries,
+ (entry) => entry.artist.ID,
+ (entry) => this.renderArtistCard(entry),
+ )}
+
+ ```
+ 3. Determine the correct key — look at the ArtistEntry type to find the artist ID field. Use the artist's unique identifier.
+
+ **genres-view.ts (1 virtualizer):**
+
+ 1. Add import: `import { repeat } from 'lit/directives/repeat.js';`
+ 2. Find the virtualizer (~line 1169-1177). Same pattern:
+ ```html
+ this.renderGenreCard(entry)}
+ >
+ ```
+ With:
+ ```html
+
+ ${repeat(
+ entries,
+ (entry) => entry.genre.Name,
+ (entry) => this.renderGenreCard(entry),
+ )}
+
+ ```
+ 3. Determine the correct key — genres are identified by name (string). Use the genre name as key.
+
+ **Important for all:** Keep `.items` property on virtualizers. The virtualizer needs the items array for scroll height calculation and viewport management. The repeat() directive handles the rendering and keying.
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ All three cover-grid virtualizers use repeat() with album.ID key. artists-view uses repeat() with artist ID key. genres-view uses repeat() with genre name key. .keyFunction and .renderItem properties removed. TypeScript compiles.
+
+
+
+
+
+1. `cd frontend && npx tsc --noEmit` compiles without errors
+2. All 7 virtualizer instances across 5 files use repeat() directive
+3. No .renderItem properties remain on any lit-virtualizer element
+4. No .keyFunction properties remain on any lit-virtualizer element
+5. All virtualizers retain .items property for scroll sizing
+6. Stable keys: FilePath (tracks), album.ID (covers), QueueTrack.id (queue), artist.ID (artists), genre.Name (genres)
+
+
+
+- Every lit-virtualizer in the codebase uses repeat() directive with stable keys
+- .items is preserved on all virtualizers for virtualization sizing
+- .renderItem and .keyFunction properties are removed
+- TypeScript compiles without errors
+
+
+
diff --git a/.planning/phases/08-frontend-performance-ux/08-03-PLAN.md b/.planning/phases/08-frontend-performance-ux/08-03-PLAN.md
new file mode 100644
index 0000000..5da5a9d
--- /dev/null
+++ b/.planning/phases/08-frontend-performance-ux/08-03-PLAN.md
@@ -0,0 +1,168 @@
+---
+phase: 08-frontend-performance-ux
+plan: 03
+type: execute
+wave: 2
+depends_on:
+ - "08-01"
+ - "08-02"
+files_modified:
+ - frontend/src/components/track-list/track-list.ts
+autonomous: true
+requirements:
+ - PERF-05
+ - UX-02
+
+must_haves:
+ truths:
+ - "renderTrackRow does not allocate arrays or join strings for CSS classes on every render call"
+ - "Column values used in rendering are pre-computed or cached, not recomputed per-cell on every render"
+ - "Scrolling through a 10k+ track list is smooth with no visible jank"
+ artifacts:
+ - path: "frontend/src/components/track-list/track-list.ts"
+ provides: "Optimized renderTrackRow with cached class strings and pre-computed column values"
+ contains: "classMap\\|ifDefined\\|cached"
+ key_links:
+ - from: "frontend/src/components/track-list/track-list.ts renderTrackRow"
+ to: "repeat() directive"
+ via: "Called per-item by repeat() — must be fast"
+ pattern: "renderTrackRow"
+---
+
+
+Optimize the track-list renderTrackRow method to minimize per-row allocations and template computation during scrolling and filtering.
+
+Purpose: renderTrackRow is the hot path for the largest list component. It's called for every visible row on every scroll event. Current implementation builds CSS class strings via array filter/join and computes column values per-cell on every call. With 10k+ tracks, reducing per-row work directly impacts scroll smoothness.
+Output: Optimized renderTrackRow with cached class strings and efficient column rendering.
+
+
+
+@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
+@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/08-frontend-performance-ux/08-CONTEXT.md
+@.planning/phases/08-frontend-performance-ux/08-01-SUMMARY.md
+@.planning/phases/08-frontend-performance-ux/08-02-SUMMARY.md
+
+@frontend/src/components/track-list/track-list.ts
+
+
+
+
+Current renderTrackRow pattern (approximate):
+```typescript
+private renderTrackRow = (track: library.Track, index: number) => {
+ // 1. Class string built via array filter/join on EVERY render:
+ const classes = [
+ 'track-row',
+ this.isSelected(track) ? 'selected' : '',
+ this.isCurrentTrack(track) ? 'playing' : '',
+ // ... more conditions
+ ].filter(Boolean).join(' ');
+
+ // 2. Column values computed per-cell via accessor:
+ // col.accessor(track) called for each column on each row
+
+ // 3. Search highlighting applied per-cell
+};
+```
+
+Optimization targets:
+1. Replace array filter/join class construction with Lit's classMap directive
+2. Pre-compute or cache column accessor results where possible
+3. Avoid object/array allocations in the render hot path
+
+
+
+
+
+
+ Task 1: Replace class string construction with classMap directive in renderTrackRow
+ frontend/src/components/track-list/track-list.ts
+
+ The current renderTrackRow builds CSS class strings by creating an array of conditional class names, filtering out falsy values, and joining with spaces — this allocates a new array and string on every render call for every visible row.
+
+ Replace with Lit's `classMap` directive which is purpose-built for conditional classes and avoids these allocations:
+
+ 1. Add import: `import { classMap } from 'lit/directives/class-map.js';` (if not already imported)
+ 2. In renderTrackRow, find every pattern like:
+ ```typescript
+ const classes = ['base-class', condition ? 'class-a' : '', ...].filter(Boolean).join(' ');
+ // Used as: class="${classes}"
+ ```
+ 3. Replace with:
+ ```typescript
+ // Used as: class=${classMap({ 'base-class': true, 'class-a': condition, ... })}
+ ```
+
+ Read the full renderTrackRow method carefully — there may be multiple class string constructions (row-level and cell-level). Convert ALL of them.
+
+ The classMap object literal is still allocated per-call, but classMap internally compares with previous values and only updates changed classes — it's significantly faster than string concatenation for Lit's update cycle.
+
+ Also check `renderTrackItem` in queue-panel.ts for the same pattern — if it uses array filter/join for classes, apply the same classMap conversion there too. (Queue panel was listed in CONTEXT.md as having this pattern.)
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ All class string construction in renderTrackRow uses classMap directive instead of array filter/join. No .filter(Boolean).join(' ') patterns remain in track-list render methods. TypeScript compiles.
+
+
+
+ Task 2: Optimize column value computation and apply classMap to queue-panel renderTrackItem
+ frontend/src/components/track-list/track-list.ts, frontend/src/components/queue-panel/queue-panel.ts
+
+ **Track-list column optimization (track-list.ts):**
+
+ Read the full renderTrackRow method to understand how column values are computed. The current pattern calls `col.accessor(track)` for each visible column on each row during render.
+
+ Optimization approach — evaluate what's actually expensive:
+ 1. If `col.accessor` is a simple property lookup (e.g., `track.Title`, `track.Artist`), it's already fast — no caching needed
+ 2. If any accessor does computation (string formatting, duration conversion, etc.), consider whether it can be memoized or moved outside the per-cell loop
+ 3. If search highlighting is applied per-cell, check if the highlight computation can be short-circuited when there's no active search term (skip the regex/string manipulation entirely when term is empty)
+
+ Focus on the highest-impact optimizations:
+ - **Search highlight short-circuit**: When searchTerm is empty, skip all highlight logic entirely — just render the raw column value. This eliminates regex creation and string splitting for every cell in the common case.
+ - **Duration formatting**: If a time/duration column reformats on every render, cache the formatted string on the track object or in a WeakMap.
+
+ Do NOT over-optimize — if accessor is just `track.Title`, a cache would be slower than the direct access. Only optimize where measurement or code inspection shows actual waste.
+
+ **Queue-panel classMap (queue-panel.ts):**
+
+ Apply the same classMap directive conversion to renderTrackItem in queue-panel.ts:
+ 1. Add import: `import { classMap } from 'lit/directives/class-map.js';`
+ 2. Find the class string construction pattern (array filter/join) in renderTrackItem
+ 3. Convert to classMap directive (same pattern as Task 1)
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ Track-list search highlighting is short-circuited when search term is empty. Queue-panel renderTrackItem uses classMap. No unnecessary per-row allocations in render hot paths. TypeScript compiles.
+
+
+
+
+
+1. `cd frontend && npx tsc --noEmit` compiles without errors
+2. No `.filter(Boolean).join(' ')` patterns in track-list.ts or queue-panel.ts render methods
+3. classMap directive is used for all conditional CSS classes in render hot paths
+4. Search highlighting short-circuits when search term is empty
+5. No regressions — row selection, playing indicator, and search highlighting still work
+
+
+
+- renderTrackRow uses classMap for all conditional CSS classes
+- renderTrackItem (queue) uses classMap for all conditional CSS classes
+- Search highlighting skips computation when search term is empty
+- No array allocations (filter/join) in render hot paths
+- TypeScript compiles without errors
+
+
+
diff --git a/.planning/phases/08-frontend-performance-ux/08-04-PLAN.md b/.planning/phases/08-frontend-performance-ux/08-04-PLAN.md
new file mode 100644
index 0000000..fd54255
--- /dev/null
+++ b/.planning/phases/08-frontend-performance-ux/08-04-PLAN.md
@@ -0,0 +1,267 @@
+---
+phase: 08-frontend-performance-ux
+plan: 04
+type: execute
+wave: 2
+depends_on:
+ - "08-01"
+files_modified:
+ - frontend/src/components/sidebar/app-sidebar.ts
+ - frontend/src/components/now-playing/now-playing.ts
+ - frontend/src/components/search-bar/search-bar.ts
+ - frontend/src/components/audio-player/controls/player-controls.ts
+ - frontend/src/components/audio-player/seekbar/seek-bar.ts
+ - frontend/src/components/audio-player/volume-control/volume-control.ts
+ - frontend/src/components/audio-player/audio-player.ts
+ - frontend/src/components/cover-grid/cover-grid.ts
+ - frontend/src/components/cover-grid/cover-grid-styles.ts
+ - frontend/src/components/track-list/track-list.ts
+ - frontend/src/components/queue-panel/queue-panel.ts
+ - frontend/src/components/track-details/track-details.ts
+ - frontend/src/components/track-info/track-info.ts
+ - frontend/src/components/artist-details/artist-details.ts
+ - frontend/src/components/genre-details/genre-details.ts
+autonomous: false
+requirements:
+ - UX-01
+
+must_haves:
+ truths:
+ - "All components use px-based spacing (no em-based padding/gap/margin in sidebar or anywhere)"
+ - "Icon sizes reference --yj-icon-sm/md/lg tokens instead of ad-hoc pixel or em values"
+ - "Typography references --yj-text-xs/sm/md/lg/xl tokens instead of ad-hoc font-size values"
+ - "Cover-grid dynamic text sizing tiers map to the type scale tokens"
+ - "Visual consistency is verified by human inspection across all views"
+ artifacts:
+ - path: "frontend/src/components/sidebar/app-sidebar.ts"
+ provides: "px-based spacing, icon tokens"
+ contains: "--yj-icon-"
+ - path: "frontend/src/components/now-playing/now-playing.ts"
+ provides: "Icon tokens for cover placeholder"
+ contains: "--yj-icon-lg"
+ - path: "frontend/src/components/search-bar/search-bar.ts"
+ provides: "Icon and type scale tokens"
+ contains: "--yj-icon-sm"
+ - path: "frontend/src/components/cover-grid/cover-grid.ts"
+ provides: "Dynamic text sizing mapped to type scale tokens"
+ contains: "--yj-text-"
+ key_links:
+ - from: "all components"
+ to: "frontend/src/styles/tokens.css.ts"
+ via: "import { designTokens } and include in static styles"
+ pattern: "designTokens"
+---
+
+
+Systematically audit and fix visual inconsistencies across all components — convert em-based spacing to px, apply icon size tokens, apply type scale tokens, and ensure coherent visual language.
+
+Purpose: The codebase has evolved with ad-hoc values (0.9em icons in sidebar, 24px in now-playing, 14px in search-bar, 11-16px dynamic text in cover-grid). This pass replaces them with the design tokens defined in Plan 01, creating a single source of truth for sizing.
+Output: All components use consistent design tokens. Human-verified visual quality.
+
+
+
+@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
+@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/08-frontend-performance-ux/08-CONTEXT.md
+@.planning/phases/08-frontend-performance-ux/08-01-SUMMARY.md
+
+@frontend/src/styles/tokens.css.ts
+@frontend/src/components/sidebar/app-sidebar.ts
+@frontend/src/components/now-playing/now-playing.ts
+@frontend/src/components/search-bar/search-bar.ts
+@frontend/src/components/cover-grid/cover-grid.ts
+@frontend/src/components/cover-grid/cover-grid-styles.ts
+
+
+
+From frontend/src/styles/tokens.css.ts:
+```typescript
+export const designTokens = css`
+ :host {
+ --yj-icon-sm: 14px;
+ --yj-icon-md: 18px;
+ --yj-icon-lg: 24px;
+
+ --yj-text-xs: 11px;
+ --yj-text-sm: 12px;
+ --yj-text-md: 13px;
+ --yj-text-lg: 15px;
+ --yj-text-xl: 18px;
+ }
+`;
+```
+
+How to use in a component:
+```typescript
+import { designTokens } from '../../styles/tokens.css';
+
+@customElement('my-component')
+export class MyComponent extends LitElement {
+ static styles = [designTokens, css`
+ .icon { font-size: var(--yj-icon-md); }
+ .label { font-size: var(--yj-text-sm); }
+ `];
+}
+```
+
+Known inconsistencies to fix:
+- app-sidebar.ts: em-based spacing (padding: 1em, gap: 0.6em, padding: 0.5em), icon 0.9em/1.1em, border-radius: 5px
+- now-playing.ts: cover placeholder icon font-size: 24px → --yj-icon-lg
+- search-bar.ts: search icon font-size: 14px → --yj-icon-sm, input font-size: 13px → --yj-text-md
+- cover-grid.ts: dynamic text sizing tiers (11px/10px, 14px/12px, 16px/13px) in updateSizeProperties()
+- Various components: ad-hoc font-size values that should map to type scale
+
+
+
+
+
+
+ Task 1: Convert sidebar em-based spacing to px and apply icon/type tokens to sidebar, now-playing, search-bar, and audio-player components
+ frontend/src/components/sidebar/app-sidebar.ts, frontend/src/components/now-playing/now-playing.ts, frontend/src/components/search-bar/search-bar.ts, frontend/src/components/audio-player/controls/player-controls.ts, frontend/src/components/audio-player/seekbar/seek-bar.ts, frontend/src/components/audio-player/volume-control/volume-control.ts, frontend/src/components/audio-player/audio-player.ts
+
+ For EACH component listed, read the file first, then:
+ 1. Import designTokens: `import { designTokens } from '../../styles/tokens.css';` (adjust relative path based on file location)
+ 2. Add designTokens to the component's `static styles` array (prepend it so tokens are available to component styles)
+ 3. Apply the following conversions:
+
+ **app-sidebar.ts:**
+ - Convert ALL em-based values to px equivalents:
+ - `padding: 1em` → `padding: 16px`
+ - `gap: 0.6em` → `gap: 10px`
+ - `padding: 0.5em` → `padding: 8px`
+ - Any other em values → compute px (base is ~16px for desktop)
+ - Icon font-size `0.9em` → `var(--yj-icon-md)` (was ~14px, md=18px is closer to sidebar intent)
+ - Icon font-size `1.1em` (collapsed mode) → `var(--yj-icon-md)` (same token, consistent)
+ - Audit ALL font-size values and replace with appropriate --yj-text-* tokens
+ - `border-radius: 5px` → keep as-is (border-radius doesn't need tokenizing)
+
+ **now-playing.ts:**
+ - Cover placeholder icon `font-size: 24px` → `font-size: var(--yj-icon-lg)`
+ - Audit all font-size values → replace with --yj-text-* tokens
+
+ **search-bar.ts:**
+ - Search icon `font-size: 14px` → `font-size: var(--yj-icon-sm)`
+ - Input `font-size: 13px` → `font-size: var(--yj-text-md)`
+ - Audit all other font-size values
+
+ **audio-player components (player-controls.ts, seek-bar.ts, volume-control.ts, audio-player.ts):**
+ - Read each file, audit for ad-hoc font-size and icon-size values
+ - Replace with appropriate --yj-text-* and --yj-icon-* tokens
+ - Convert any em-based spacing to px if found
+
+ **General rules:**
+ - When mapping existing px values to tokens, pick the NEAREST token value. If 12px → --yj-text-sm (12px). If 13px → --yj-text-md (13px). If 14px and it's text → --yj-text-sm or --yj-text-md based on context. If 14px and it's an icon → --yj-icon-sm (14px).
+ - Do NOT change values that are layout-specific (width, height, margins for positioning). Only convert font-size, icon font-size, and em-based spacing.
+ - Do NOT change color values — those already use --yj- tokens.
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ Sidebar uses px-based spacing throughout. All icon sizes in sidebar, now-playing, search-bar, and audio-player use --yj-icon-* tokens. All text sizes in these components use --yj-text-* tokens. No em-based spacing remains. TypeScript compiles.
+
+
+
+ Task 2: Apply design tokens to cover-grid dynamic text sizing, track-list, queue-panel, and remaining detail/info components
+ frontend/src/components/cover-grid/cover-grid.ts, frontend/src/components/cover-grid/cover-grid-styles.ts, frontend/src/components/track-list/track-list.ts, frontend/src/components/queue-panel/queue-panel.ts, frontend/src/components/track-details/track-details.ts, frontend/src/components/track-info/track-info.ts, frontend/src/components/artist-details/artist-details.ts, frontend/src/components/genre-details/genre-details.ts
+
+ For EACH component, read the file, import designTokens, add to static styles, then audit and fix:
+
+ **cover-grid.ts — Dynamic text sizing:**
+ The updateSizeProperties() method has hardcoded px values for text sizing tiers based on card size:
+ - Small cards: 11px/10px → map to `--yj-text-xs` (11px) / computed smaller
+ - Medium cards: 14px/12px → map to `--yj-text-lg` (15px) / `--yj-text-sm` (12px) — or adjust
+ - Large cards: 16px/13px → map to values near `--yj-text-lg`/`--yj-text-md`
+
+ For the dynamic sizing tiers, the approach depends on how they're applied:
+ - If set as inline styles or CSS custom properties on the element, replace hardcoded values with references to the tokens: `var(--yj-text-xs)`, `var(--yj-text-sm)`, etc.
+ - If set programmatically in JS (this.style.setProperty), use the token values directly or set CSS custom properties that reference the tokens
+ - The goal is that card text sizes use the SAME scale as everything else, not independent magic numbers
+
+ Read the updateSizeProperties() method carefully to understand the tier logic before modifying.
+
+ **cover-grid-styles.ts:**
+ - Audit for ad-hoc font-size values, replace with --yj-text-* tokens
+
+ **track-list.ts:**
+ - Import designTokens (if not already from Plan 03)
+ - Audit ALL font-size values in styles — header, cells, sort labels, etc.
+ - Replace with --yj-text-* tokens
+ - Audit icon sizes (favorites icon was noted as 12px) → --yj-icon-sm
+
+ **queue-panel.ts:**
+ - Import designTokens (if not already from Plan 03)
+ - Audit font-size values → --yj-text-* tokens
+ - Audit icon sizes → --yj-icon-* tokens
+
+ **track-details.ts, track-info.ts, artist-details.ts, genre-details.ts:**
+ - Read each file, audit for font-size and icon-size values
+ - Import designTokens, add to static styles
+ - Replace ad-hoc values with tokens
+
+ **Same rules as Task 1:** Only convert font-size, icon sizes, em-based spacing. Don't change layout dimensions or colors.
+
+
+ cd frontend && npx tsc --noEmit 2>&1 | head -30
+
+ Cover-grid dynamic text tiers use type scale tokens. Track-list, queue-panel, and detail components use design tokens for all font-size and icon-size values. No meaningful ad-hoc font-size values remain across audited components. TypeScript compiles.
+
+
+
+ Task 3: Visual consistency verification
+ n/a
+
+ Human verifies visual consistency after Tasks 1-2.
+
+ What was built:
+ - Sidebar: px-based spacing, icon tokens, type tokens
+ - Now-playing: icon tokens, type tokens
+ - Search bar: icon and type tokens
+ - Audio player: icon and type tokens
+ - Cover grid: dynamic text sizing mapped to type scale
+ - Track list: type and icon tokens
+ - Queue panel: type and icon tokens
+ - Detail/info views: type and icon tokens
+
+ How to verify — run the app and check each view:
+ 1. Sidebar — Icons are consistent size, text is readable, spacing looks balanced (no too-tight or too-loose areas from em→px conversion)
+ 2. Track list — Column headers, cell text, and sort indicators look consistent. Favorites icon is appropriately sized.
+ 3. Cover grid — Album names scale with card size using the type scale tiers. Small, medium, and large cards all have readable text.
+ 4. Queue panel — Track names, durations, and icons are consistently sized
+ 5. Now playing — Cover placeholder icon is appropriately sized, track info text is consistent
+ 6. Search bar — Search icon and input text are balanced
+ 7. Audio player — Play/pause/skip icons, seek bar labels, volume icon are consistent
+ 8. Detail views — Artist details, genre details, track details/info all use consistent typography
+ 9. Overall — No view has text that looks noticeably different in size from the same-purpose text in another view
+
+ Human visual inspection — type "approved" or describe specific visual issues to fix
+ All views pass visual consistency check — no em-based spacing, icon sizes are consistent, typography follows the type scale, and no jarring size mismatches between views.
+
+
+
+
+
+1. `cd frontend && npx tsc --noEmit` compiles without errors
+2. `grep -r "0\.\d*em" frontend/src/components/sidebar/` returns no em-based spacing
+3. `grep -rn "font-size:" frontend/src/components/ | grep -v "var(--yj-"` shows minimal remaining ad-hoc values (only layout-specific sizes)
+4. All components that have styles import designTokens
+5. Human verification confirms visual consistency
+
+
+
+- Zero em-based spacing values in sidebar
+- All icon sizes use --yj-icon-sm/md/lg tokens
+- All text sizes use --yj-text-xs/sm/md/lg/xl tokens (with minimal justified exceptions)
+- Cover-grid dynamic text tiers map to the type scale
+- Human approves visual consistency across all views
+- TypeScript compiles without errors
+
+
+