docs: add M6a home page redesign + library relocation spec

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>
This commit is contained in:
2026-05-01 08:07:37 -04:00
parent 2f3326aee6
commit fd053e76ca
@@ -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 (`<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/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 `<ArtistCard>` (~140px wide) with alphabetical letter dividers between sort-name groups.
- `/library/albums` is 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_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-`<a>` 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 `<ArtistCard>` everywhere. The `absolute inset-0 <a>` 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 `<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 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."
### `<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 `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)`.
### `<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 `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 `<a>` 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.
### `<ArtistCard>` (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}`.
### `<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 `A` to `D`, the grid jumps from `A` divider to `D` divider 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 `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 `<AlbumCard>` (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)
- `<HorizontalScrollRow>`: arrow clicks fire `scrollBy`; left disabled at start; right disabled at end; renders items snippet.
- `<AlbumCard>`: play overlay click fetches album detail and calls `playQueue` with index 0; card click navigates to `/albums/{id}`.
- `<ArtistCard>`: play overlay click fetches `/api/artists/{id}/tracks`, shuffles, calls `playQueue` at 0; falls back to `Disc3` when `cover_art_path` is null.
- `<CompactTrackCard>`: card click calls `playQueue` with section tracks at the right index.
- `<AlphabeticalGrid>`: 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 `<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.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.