Files
minstrel/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md
T
bvandeusen fd053e76ca 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>
2026-05-01 08:07:37 -04:00

411 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.