# Plan: Split `cover-grid.ts` + Extract Shared Context Menu Addresses refactoring catalog #4 (split `cover-grid.ts`, 3774 lines) and partially addresses #8 (49× `as any` casts on popups). ## Current State `frontend/src/components/cover-grid/cover-grid.ts` is the largest frontend file at 3774 lines. It contains a single `CoverGrid` LitElement that handles: - Virtualized album grid rendering (single + split mode with inline dropdown) - Album/track selection (custom inline logic with Ctrl/Shift/range) - Context menus (album + track, with playlist submenu) — **duplicated across 6 components** - Drag-and-drop source (albums + tracks) - Sort controls (toolbar + dropdown) - Ctrl+scroll zoom - Scroll position save/restore (index-based + pixel-based resize-aware) - Transition overlays (DOM snapshots during layout transitions) - Album filtering/sorting (memoized) - 319 lines of CSS The context menu logic is copy-pasted into 6 components: `cover-grid.ts`, `track-list.ts`, `playlist-view.ts`, `queue-panel.ts`, `genres-view.ts`, `artists-view.ts`. Each duplicates ~200 lines of state, open/close methods, submenu timers, document event listeners, and render templates. --- ## Guiding Principles 1. **Extract logic modules, not sub-components.** The grid is one visual component. Splitting it into multiple custom elements would create artificial boundaries and state-forwarding complexity. Instead, extract plain TS files (classes/functions) that the component imports. 2. **Follow existing patterns.** The codebase has `SelectionController` in `utils/`, `drag-controller.ts`, `drag-image.ts`. New extractions follow these conventions. 3. **Shared context menu is the highest-value extraction.** Duplicated across 6 components, it benefits the whole codebase. 4. **Don't over-split.** Lifecycle methods, render methods, and data loading are inherently tied to component state and stay in the main file. Some code density is fine for orchestration. --- ## Part 1: Types and Constants → `cover-grid-types.ts` **New file:** `frontend/src/components/cover-grid/cover-grid-types.ts` (~85 lines) **Move from `cover-grid.ts` lines 49-132:** - `ContextMenuTarget` discriminated union type - `GridEntry` interface - `SCROLL_DEBOUNCE_MS`, `ZOOM_STEP` constants - `SORT_FIELD_KEY`, `SORT_DIR_KEY` localStorage key constants - `AlbumSortField` type, `SortDirection` type - `AlbumSortOption` interface - `ALBUM_SORT_OPTIONS` array (3 sort options with comparator functions) **Rationale:** Pure data definitions with zero component dependency. Multiple files in the directory will import these (scroll-manager needs `SCROLL_DEBOUNCE_MS`, main file needs sort options, etc.). --- ## Part 2: CSS Styles → `cover-grid-styles.ts` **New file:** `frontend/src/components/cover-grid/cover-grid-styles.ts` (~270 lines) **Move from `cover-grid.ts` lines 343-661**, minus the context-menu styles (~47 lines at 615-661) which move to the shared context menu utility in Part 4. Export as a tagged template: ```typescript import { css } from 'lit'; export const coverGridStyles = css`...`; ``` Main file uses: ```typescript import { coverGridStyles } from './cover-grid-styles.js'; import { contextMenuStyles } from '@utils/context-menu-controller.js'; // ... static override styles = [coverGridStyles, contextMenuStyles]; ``` **Rationale:** Standard Lit pattern for large style blocks. Reduces visual noise. The style array composition pattern is idiomatic Lit. --- ## Part 3: Scroll Manager → `scroll-manager.ts` **New file:** `frontend/src/components/cover-grid/scroll-manager.ts` (~450 lines) **Move from `cover-grid.ts`:** - Scroll position persistence: `restoreScrollPosition()` (line 1580), `onVisibilityChanged` (line 1607) - Resize-aware scroll preservation: `setupResizeObserver()` (line 1668), `captureFocusPoint()` (line 1803) - Layout helpers: `getColumnCount()` (line 1865), `getContainerWidth()` (line 1887), `getGridRowWidth()` (line 1901), `getCaratOffset()` (line 1916), `computeSplitIndex()` (line 1949) - Transition overlay: `captureOverlay()` (line 2039), `removeOverlay()` (line 2090) - Scroll positioning: `awaitBeforeLayout()` (line 2116), `computeAdjustedScrollTop()` (line 2135), `restoreScrollTop()` (line 2197), `scrollToShowDropdown()` (line 2265) - Associated fields: `resizeObserver`, `resizeDebounceTimer`, `pendingFocus`, `currentColumnCount`, `isResizing`, `savedScrollTop`, `needsScrollRestore`, `showDropdownAfterRestore`, `scrollRestoreGeneration`, `scrollRestoreResolved`, `savedAlbumViewportOffset`, `transitionOverlay`, `scrollDebounceTimer` **Shape:** Plain class with a host interface (not a ReactiveController — scroll management is imperative/async, not reactive). ```typescript export interface ScrollManagerHost { readonly libraryCtrl: LibraryController; readonly cachedFilteredAlbums: library.Album[]; readonly expandedAlbumId: number | null; readonly expandedTracks: library.Track[]; readonly splitMode: boolean; readonly splitIndex: number; readonly cardWidth: number; readonly cardHeight: number; readonly cardTextHeight: number; shadowRoot: ShadowRoot | null; updateComplete: Promise; requestUpdate(): void; } export class ScrollManager { constructor(host: ScrollManagerHost, gridConstants: GridConstants); // Called from component lifecycle setup(): void; // from connectedCallback teardown(): void; // from disconnectedCallback // Scroll save/restore onVisibilityChanged(e: VisibilityChangedEvent): void; restoreScrollPosition(): void; // Resize handling setupResizeObserver(): void; // Split/single mode transitions captureOverlay(): void; removeOverlay(): void; computeAdjustedScrollTop(): number; async restoreScrollTop(target: number): Promise; async scrollToShowDropdown(): Promise; awaitBeforeLayout(): Promise; // Layout geometry getColumnCount(): number; getContainerWidth(): number; getGridRowWidth(): number; getCaratOffset(): number; computeSplitIndex(): number; // State exposed to component needsScrollRestore: boolean; showDropdownAfterRestore: boolean; savedScrollTop: number; savedAlbumViewportOffset: number | null; isResizing: boolean; splitIndex: number; } ``` **Rationale:** Scroll management is the largest concern (~800 raw lines, consolidated to ~450 without the grid constants that stay on the component). It's completely self-contained — reads component state but doesn't modify selection, context menus, or rendering. The host interface decouples it from the concrete class. A plain class (not ReactiveController) is honest about the imperative nature of scroll management. --- ## Part 4: Shared Context Menu Controller → `utils/context-menu-controller.ts` **New file:** `frontend/src/utils/context-menu-controller.ts` (~200 lines) This is the highest cross-cutting value extraction. The same context menu pattern is duplicated in 6 components. **Extract the common pattern from all 6 components:** ```typescript import type { ReactiveController, ReactiveControllerHost } from 'lit'; export interface ContextMenuHost extends ReactiveControllerHost { // Query accessors — each component provides its own popup element refs getContextMenuPopup(): HTMLElement | undefined; getPlaylistSubmenuPopup(): HTMLElement | undefined; updateComplete: Promise; shadowRoot: ShadowRoot | null; } export class ContextMenuController implements ReactiveController { // Reactive state (component reads these for rendering) contextMenuOpen = false; playlistSubmenuOpen = false; playlistFilePaths: string[] = []; constructor(host: ContextMenuHost); // Lifecycle — registers/removes document-level listeners hostConnected(): void; hostDisconnected(): void; // Actions openAt(clientX: number, clientY: number): void; close(): void; showPlaylistSubmenu(filePaths: string[]): Promise; closePlaylistSubmenu(): void; onPlaylistActionComplete(): void; } ``` **Also extract** shared context menu CSS styles as: ```typescript export const contextMenuStyles = css` #context-menu { ... } .context-menu-panel { ... } wa-dropdown-item { ... } .submenu-item { ... } .submenu-arrow { ... } #playlist-submenu { ... } `; ``` **What stays in each component:** - The `renderContextMenu()` method — menu items differ per component (cover-grid has conditional "Track Details", queue-panel has "Remove" instead of "Add to Queue", etc.) - The `onContextMenuAction(action)` handler — file path resolution differs per component - The `@query` decorators for popup elements (passed to controller via host interface) **Components to update (6):** 1. `cover-grid.ts` — Remove ~200 lines of inline context menu code 2. `track-list.ts` — Remove ~200 lines 3. `playlist-view.ts` — Remove ~200 lines (keep the second playlist-level context menu as-is or also migrate) 4. `queue-panel.ts` — Remove ~200 lines 5. `genres-view.ts` — Remove ~200 lines 6. `artists-view.ts` — Remove ~200 lines **Bonus:** All 49× `(popup as any).anchor = ...` and `(popup as any).active = ...` casts are now centralized in one file. This partially addresses catalog item #8 — adding proper typing to the controller's internals eliminates the `as any` from all 6 components. **Rationale:** ReactiveController is the right shape here (unlike ScrollManager) because it manages document-level event listeners tied to the component lifecycle via `hostConnected`/`hostDisconnected`. This matches the existing `SelectionController` pattern in `utils/`. --- ## Part 5: Album Selection Manager → `album-selection.ts` **New file:** `frontend/src/components/cover-grid/album-selection.ts` (~250 lines) **Move from `cover-grid.ts`:** - Album selection: `selectAlbumRange()` (line 2378), `getSelectedAlbumFilePaths()` (line 2398), `getContextMenuAlbumFilePaths()` (line 2422), `getAlbumFilePaths()` (line 2449) - Drag cache: `warmAlbumFilePathCache()` (line 2471), `getCachedSelectedAlbumFilePaths()` (line 2505), `albumFilePathCache` Map - Track selection: `selectTrackRange()` (line 2528), `getSelectedTrackFilePaths()` (line 2547) - Dropdown coupling: `closeDropdown()` (line 2561), `openDropdown()` (line 2575), `syncDropdownToSelection()` (line 2607) **Shape:** ```typescript export class AlbumSelectionManager { selectedAlbums = new Set(); selectedTracks = new Set(); expandedAlbumId: number | null = null; expandedTracks: library.Track[] = []; lastSelectedAlbumIndex: number | null = null; lastSelectedTrackIndex: number | null = null; private albumFilePathCache = new Map(); // Album selection selectAlbumRange(from: number, to: number, filteredAlbums: library.Album[]): Set; async getSelectedAlbumFilePaths(albums: library.Album[]): Promise; async getContextMenuAlbumFilePaths(contextMenuAlbumId: number | null, albums: library.Album[]): Promise; // Drag cache async warmCache(albums: library.Album[]): Promise; getCachedSelectedPaths(albums: library.Album[]): string[]; // Track selection selectTrackRange(from: number, to: number): Set; getSelectedTrackFilePaths(): string[]; // Dropdown async openDropdown(album: library.Album): Promise; closeDropdown(): void; syncDropdownToSelection(filteredAlbums: library.Album[]): void; // Reset clear(): void; } ``` **Why not use the existing `SelectionController`?** The existing controller: - Uses string keys only; album selection uses numeric IDs - Manages a single selection set; cover-grid has separate album and track selections - Has no concept of dropdown coupling (selecting 1 album → opens dropdown) - Has no file path caching for drag Retrofitting `SelectionController` to handle all of this would make it overly complex for its other consumers (`track-list.ts`, `playlist-view.ts`, `queue-panel.ts`). A dedicated manager for cover-grid's dual album/track model is cleaner. **Rationale:** Selection state + file path resolution is a coherent concern (~250 lines) that doesn't need access to the DOM, making it easy to extract. The main component's event handlers become thin wrappers that call into this manager. --- ## What Stays in `cover-grid.ts` After all extractions and improvements, the main file will be approximately **~1700 lines** (down from 3774): | Section | ~Lines | Why it stays | |---------|--------|-------------| | Imports and class declaration | 60 | Structural | | Properties, state, queries, controllers | 100 | Component-specific reactive state (fewer `@state` props) | | Grid layout creation + memoization | 80 | Tightly coupled to virtualizer | | Lifecycle (connectedCallback, disconnectedCallback, willUpdate, updated) | 350 | Orchestration — wires managers together (debug logs removed) | | Dynamic size properties + zoom | 70 | Simple, component-specific | | Data loading | 30 | Simple async fetch | | Virtualizer item builders | 40 | Depends on component state (memoized) | | Event handlers (album + track + drag) | 340 | Thin delegation to managers | | Render methods | 430 | Templates reference component state | | Sort toolbar logic | 120 | Small, self-contained | ~1700 lines is still substantial, but the *complexity* is dramatically reduced because the three hardest subsystems (scroll management, context menus, selection/file-path resolution) are encapsulated in dedicated modules. The remaining code is pure orchestration and rendering. --- ## What This Does NOT Do - **Does not split into multiple custom elements** — Artificial component boundaries would add event-forwarding complexity for no UX benefit. - **Does not refactor the split/single virtualizer architecture** — That's the core rendering strategy; changing it is a separate effort. - **Does not retrofit `SelectionController` for albums** — The existing controller serves different consumers with simpler needs. See Part 5 rationale. - **Does not touch `album-dropdown.ts`** — Already a well-scoped 410-line sub-component. - **Does not extract drag handlers** — ~165 lines of glue code that delegates to existing `drag-controller.ts`/`drag-image.ts`. Diminishing returns. --- ## Part 6: Code Quality and Performance Improvements These improvements are applied during the extraction steps that touch the relevant code. They don't change behavior — they make the same behavior more efficient and clean. ### 6a. Remove 13 `console.log` debug statements **Lines:** 1133, 1151, 1192, 1226, 1303, 1320, 1328, 1390, 1432, 1437, 2158, 2176, 2345 The scroll restoration and transition overlay code contains 13 `console.log` calls that are clearly development debugging artifacts (e.g., `[willUpdate] exit split (tracks empty)`, `[updated] scroll restore start`, `[adjustScroll]`, `[restoreScrollTop] attempt ${i}`). **Action:** Remove all 13 `console.log` calls. Keep the 3 `console.error` (actual failures) and 1 `console.warn` (retry exhaustion). **Applied during:** Part 3 (scroll-manager extraction) and Part 5 lifecycle cleanup. ### 6b. Memoize `buildGridEntries()` — eliminates 3-5 redundant array allocations per render **Problem:** `buildGridEntries()` allocates a new `GridEntry[]` array on every call. In split-mode rendering, it's called up to 5 times per render cycle: - `getBeforeEntries()` → `buildGridEntries().slice(0, splitIndex)` (line 2003) - `getAfterEntries()` called **twice** in `renderSplitGrid()` — once for `.length > 0` check (line 3612), once for `.items` (line 3616) — each rebuilding the full array - `onVisibilityChanged` scroll handler also rebuilds it (line 1637) There's even a placeholder comment on line 340: `// buildGridEntries() memoization cache.` — but no cache was ever implemented. **Action:** 1. Cache the `GridEntry[]` result, keyed on `cachedFilteredAlbums` reference identity. Invalidate in `recomputeAlbumCache()`. 2. In `renderSplitGrid()`, compute `const afterEntries = this.getAfterEntries()` once and reuse for both the length check and the `.items` binding. **Applied during:** Part 1 (types — `GridEntry` moves) and main file cleanup. ### 6c. Cache expanded album index — eliminates 6 redundant O(n) scans **Problem:** `cachedFilteredAlbums.findIndex((a) => a.ID === this.expandedAlbumId)` appears at 6 call sites (lines 1077, 1364, 1813, 1919, 1959, 2276). Each is a linear scan of the album array for the same ID. **Action:** Compute `expandedAlbumIndex` in `recomputeAlbumCache()` (or in `willUpdate` when `expandedAlbumId` changes). All 6 call sites become a direct property read. Invalidate when either `expandedAlbumId` or `cachedFilteredAlbums` changes. **Applied during:** Part 3 (scroll-manager extraction — 4 of the 6 sites are in scroll code) and main file cleanup. ### 6d. Build `albumById` Map for O(1) selection lookups **Problem:** `getSelectedAlbumFilePaths()` (line 2401) and `warmAlbumFilePathCache()` (line 2472) both call `this.albums.filter(a => selectedAlbums.has(a.ID))` to find selected albums — an O(n) scan of the full album list. `resolveTrackCoverArt()` (line 3181) does `this.albums.find(a => a.Name === albumName)` — an O(n) name-based scan that could also match the wrong album if names collide. **Action:** Build a `Map` (keyed by album ID) when `albums` changes. Selection lookups iterate `selectedAlbums` and do O(1) map lookups. `resolveTrackCoverArt()` uses the map with `expandedAlbumId` instead of name-based search. **Applied during:** Part 5 (album-selection extraction). ### 6e. Remove unnecessary `@state()` from 2 properties **Problem:** 15 properties have `@state()`. Two don't need it: - `playlistFilePaths` (line 695) — only rendered inside the playlist submenu, which is conditionally shown when `playlistSubmenuOpen` is true. Since `showPlaylistSubmenu()` sets `playlistFilePaths` before setting `playlistSubmenuOpen`, the reactive update from `playlistSubmenuOpen` will render with the correct paths. `playlistFilePaths` itself doesn't need to trigger a re-render. - `splitIndex` (line 737) — only used to compute `getBeforeEntries()`/`getAfterEntries()`. It's always set before `splitMode` changes (which triggers the render), so it doesn't need independent reactivity. **Action:** Remove `@state()` decorator from both. Make them plain private fields. **Applied during:** Main file cleanup after extractions. ### 6f. Single-pass `onGridClick` path traversal **Problem:** `onGridClick` (line 3071) calls `composedPath()` once, then iterates it twice with `.some()` — once for `.album-card` and once for `.album-dropdown`. **Action:** Single loop checking both classes: ```typescript for (const el of e.composedPath()) { if (!(el instanceof HTMLElement)) continue; if (el.classList.contains('album-card') || el.classList.contains('album-dropdown')) return; } ``` **Applied during:** Main file cleanup. ### 6g. Use expanded album directly for cover art resolution **Problem:** `resolveTrackCoverArt(albumName)` (line 3176) does an O(n) `.find()` on `this.albums` by `Name` to get cover art URLs. But we already know which album is expanded (`expandedAlbumId`), and all tracks in the dropdown belong to that album. Name-based lookup has a theoretical collision risk if two albums share the same name. **Action:** Replace the name-based search with a direct lookup using `expandedAlbumId` and the `albumById` map from improvement 6d. Falls back gracefully if the album isn't found. **Applied during:** Part 5 (album-selection extraction) or main file cleanup. ### 6h. Prune `albumFilePathCache` to prevent unbounded growth **Problem:** The `albumFilePathCache` (Map) is warmed when albums are selected and read during dragstart, but entries are never removed. Over a session, it grows without bound. **Action:** 1. Clear the entire cache when `albums` changes (library rescan). 2. After `warmAlbumFilePathCache()` completes, remove entries whose album ID is no longer in `selectedAlbums`. **Applied during:** Part 5 (album-selection extraction — the cache moves to `AlbumSelectionManager`). --- ## Execution Order | Step | File(s) | Risk | Notes | |------|---------|------|-------| | 1 | `cover-grid-types.ts` | Minimal | Pure move, no logic changes | | 2 | `cover-grid-styles.ts` | Minimal | Pure move, verify `static styles` array works | | 3 | `utils/context-menu-controller.ts` | Medium | Widest blast radius — update 6 components | | 4 | `album-selection.ts` + improvements 6d, 6g, 6h | Low | Contained to cover-grid | | 5 | `scroll-manager.ts` + improvements 6a, 6c | Medium | Largest extraction, deep state interaction | | 6 | Main file cleanup: improvements 6b, 6e, 6f | Low | After extractions, clean up remaining code | | 7 | Verify: `pnpm build` + `pnpm exec tsc --noEmit` | — | Ensure no type errors or build failures | Steps 1-2 are safe warmups. Step 3 has the highest cross-cutting value. Steps 4-5 are the structural wins for cover-grid itself. Step 6 is polish. Each step should be independently verifiable with `tsc --noEmit`.