diff --git a/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md b/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md new file mode 100644 index 00000000..7b4d7660 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md @@ -0,0 +1,410 @@ +# M6a — Home page redesign + library list relocation + +> **Status:** Draft for review · 2026-04-30 +> +> **Phase:** UI polish (Fable #349 per `project_ui_quality.md` memory). First slice. Brings the existing scaffolding-quality SPA surfaces (root, library list) up to the FabledSword design-system bar that M5a/M5b/M5c established. Subsequent polish slices (search results, artist detail, album detail) get their own brainstorm/spec/plan cycles. + +## 1. Goal + +`/` becomes a personalized home page with sections in independent horizontal-scroll rows: **Recently added albums** (2 rows) · **Rediscover** (2 rows: albums + artists) · **Most played tracks** (3 rows) · **Last played artists** (1 row). Each item card has a play affordance (album → from track 1; artist → shuffled queue; track → queue starting from that track). The existing library-as-list moves to dedicated `/library/artists` and `/library/albums` wrapping-grid pages with alphabetical dividers. Pre-FabledSword styling on existing components (``, ``) is rebuilt at the design-system bar; ``'s click-target bug is retired with the component itself. + +## 2. Goals and non-goals + +### Goals + +- `/` is the new home page. Old library-list content moves to `/library/artists` and `/library/albums`. +- Home page sections (top to bottom): + 1. **Recently added albums** — 2 horizontal-scroll rows × 25 cards each (50 most-recent albums). + 2. **Rediscover** — 2 horizontal-scroll rows: row 1 = albums (squares), row 2 = artists (circles). 25 each. + 3. **Most played tracks** — 3 horizontal-scroll rows × 25 compact track cards each (75 top-ranked tracks). + 4. **Last played artists** — 1 horizontal-scroll row × 25 circular artist cards. +- All rows scroll **independently** (each row has its own scroll position). +- All rows in all sections standardize to **25 items** for consistent visual length. +- Each scroll row has **left/right arrow buttons** (Lucide `ChevronLeft`/`ChevronRight`) that page the scroll. Arrows disable at scroll boundaries. +- Item interactions: + - **Album card** click → `/albums/{id}`. Play-button overlay → fetches detail, plays from track 1. + - **Artist card** click → `/artists/{id}`. Play-button overlay → fetches all artist tracks, shuffles, plays. + - **Compact track card** click → plays the track in queue mode within the section's track list. +- Artist cards use a circular thumbnail derived from one of the artist's albums via SQL join (most-recent album with non-null `cover_art_path`). Falls back to Lucide `Disc3` glyph. +- `/library/artists` is a wrapping grid of `` (~140px wide) with alphabetical letter dividers between sort-name groups. +- `/library/albums` is a parallel wrapping grid of `` with alphabetical dividers by album title. +- `` polish-pass: replace pre-FabledSword styling, add play-button overlay alongside the existing add-to-queue button. +- `` retired (replaced by `` in `/library/artists`); the absolute-link click-target bug goes with it. +- Main nav updated: `Home · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings`. +- Per the operator's "no in-between steps" rule: the slice ships finished. No "v2 polish" placeholders left in the home-page surface. + +### Non-goals (this slice) + +- Section-header "See all →" links / per-section detail routes. The library/artists and library/albums pages serve that need for those entity types. Most-played-tracks-detail and rediscover-detail are not built; horizontal-scroll-with-arrows is the navigation. +- Music-service-style mood/genre/playlist sections. +- Time-window slicing on Most played (e.g., "this week / this month / all time" per row). All 3 rows show one ranked dataset. +- Polish for search results, artist detail, album detail pages — those are M6b/c (separate slices). +- Drag-to-reorder, user-customizable sections. +- Cover-art derivation refresh strategy. The SQL picks one representative album per artist at query time; if the chosen album is later removed, the next query naturally re-derives. + +## 3. Architecture + +### Backend + +**No migration required.** All sections derive from existing tables: +- `albums.created_at` and `tracks.added_at` from migration 0002. +- `play_events.{user_id, track_id, started_at, was_skipped}` from migration 0005. +- `general_likes_albums` and `general_likes_artists` from migration 0006. +- `albums.cover_art_path` already exists; the artist-cover derivation is a query-time `LEFT JOIN LATERAL`. + +**New queries** (in `internal/db/queries/recommendation.sql` for the home-page composites; `artists.sql` and `albums.sql` for the listing variants): + +- `ListRecentlyAddedAlbumsForUser(user_id, limit)` — albums sorted by `created_at DESC` with `artist_name` joined. Largely shadows the existing `ListAlbumsNewest`; the user_id parameter exists for future personalization (none in v1). +- `ListMostPlayedTracksForUser(user_id, limit)` — tracks ranked by `count(*)` over `play_events WHERE user_id=$1 AND was_skipped=false`, grouped by `track_id`, ordered DESC. Joined to `lidarr_quarantine NOT EXISTS` so quarantined tracks are filtered. +- `ListLastPlayedArtistsForUser(user_id, limit)` — artists ranked by `max(play_events.started_at) WHERE user_id=$1`, with derived `cover_art_path` via the representative-album lateral join. +- `ListRediscoverAlbumsForUser(user_id, limit)`: + - Eligibility: `general_likes_albums.liked_at < now() - interval '30 days'` AND no track from the album has `play_events.started_at > now() - interval '14 days'` for the user. + - Order: `(now() - max(play_events.started_at))` DESC — longest-since-last-play first. + - Fallback: when fewer than `limit` rows match, the query also returns randomly-sampled albums-from-likes to fill out (per (d) decision). +- `ListRediscoverArtistsForUser(user_id, limit)` — same shape via `general_likes_artists`. +- `ListArtistsAlphaWithCovers(limit, offset)` — replaces the existing `ListArtistsAlpha`. Sorts by `sort_name ASC`, joins to a representative album to expose `cover_art_path`. +- `ListAlbumsAlphaWithArtist(limit, offset)` — sorts by album title (or `sort_title`), joins `artists.name`. May already exist as `ListAlbumsAlphaByName` — verify before duplicating. + +**New Go service**: `internal/recommendation/home.go` — exports `HomeData(ctx, pool, userID) (*HomePayload, error)` that runs all section queries in parallel via goroutines and assembles a single composite. Mirrors the M5c `SuggestArtists` pattern. + +```go +type HomePayload struct { + RecentlyAddedAlbums []AlbumRefView + RediscoverAlbums []AlbumRefView + RediscoverArtists []ArtistRefView + MostPlayedTracks []TrackRefView + LastPlayedArtists []ArtistRefView +} +``` + +**New API handlers** (in `internal/api/home.go` and `internal/api/library_albums.go`): + +- `GET /api/home` — returns the composite payload. Authenticated; sub-100ms target at household scale. +- `GET /api/library/albums?sort=alpha&limit=&offset=` — paged album list mirroring the existing `/api/artists` pattern. +- `GET /api/artists/{id}/tracks` — all tracks for an artist across their albums. Honors quarantine. Used by the artist-card play affordance for client-side shuffle. + +**Quarantine compatibility**: most-played-tracks and the artist-tracks endpoint join `lidarr_quarantine NOT EXISTS WHERE user_id = $1`. Other home-page sections operate on albums/artists which aren't quarantined, so no special handling. + +### Frontend + +**New components** (in `web/src/lib/components/`): + +- `HorizontalScrollRow.svelte` — reusable scroll wrapper. Owns horizontal-scroll container, snap behavior, arrow buttons, edge-state disabling. Takes a snippet for items. +- `ArtistCard.svelte` — circular variant of the entity card. Includes play-button overlay. +- `CompactTrackCard.svelte` — small card for "Most played" rows. Card-click plays the track in section-queue mode. +- `AlphabeticalGrid.svelte` — wrapping grid wrapper for `/library/artists` and `/library/albums`, with letter dividers between sort-name groups. + +**Modified components**: + +- `AlbumCard.svelte` — full polish pass: FabledSword tokens replace pre-M5a styling, play-button overlay added (Lucide `Play` accent on hover, top-center over art square), card-click navigates to `/albums/{id}` cleanly (single-`` wrapper, no relative-positioning click-trap). +- `Shell.svelte` — `navItems` updated to `Home · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings`. + +**Retired components**: + +- `ArtistRow.svelte` — replaced by `` everywhere. The `absolute inset-0 ` click-target bug retires with it. +- `LibrarySkeleton.svelte` — was list-skeleton; replaced by a grid-skeleton variant for `/library/artists` and `/library/albums`. (May still be useful for other surfaces; check usage and decide whether to retire or extend.) + +**Routes**: + +- `web/src/routes/+page.svelte` — full rewrite. Composes ``, ``, ``, `` (or inlines the four sections if subdivision adds noise without value). +- `web/src/routes/library/artists/+page.svelte` — wrapping grid of `` via ``. Infinite-scroll pagination via TanStack `createInfiniteQuery`. +- `web/src/routes/library/albums/+page.svelte` — parallel page for albums. + +**Frontend API client** (in `web/src/lib/api/`): + +- `home.ts` — `listHome()` + `createHomeQuery()` factory (`staleTime: 60_000`). +- `albums.ts` (new or extend existing) — `listAlbumsAlpha(limit, offset)` + infinite-query factory. +- `artists.ts` — extend with `listArtistTracks(artistID)`. +- `types.ts` — add `HomePayload`, ensure `ArtistRef.cover_art_path?: string | null` is exposed. +- `queries.ts` — add `qk.home()`, `qk.albumsAlpha()`, `qk.artistTracks(id)`. + +### Routing summary + +``` +/ → Home (new) +/library/artists → Artist grid (new — replaces old "/" content) +/library/albums → Album grid (new) +/library/liked → existing +/library/hidden → existing (M5b) +/artists/{id} → existing +/albums/{id} → existing +``` + +## 4. Schema + +No migration. Schema is mature enough that all sections are expressible against existing tables. + +The artist-cover derivation is a `LEFT JOIN LATERAL` against `albums`: + +```sql +LEFT JOIN LATERAL ( + SELECT cover_art_path + FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC + LIMIT 1 +) cov ON true +``` + +The non-null filter ensures we pick a representative album that actually *has* art when one exists; falls through to NULL when no album in the discography has cover art set. + +## 5. API surface + +| Method | Path | Behavior | +|---|---|---| +| `GET` | `/api/home` | Returns the composite home payload. Section sizes capped server-side: 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists) / 75 (most_played) / 25 (last_played). SPA receives the full set per section and splits across visual rows. Response time target: sub-100ms. | +| `GET` | `/api/library/albums?sort=alpha&limit=&offset=` | Paged album list. Defaults: `sort=alpha`, `limit=50`, `offset=0`. Returns `{items: AlbumRef[], total: int, limit: int, offset: int}`. | +| `GET` | `/api/artists/{id}/tracks` | Returns all tracks for the artist across their albums, as `TrackRef[]`. Honors per-user quarantine. 404 if the artist doesn't exist. | + +### Response shapes + +**`GET /api/home`** body: + +```ts +type HomePayload = { + recently_added_albums: AlbumRef[]; // up to 50 + rediscover_albums: AlbumRef[]; // up to 25 + rediscover_artists: ArtistRef[]; // up to 25 + most_played_tracks: TrackRef[]; // up to 75 + last_played_artists: ArtistRef[]; // up to 25 +}; +``` + +**`AlbumRef`** (existing type, extended): +```ts +type AlbumRef = { + id: string; + title: string; + artist_id: string; + artist_name: string; + cover_art_path?: string | null; + // existing fields preserved + play_track_count: number; // new — number of tracks for the play affordance +}; +``` + +**`ArtistRef`** (existing type, extended): +```ts +type ArtistRef = { + id: string; + name: string; + // existing fields preserved + cover_art_path?: string | null; // new — derived from representative album +}; +``` + +`TrackRef` is the existing shape; no changes needed. + +### Errors + +- `500 server_error` on DB failure for any home-page section. `/api/home` returns the error rather than a partial payload (predictable empty-or-full UX for the SPA). +- `404 not_found` for `/api/artists/{id}/tracks` when the artist doesn't exist. +- Empty arrays are NOT errors — `200 {recently_added_albums: [], …}` is the new-user response. SPA renders empty-state copy per section. + +### Caching + +Frontend wraps `/api/home` in TanStack Query with `staleTime: 60_000` (1 minute). Repeat home-page visits within a session don't re-query. Like-state changes, new plays, freshly-added albums surface on next refetch (1 min after the last fetch, or on manual refetch / page navigation). + +## 6. UI surfaces + +All against FabledSword tokens. Sentence case throughout. + +### Home page (`/`) + +Vertical-scroll page with 4 sections stacked top-to-bottom: + +``` +┌───────────────────────────────────────────────────────┐ +│ Recently added │ +│ ◀ [album card] [album card] [album card] … ▶ │ row 1 (newest 25) +│ ◀ [album card] [album card] [album card] … ▶ │ row 2 (next 25) +│ │ +│ Rediscover │ +│ ◀ [album card] [album card] … ▶ │ albums (25) +│ ◀ [artist circle] [artist circle] … ▶ │ artists (25) +│ │ +│ Most played │ +│ ◀ [track card] [track card] … ▶ │ row 1 (top 25) +│ ◀ [track card] [track card] … ▶ │ row 2 (next 25) +│ ◀ [track card] [track card] … ▶ │ row 3 (next 25) +│ │ +│ Last played │ +│ ◀ [artist circle] [artist circle] … ▶ │ row 1 (last 25) +└───────────────────────────────────────────────────────┘ +``` + +**Section header**: H2 in Fraunces 24/500 (Parchment) + optional one-line Vellum subtitle (sentence-case voice rule). + +**Empty section state**: section header always renders; rows replaced by Vellum copy. Voice-rule examples: +- Recently added: "Nothing added yet. Scan a folder via the server's config." +- Rediscover: "No forgotten favourites yet. Like some albums or artists to fill this in." +- Most played: "No plays to draw from. Listen to something." +- Last played: "No recent plays." + +### `` component + +Reusable wrapper for any horizontal-scroll row. Props: `{ items, renderItem }` (snippet). + +- Container: `overflow-x: auto`, `scroll-snap-type: x mandatory`, `gap: 16px`. +- Items inside: `flex-shrink: 0`, `scroll-snap-align: start`, consistent width per card type. +- **Left arrow button**: 40px circular Iron-bg button, absolutely positioned at the row's left edge, vertically centered. Lucide `ChevronLeft` 16px / 1px stroke, Vellum default / Parchment hover. Disabled when `scrollLeft === 0`. +- **Right arrow button**: mirrored at the row's right edge with `ChevronRight`. Disabled when `scrollLeft + clientWidth >= scrollWidth`. +- Click on either arrow: `scrollBy({ left: ±containerWidth*0.85, behavior: 'smooth' })` — pages by ~one screenful. +- Native pointer-drag, trackpad swipe, and touch swipe all work via browser default on overflow. +- Arrows fade in on row hover; on touch devices (no hover), always visible. Detect via `@media (hover: hover)`. + +### `` (polish + play button) + +- Square aspect ratio, ~180-200px wide on desktop home page; ~140px in `/library/albums`. +- Title (Parchment 14/500) below the art; artist name (Vellum 12/400) below the title. +- **Play-button overlay** at the art's center on hover (or always-visible on touch). Lucide `Play` filled, 32px, accent forest-teal — qualifies as a brand moment per the design system. Click stops propagation, fetches album detail, calls `playQueue(detail.tracks, 0)`. Existing `+` add-to-queue affordance stays at top-right. +- Card click (anywhere not on the play / like / + buttons) → navigates to `/albums/{id}` via single `` wrapper. The inner action buttons sit at `z-10` above the link to claim their click events. +- Like button stays at top-right alongside the `+` button. + +### `` (new, circular) + +- Square aspect ratio art container with `border-radius: 9999px` (circle). Same width as AlbumCard variants. +- Image source: `cover_art_path` from the representative-album SQL join. When null, render a Slate-bg circle with Lucide `Disc3` 32/1.5px-stroke centered. +- Artist name centered below the circle (Parchment 14/500, single-line truncate). +- **Play-button overlay** at the circle's center on hover. Click → fetch `/api/artists/{id}/tracks`, shuffle client-side, call `playQueue(shuffled, 0)`. +- Card click → navigates to `/artists/{id}`. + +### `` (new) + +- Square cover art at top, ~140-160px wide. +- Title (Parchment 13/500, truncate) + artist name (Vellum 11/400, truncate) below. +- Card click → calls `playQueue(sectionTracks, indexInRow)` so playback starts at this track and continues through the section's track list. +- No separate play-overlay; the entire card is the play affordance. Smaller chrome footprint matches the section's "small rows" intent. + +### `` (new wrapping-grid wrapper) + +- Items wrap normally in a CSS grid (e.g., `grid-template-columns: repeat(auto-fill, minmax(140px, 1fr))`). +- Iterates the items in sort-name order. Whenever consecutive items differ in `sort_name[0]` (uppercase), inserts a row-spanning divider with the letter at left and a thin Pewter rule extending across the grid width. +- No empty-letter padding — if the library jumps from `A` to `D`, the grid jumps from `A` divider to `D` divider directly. +- Used by both `/library/artists` (with ``) and `/library/albums` (with ``). + +### `/library/artists` + +- H2 "Artists" + Vellum subtitle showing total count ("327 artists"). +- `` wrapping `` items at smaller width (~140px). +- Pagination via `createInfiniteQuery` against `/api/artists?sort=alpha&limit=&offset=`. The existing artist-list endpoint stays; the alphabetical-cover variant just supersedes the row-style rendering. +- Sort is fixed at A→Z by `sort_name`. The previous sort dropdown (alpha vs newest) is removed. + +### `/library/albums` + +- H2 "Albums" + Vellum subtitle ("1,204 albums"). +- Identical structure to `/library/artists` but with `` (squares) and dividers driven by `albums.sort_title[0]`. + +### `Shell.svelte` nav update + +```ts +const navItems = [ + { href: '/', label: 'Home' }, + { href: '/library/artists', label: 'Artists' }, + { href: '/library/albums', label: 'Albums' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/library/hidden', label: 'Hidden' }, + { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } +]; +``` + +Admin link injection (M5a) stays. + +### Voice rule + design tokens recap + +Section copy uses sentence case ("Recently added," "Rediscover," "Most played," "Last played"). Empty states use the understated-mythic register. All chrome uses FS tokens — no raw hex. Lucide icons at 16px/1px or 32px/1.5px stroke per design rule. Brand-moment accent (forest teal) reserved for the play affordance and active-nav state — not the card chrome itself. + +## 7. Error handling + +This slice is read-only; the error surface is small. + +- **`/api/home`**: 500 on any underlying DB failure. Each section query runs in a goroutine; the handler aggregates errors and 500s rather than returning a partial payload. +- **`/api/library/albums`** and **`/api/artists/{id}/tracks`**: 500 on DB error; 404 on artist-not-found for the tracks endpoint. +- Empty section data is NOT an error. SPA renders the section header with empty-state copy. +- **Quarantine effects**: if a user's most-played tracks are entirely quarantined, the section empties to its empty-state. Same UX as a user with no plays. +- **Stale data**: TanStack Query `staleTime: 60_000` keeps repeat home-page mounts cached. Fresh data on next refetch. +- **Play action errors**: card stays in normal state; toast surfaces "Couldn't load tracks. Try again." No optimistic playback. + +## 8. Testing + +### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`) + +`internal/recommendation/home_test.go`: + +- `TestHomeData_NewUser_ReturnsAllSectionsEmpty` +- `TestHomeData_RecentlyAdded_OrderedByCreatedAt` — seed 30 albums; verify top 50 in order. +- `TestHomeData_MostPlayed_RanksByPlayCount` — seed plays for 5 tracks with varying counts; verify ranking. +- `TestHomeData_MostPlayed_HonorsQuarantine` — quarantine a top-played track, verify it's filtered. +- `TestHomeData_LastPlayedArtists_OrderedByMaxStartedAt` +- `TestHomeData_LastPlayedArtists_DerivesCoverArtFromAlbum` +- `TestHomeData_RediscoverAlbums_AppliesEligibility` — recently-liked or recently-played albums excluded. +- `TestHomeData_RediscoverAlbums_FallbackWhenSparse` — fewer than `limit` eligibility matches → mixes in randomly-sampled likes. +- `TestHomeData_RediscoverArtists_*` — symmetric coverage. +- `TestListArtistsAlphaWithCovers` — alphabetical sort + cover-art derivation. + +### HTTP tests + +`internal/api/home_test.go`: +- Happy path returns the composite shape. +- Empty-user returns all-empty arrays. +- Quarantined tracks excluded from most-played. +- 500 on DB error. + +`internal/api/library_albums_test.go`: +- Paged response shape; sort=alpha returns alphabetical. + +`internal/api/artists_test.go` extension: +- `GetArtistTracks` happy path + quarantine filter + 404. + +### Frontend tests (vitest) + +- ``: arrow clicks fire `scrollBy`; left disabled at start; right disabled at end; renders items snippet. +- ``: play overlay click fetches album detail and calls `playQueue` with index 0; card click navigates to `/albums/{id}`. +- ``: play overlay click fetches `/api/artists/{id}/tracks`, shuffles, calls `playQueue` at 0; falls back to `Disc3` when `cover_art_path` is null. +- ``: card click calls `playQueue` with section tracks at the right index. +- ``: dividers render between items differing in `sort_name[0]`; skipped letters absent. +- `/+page.svelte` (home): all 4 section headers render; empty sections show copy; populated sections render `` with right components. +- `/library/artists/+page.svelte`: wrapping grid renders, alphabetical dividers correct, infinite-scroll pagination. +- `/library/albums/+page.svelte`: same. +- `Shell.test.ts`: nav order updated. + +### Coverage targets + +Combined `internal/recommendation/` + new `internal/api/home.go` + new `internal/api/library_albums.go` ≥ 80%. Frontend test coverage maintained at current level. + +## 9. Decisions ledger + +| # | Decision | Rationale | +|---|---|---| +| 1 | Home page replaces `/`; library list moves to `/library/artists` and `/library/albums` | Operator's framing — "the root of the app should be a page that shows sections" | +| 2 | Independent horizontal-scroll rows with arrow buttons | Operator's explicit pick — independent scroll over lockstep; arrows over swipe-only | +| 3 | All rows standardized to 25 items | Operator's "consistent length" ask; clean visual alignment | +| 4 | Most-played tracks render as compact cards (not text rows) | Operator's clarification — "tracks as little cards" in horizontal scroll | +| 5 | Artist cards use circular thumbnails derived from album covers | Operator's ask; SQL `LEFT JOIN LATERAL` handles derivation, no schema change needed | +| 6 | Album cards get a play-button overlay; artist cards too | Operator's ask — albums play from track 1, artists play shuffled | +| 7 | Rediscover semantics = (d) eligibility filter + sort by longest-since-last-play + fallback | Operator's pick during brainstorming | +| 8 | Albums + artists in Rediscover (separate rows in same section) | Operator's pick | +| 9 | `` retired; `` (circular) is the only artist visualization | Resolves the click-target bug as a side effect; one component to maintain | +| 10 | `/library/artists` and `/library/albums` use wrapping grid + alphabetical dividers | Operator's ask; matches the FS-design-system bar | +| 11 | `` polish-pass within this slice | Doing it now beats leaving it scaffolding-quality across surfaces it appears on | +| 12 | "No in-between steps" — anything that ships, ships finished | Operator's principle — half-built UI loses functionality across sessions | +| 13 | TanStack `staleTime: 60_000` on `/api/home` | Repeat home visits cached; fresh data on next refetch | + +## 10. Out of scope (this slice) + +- Section "See all →" detail routes (most-played-detail, rediscover-detail). The library/artists and library/albums pages serve that role for those entity types; track and rediscover details defer. +- Mood/genre/playlist sections. +- Per-row time-window splits on Most played. +- Polish for search results, artist detail, album detail pages — separate slices (M6b, M6c). +- Drag-to-reorder home sections, user-customizable sections. +- Cover-art refresh strategy when the chosen representative album is removed (next query naturally re-derives; sufficient for v1). + +## 11. Open questions + +- **Section subdivision into subcomponents?** The home page composes 4 sections; whether each becomes its own subcomponent (e.g., ``) or all four inline in `+page.svelte` is an implementation choice. Defer to plan-time. Likely subdivide if any section gets >50 lines. +- **Existing `LibrarySkeleton.svelte`** — used elsewhere or only on the old root? Verify during implementation. Either retire or extend with a grid variant for the new pages. +- **Cover-art lookup for fallback artists** — if no album in the discography has art set, do we ever fall through to MusicBrainz Cover Art Archive? Out of scope here; flagged as a Fable polish task.