Captures the brainstorm for the first UI polish slice (Fable #349): new `/` home page with 4 sections (Recently added / Rediscover / Most played / Last played) as independent horizontal-scroll rows with arrow buttons, library list relocated to `/library/artists` and `/library/albums` as wrapping grids with alphabetical dividers, ArtistCard (circular) replacing ArtistRow (retires the click-target bug), AlbumCard polish pass with play-button overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
M6a — Home page redesign + library list relocation
Status: Draft for review · 2026-04-30
Phase: UI polish (Fable #349 per
project_ui_quality.mdmemory). 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 (<AlbumCard>, <ArtistRow>) is rebuilt at the design-system bar; <ArtistRow>'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/artistsand/library/albums.- Home page sections (top to bottom):
- Recently added albums — 2 horizontal-scroll rows × 25 cards each (50 most-recent albums).
- Rediscover — 2 horizontal-scroll rows: row 1 = albums (squares), row 2 = artists (circles). 25 each.
- Most played tracks — 3 horizontal-scroll rows × 25 compact track cards each (75 top-ranked tracks).
- 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.
- Album card click →
- 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 LucideDisc3glyph. /library/artistsis a wrapping grid of<ArtistCard>(~140px wide) with alphabetical letter dividers between sort-name groups./library/albumsis a parallel wrapping grid of<AlbumCard>with alphabetical dividers by album title.<AlbumCard>polish-pass: replace pre-FabledSword styling, add play-button overlay alongside the existing add-to-queue button.<ArtistRow>retired (replaced by<ArtistCard>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_atandtracks.added_atfrom migration 0002.play_events.{user_id, track_id, started_at, was_skipped}from migration 0005.general_likes_albumsandgeneral_likes_artistsfrom migration 0006.albums.cover_art_pathalready exists; the artist-cover derivation is a query-timeLEFT 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 bycreated_at DESCwithartist_namejoined. Largely shadows the existingListAlbumsNewest; the user_id parameter exists for future personalization (none in v1).ListMostPlayedTracksForUser(user_id, limit)— tracks ranked bycount(*)overplay_events WHERE user_id=$1 AND was_skipped=false, grouped bytrack_id, ordered DESC. Joined tolidarr_quarantine NOT EXISTSso quarantined tracks are filtered.ListLastPlayedArtistsForUser(user_id, limit)— artists ranked bymax(play_events.started_at) WHERE user_id=$1, with derivedcover_art_pathvia 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 hasplay_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
limitrows match, the query also returns randomly-sampled albums-from-likes to fill out (per (d) decision).
- Eligibility:
ListRediscoverArtistsForUser(user_id, limit)— same shape viageneral_likes_artists.ListArtistsAlphaWithCovers(limit, offset)— replaces the existingListArtistsAlpha. Sorts bysort_name ASC, joins to a representative album to exposecover_art_path.ListAlbumsAlphaWithArtist(limit, offset)— sorts by album title (orsort_title), joinsartists.name. May already exist asListAlbumsAlphaByName— 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.
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/artistspattern.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/artistsand/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 (LucidePlayaccent on hover, top-center over art square), card-click navigates to/albums/{id}cleanly (single-<a>wrapper, no relative-positioning click-trap).Shell.svelte—navItemsupdated toHome · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings.
Retired components:
ArtistRow.svelte— replaced by<ArtistCard>everywhere. Theabsolute inset-0 <a>click-target bug retires with it.LibrarySkeleton.svelte— was list-skeleton; replaced by a grid-skeleton variant for/library/artistsand/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<RecentlyAddedSection>,<RediscoverSection>,<MostPlayedSection>,<LastPlayedSection>(or inlines the four sections if subdivision adds noise without value).web/src/routes/library/artists/+page.svelte— wrapping grid of<ArtistCard>via<AlphabeticalGrid>. Infinite-scroll pagination via TanStackcreateInfiniteQuery.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 withlistArtistTracks(artistID).types.ts— addHomePayload, ensureArtistRef.cover_art_path?: string | nullis exposed.queries.ts— addqk.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:
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:
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):
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):
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_erroron DB failure for any home-page section./api/homereturns the error rather than a partial payload (predictable empty-or-full UX for the SPA).404 not_foundfor/api/artists/{id}/trackswhen 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."
<HorizontalScrollRow> 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
ChevronLeft16px / 1px stroke, Vellum default / Parchment hover. Disabled whenscrollLeft === 0. - Right arrow button: mirrored at the row's right edge with
ChevronRight. Disabled whenscrollLeft + 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).
<AlbumCard> (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
Playfilled, 32px, accent forest-teal — qualifies as a brand moment per the design system. Click stops propagation, fetches album detail, callsplayQueue(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<a>wrapper. The inner action buttons sit atz-10above the link to claim their click events. - Like button stays at top-right alongside the
+button.
<ArtistCard> (new, circular)
- Square aspect ratio art container with
border-radius: 9999px(circle). Same width as AlbumCard variants. - Image source:
cover_art_pathfrom the representative-album SQL join. When null, render a Slate-bg circle with LucideDisc332/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, callplayQueue(shuffled, 0). - Card click → navigates to
/artists/{id}.
<CompactTrackCard> (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.
<AlphabeticalGrid> (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
AtoD, the grid jumps fromAdivider toDdivider directly. - Used by both
/library/artists(with<ArtistCard>) and/library/albums(with<AlbumCard>).
/library/artists
- H2 "Artists" + Vellum subtitle showing total count ("327 artists").
<AlphabeticalGrid>wrapping<ArtistCard>items at smaller width (~140px).- Pagination via
createInfiniteQueryagainst/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/artistsbut with<AlbumCard>(squares) and dividers driven byalbums.sort_title[0].
Shell.svelte nav update
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/albumsand/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_000keeps 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_ReturnsAllSectionsEmptyTestHomeData_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_OrderedByMaxStartedAtTestHomeData_LastPlayedArtists_DerivesCoverArtFromAlbumTestHomeData_RediscoverAlbums_AppliesEligibility— recently-liked or recently-played albums excluded.TestHomeData_RediscoverAlbums_FallbackWhenSparse— fewer thanlimiteligibility 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:
GetArtistTrackshappy path + quarantine filter + 404.
Frontend tests (vitest)
<HorizontalScrollRow>: arrow clicks firescrollBy; left disabled at start; right disabled at end; renders items snippet.<AlbumCard>: play overlay click fetches album detail and callsplayQueuewith index 0; card click navigates to/albums/{id}.<ArtistCard>: play overlay click fetches/api/artists/{id}/tracks, shuffles, callsplayQueueat 0; falls back toDisc3whencover_art_pathis null.<CompactTrackCard>: card click callsplayQueuewith section tracks at the right index.<AlphabeticalGrid>: dividers render between items differing insort_name[0]; skipped letters absent./+page.svelte(home): all 4 section headers render; empty sections show copy; populated sections render<HorizontalScrollRow>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 | <ArtistRow> retired; <ArtistCard> (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 | <AlbumCard> 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.,
<RecentlyAddedSection>) or all four inline in+page.svelteis 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.