# 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.