# UI Overhaul — Implementation Plan **Date:** 2026-06-08 **Phases:** 1–4 per `docs/ui-rework.md` **Stack:** React 18 · TanStack Router · Zustand · react-query · Tailwind CSS 3 · lucide-react --- ## File map | Action | Path | |--------|------| | **Modify** | `frontend/tailwind.config.js` | | **Modify** | `frontend/src/index.css` | | **Modify** | `frontend/src/lib/theme.ts` | | **Modify** | `frontend/src/router.tsx` | | **Modify** | `frontend/src/types.ts` | | **Modify** | `frontend/src/pages/Home.tsx` | | **Modify** | `frontend/src/pages/Tracks.tsx` | | **Modify** | `frontend/src/pages/Artists.tsx` | | **Modify** | `frontend/src/pages/ArtistDetail.tsx` | | **Modify** | `frontend/src/pages/Albums.tsx` | | **Modify** | `frontend/src/pages/AlbumDetail.tsx` | | **Modify** | `frontend/src/pages/Genres.tsx` | | **Modify** | `frontend/src/pages/Discover.tsx` | | **Modify** | `frontend/src/pages/Vibe.tsx` | | **Modify** | `frontend/src/pages/Search.tsx` | | **Modify** | `frontend/src/pages/Quarantine.tsx` | | **Modify** | `frontend/src/pages/Settings.tsx` | | **Create** | `frontend/src/components/AppShell.tsx` | | **Create** | `frontend/src/components/NavRail.tsx` | | **Create** | `frontend/src/components/TopBar.tsx` | | **Create** | `frontend/src/components/PlaybackBar.tsx` | | **Create** | `frontend/src/components/NowPlayingPanel.tsx` | | **Create** | `frontend/src/components/Artwork.tsx` | | **Create** | `frontend/src/components/MediaCard.tsx` | | **Create** | `frontend/src/components/ShelfRow.tsx` | | **Create** | `frontend/src/components/TrackRow.tsx` | | **Create** | `frontend/src/services/quarantineService.ts` | | **Delete** | `frontend/src/components/Layout.tsx` | | **Delete** | `frontend/src/components/NowPlayingBar.tsx` | | **Delete** | `frontend/src/pages/LibraryTrackRow.tsx` | --- ## Task 1 — Expand design tokens **Goal:** Add 8 new CSS vars, wire every token into `tailwind.config.js` as semantic color keys, update all 4 existing theme presets + add a Default(Purple) preset. **Files:** `frontend/tailwind.config.js`, `frontend/src/index.css`, `frontend/src/lib/theme.ts` **Steps:** 1. Replace `frontend/tailwind.config.js`: ```js /** @type {import('tailwindcss').Config} */ export default { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], theme: { extend: { colors: { background: 'var(--bg)', elevated: 'var(--bg-elevated)', surface: 'var(--surface)', 'surface-h':'var(--surface-hover)', line: 'var(--border)', primary: 'var(--text)', muted: 'var(--text-muted)', accent: 'var(--accent)', 'accent-h': 'var(--accent-hover)', 'on-accent':'var(--on-accent)', 'grad-a': 'var(--card-grad-a)', 'grad-b': 'var(--card-grad-b)', }, }, }, plugins: [], }; ``` 2. Replace the `:root` block in `frontend/src/index.css`: ```css @tailwind base; @tailwind components; @tailwind utilities; :root { --bg: #000000; --bg-elevated: #111113; --surface: #18181b; --surface-hover: #27272a; --border: #3f3f46; --text: #ffffff; --text-muted: #a1a1aa; --accent: #3b82f6; --accent-hover: #2563eb; --on-accent: #ffffff; --card-grad-a: #1e293b; --card-grad-b: #0f172a; } body { margin: 0; padding: 0; background-color: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; } ``` 3. Replace `frontend/src/lib/theme.ts` — expand every preset's `vars` to include all 12 tokens; add a "Default (Purple)" preset: ```ts export interface ThemePreset { id: string; name: string; vars: Record; swatch: string; } export const THEMES: ThemePreset[] = [ { id: 'purple', name: 'Default (Purple)', swatch: '#1e1b4b', vars: { '--bg': '#0d0b1a', '--bg-elevated': '#13102a', '--surface': '#1e1b4b', '--surface-hover': '#2d2a5e', '--border': '#4c1d95', '--text': '#ede9fe', '--text-muted': '#a78bfa', '--accent': '#7c3aed', '--accent-hover': '#6d28d9', '--on-accent': '#ffffff', '--card-grad-a': '#1e1b4b', '--card-grad-b': '#0d0b1a', }, }, { id: 'dark', name: 'Dark', swatch: '#18181b', vars: { '--bg': '#000000', '--bg-elevated': '#111113', '--surface': '#18181b', '--surface-hover': '#27272a', '--border': '#3f3f46', '--text': '#ffffff', '--text-muted': '#a1a1aa', '--accent': '#3b82f6', '--accent-hover': '#2563eb', '--on-accent': '#ffffff', '--card-grad-a': '#1e293b', '--card-grad-b': '#0f172a', }, }, { id: 'midnight', name: 'Midnight', swatch: '#0f172a', vars: { '--bg': '#020617', '--bg-elevated': '#0a1120', '--surface': '#0f172a', '--surface-hover': '#1e293b', '--border': '#334155', '--text': '#e2e8f0', '--text-muted': '#94a3b8', '--accent': '#6366f1', '--accent-hover': '#4f46e5', '--on-accent': '#ffffff', '--card-grad-a': '#1e1b4b', '--card-grad-b': '#020617', }, }, { id: 'forest', name: 'Forest', swatch: '#0c1f17', vars: { '--bg': '#03120c', '--bg-elevated': '#071a10', '--surface': '#0c1f17', '--surface-hover': '#163024', '--border': '#1f4a33', '--text': '#e7f5ee', '--text-muted': '#86efac', '--accent': '#10b981', '--accent-hover': '#059669', '--on-accent': '#ffffff', '--card-grad-a': '#0c1f17', '--card-grad-b': '#03120c', }, }, { id: 'plum', name: 'Plum', swatch: '#1e1029', vars: { '--bg': '#100619', '--bg-elevated': '#180924', '--surface': '#1e1029', '--surface-hover': '#2d1a3d', '--border': '#5b2d7a', '--text': '#f3e8ff', '--text-muted': '#d8b4fe', '--accent': '#a855f7', '--accent-hover': '#9333ea', '--on-accent': '#ffffff', '--card-grad-a': '#1e1029', '--card-grad-b': '#100619', }, }, ]; export const DEFAULT_THEME_ID = 'purple'; export const STORAGE_KEYS = { theme: 'muzick.settings.theme', volume: 'muzick.settings.volume', } as const; export function applyTheme(theme: ThemePreset): void { const root = document.documentElement; for (const [key, value] of Object.entries(theme.vars)) { root.style.setProperty(key, value); } } export function readStoredThemeId(): string { try { const stored = localStorage.getItem(STORAGE_KEYS.theme); if (stored && THEMES.some((t) => t.id === stored)) return stored; } catch { /* unavailable */ } return DEFAULT_THEME_ID; } export function initTheme(): void { const id = readStoredThemeId(); const theme = THEMES.find((t) => t.id === id) ?? THEMES[0]; applyTheme(theme); } export function readStoredVolume(fallback: number): number { try { const stored = localStorage.getItem(STORAGE_KEYS.volume); if (stored !== null) { const parsed = Number(stored); if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) return parsed; } } catch { /* ignore */ } return fallback; } ``` **Acceptance criteria:** `npm run typecheck` in `frontend/` passes; no TS errors in theme.ts. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 2 — Artwork component **Goal:** A reusable `` that renders a deterministic gradient placeholder derived from a seed string (title/artist), with an optional `src` URL override. **Files:** `frontend/src/components/Artwork.tsx` (create) **Steps:** 1. Create `frontend/src/components/Artwork.tsx`: ```tsx interface ArtworkProps { seed: string; src?: string | null; className?: string; rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; } function hueFromString(s: string): number { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; } export function Artwork({ seed, src, className = '', rounded = 'md' }: ArtworkProps) { const hue = hueFromString(seed); const gradient = `linear-gradient(135deg, hsl(${hue},45%,22%), hsl(${(hue + 60) % 360},35%,12%))`; const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded]; if (src) { return {seed}; } return
; } ``` **Acceptance criteria:** Component renders with a gradient when no `src` given; renders an `` when `src` is provided. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 3 — TrackRow component **Goal:** A single reusable `` that replaces `LibraryTrackRow`, uses semantic token classes, and exposes an optional `onDislike` callback (so callers handle invalidation). **Files:** `frontend/src/components/TrackRow.tsx` (create) **Steps:** 1. Create `frontend/src/components/TrackRow.tsx`: ```tsx import { Play, Pause, Heart, ThumbsDown, Music } from 'lucide-react'; import type { Track } from '../types'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { favoritesService } from '../services/favoritesService'; import { Artwork } from './Artwork'; export function formatDuration(seconds?: number | null): string { if (!seconds || seconds < 0 || !Number.isFinite(seconds)) return '0:00'; const total = Math.floor(seconds); return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, '0')}`; } interface TrackRowProps { track: Track; queue: Track[]; index: number; showActions?: boolean; trackNumber?: number; onDislike?: (trackId: string) => void; } export function TrackRow({ track, queue, index, showActions = true, trackNumber, onDislike }: TrackRowProps) { const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore(); const isCurrent = currentTrack?.id === track.id; const handlePlay = () => { if (isCurrent) { isPlaying ? pause() : play(); return; } setQueue(queue.slice(index)); playTrack(track); }; const handleFavorite = (e: React.MouseEvent) => { e.stopPropagation(); void favoritesService.add(track.id).catch(() => undefined); }; const handleDislike = (e: React.MouseEvent) => { e.stopPropagation(); void favoritesService.dislike(track.id).catch(() => undefined); onDislike?.(track.id); }; return (
{trackNumber !== undefined ? ( {trackNumber} ) : ( )} {isCurrent && isPlaying ? ( ) : ( )}
{track.title || 'Untitled'}
{track.artist || 'Unknown artist'}
{showActions && (
)}
{formatDuration(track.duration)}
); } ``` **Acceptance criteria:** Renders with semantic classes; `isCurrent` highlights with accent; `trackNumber` or icon shown; actions hidden until hover. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 4 — PlaybackBar **Goal:** Full-width bottom transport bar (replaces `NowPlayingBar`): artwork thumbnail + title/artist on left, controls + scrubber in center, volume + panel-toggle on right. **Files:** `frontend/src/components/PlaybackBar.tsx` (create) **Steps:** 1. Create `frontend/src/components/PlaybackBar.tsx`: ```tsx import { Play, Pause, SkipBack, SkipForward, Volume2, ListMusic } from 'lucide-react'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { Artwork } from './Artwork'; import { formatDuration } from './TrackRow'; interface PlaybackBarProps { panelOpen: boolean; onTogglePanel: () => void; } export function PlaybackBar({ panelOpen, onTogglePanel }: PlaybackBarProps) { const { currentTrack, isPlaying, position, duration, volume, play, pause, next, prev, setPosition, setVolume } = usePlaybackStore(); return (
{/* Track info */}
{currentTrack ? ( <>
{currentTrack.title}
{currentTrack.artist}
) : (
Nothing playing
)}
{/* Controls + scrubber */}
{formatDuration(position)} setPosition(Number(e.target.value))} disabled={!currentTrack || duration <= 0} className="flex-1 h-1 cursor-pointer accent-[var(--accent)]" aria-label="Seek" /> {formatDuration(duration)}
{/* Volume + panel toggle */}
setVolume(Number(e.target.value))} className="w-20 h-1 cursor-pointer accent-[var(--accent)]" aria-label="Volume" />
); } ``` **Acceptance criteria:** Play/pause button is a circle with accent fill; scrubber spans center; volume + panel toggle on right; disabled state when no track. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 5 — NowPlayingPanel **Goal:** Collapsible right panel — large artwork, track info, scrubber, transport, Up Next queue list. **Files:** `frontend/src/components/NowPlayingPanel.tsx` (create) **Steps:** 1. Create `frontend/src/components/NowPlayingPanel.tsx`: ```tsx import { X, Play, Pause, SkipBack, SkipForward, Music } from 'lucide-react'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { Artwork } from './Artwork'; import { formatDuration } from './TrackRow'; interface NowPlayingPanelProps { onClose: () => void; } export function NowPlayingPanel({ onClose }: NowPlayingPanelProps) { const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition, playTrack, setQueue } = usePlaybackStore(); const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue; return ( ); } ``` **Acceptance criteria:** Panel shows artwork, scrubber, transport, and Up Next list; `onClose` hides it. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 6 — NavRail **Goal:** Persistent left navigation column with two groups (Library, Personal), active state via accent, links to all routes. **Files:** `frontend/src/components/NavRail.tsx` (create) **Steps:** 1. Create `frontend/src/components/NavRail.tsx`: ```tsx import { Link } from '@tanstack/react-router'; import { Home, Music, Disc3, Users, Tags, Zap, Compass, ShieldAlert, Settings, } from 'lucide-react'; const NAV_GROUPS = [ { label: 'Library', items: [ { to: '/', icon: Home, label: 'Home' }, { to: '/tracks', icon: Music, label: 'Songs' }, { to: '/albums', icon: Disc3, label: 'Albums' }, { to: '/artists', icon: Users, label: 'Artists' }, { to: '/genres', icon: Tags, label: 'Genres' }, { to: '/vibe', icon: Zap, label: 'Vibe' }, { to: '/discover', icon: Compass, label: 'Discover' }, ], }, { label: 'Personal', items: [ { to: '/quarantine', icon: ShieldAlert, label: 'Quarantine' }, { to: '/settings', icon: Settings, label: 'Settings' }, ], }, ] as const; const base = 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors w-full'; const inactive = 'text-muted hover:bg-surface-h hover:text-primary'; const active = 'bg-accent/15 text-accent font-medium'; export function NavRail() { return ( ); } ``` **Acceptance criteria:** Active route link has accent background; two labelled groups; logo at top. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 7 — TopBar **Goal:** Narrow top bar with logo gap (the NavRail handles branding) and a global search input that navigates to `/search?q=` on submit. **Files:** `frontend/src/components/TopBar.tsx` (create) **Steps:** 1. Create `frontend/src/components/TopBar.tsx`: ```tsx import { useState } from 'react'; import { Search } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; export function TopBar() { const [q, setQ] = useState(''); const navigate = useNavigate(); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (q.trim()) void navigate({ to: '/search', search: { q: q.trim() } as any }); }; return (
{/* aligns with NavRail width */}
setQ(e.target.value)} placeholder="Search music… (Enter)" className="w-full bg-surface border border-line rounded-lg pl-9 pr-4 py-1.5 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors" />
); } ``` **Acceptance criteria:** Submitting the form navigates to `/search` with a `q` param; input styled with surface/border tokens. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 8 — AppShell + router update **Goal:** Replace `Layout` with `AppShell` (3-pane grid), wire `NowPlayingPanel` (starts collapsed), `PlaybackBar`, `NavRail`, `TopBar`, and `AudioEngine`. Update `router.tsx` to use `AppShell`. **Files:** `frontend/src/components/AppShell.tsx` (create), `frontend/src/router.tsx` (modify) **Steps:** 1. Create `frontend/src/components/AppShell.tsx`: ```tsx import { useState } from 'react'; import { Outlet } from '@tanstack/react-router'; import { AudioEngine } from './AudioEngine'; import { NavRail } from './NavRail'; import { TopBar } from './TopBar'; import { PlaybackBar } from './PlaybackBar'; import { NowPlayingPanel } from './NowPlayingPanel'; export default function AppShell() { const [panelOpen, setPanelOpen] = useState(false); return (
{panelOpen && setPanelOpen(false)} />}
setPanelOpen((o) => !o)} />
); } ``` 2. In `frontend/src/router.tsx`, replace `import Layout` and its usage: ```tsx // replace: import Layout from './components/Layout'; // with: import AppShell from './components/AppShell'; // replace in rootRoute component: // // with: // // (AppShell renders itself) ``` Full updated rootRoute component: ```tsx export const rootRoute = createRootRoute({ component: AppShell, }); ``` **Acceptance criteria:** App renders the 3-pane layout; panel hidden by default; clicking the queue icon in PlaybackBar opens/closes the panel. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 9 — MediaCard + ShelfRow **Goal:** `` is a square artwork card with hover-play overlay used in grids and carousels. `` is a horizontal scroll container with a title + optional "View all" link. **Files:** `frontend/src/components/MediaCard.tsx` (create), `frontend/src/components/ShelfRow.tsx` (create) **Steps:** 1. Create `frontend/src/components/MediaCard.tsx`: ```tsx import { Play } from 'lucide-react'; import { Artwork } from './Artwork'; interface MediaCardProps { seed: string; title: string; subtitle?: string; artSrc?: string | null; onClick?: () => void; href?: string; } export function MediaCard({ seed, title, subtitle, artSrc, onClick }: MediaCardProps) { return ( ); } ``` 2. Create `frontend/src/components/ShelfRow.tsx`: ```tsx import { Link } from '@tanstack/react-router'; import { ChevronRight } from 'lucide-react'; interface ShelfRowProps { title: string; viewAllTo?: string; children: React.ReactNode; } export function ShelfRow({ title, viewAllTo, children }: ShelfRowProps) { return (

{title}

{viewAllTo && ( View all )}
{children}
); } ``` **Acceptance criteria:** MediaCard shows gradient artwork with play overlay on hover; ShelfRow scrolls horizontally and shows "View all" link when `viewAllTo` given. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 10 — Rework Home.tsx **Goal:** Replace the current two-section Home with Quick Access cards row + three shelf rows (Continue Listening, Recently Added, Most Played). **Files:** `frontend/src/pages/Home.tsx` (modify) **Steps:** 1. Replace `frontend/src/pages/Home.tsx` entirely: ```tsx import { useQuery } from '@tanstack/react-query'; import { Clock, Heart, Star, PlusCircle } from 'lucide-react'; import { ShelfRow } from '../components/ShelfRow'; import { MediaCard } from '../components/MediaCard'; import { historyService } from '../services/historyService'; import { trackService } from '../services/trackService'; import { usePlaybackStore } from '../store/usePlaybackStore'; import type { HistoryEntry, Track } from '../types'; interface QuickCard { label: string; icon: React.ReactNode; to: string; gradient: string; } const QUICK: QuickCard[] = [ { label: 'Favorites', icon: , to: '/tracks', gradient: 'from-pink-900/80 to-rose-950/80' }, { label: 'Recently Added', icon: , to: '/tracks', gradient: 'from-blue-900/80 to-indigo-950/80' }, { label: 'Most Played', icon: , to: '/tracks', gradient: 'from-amber-900/80 to-orange-950/80' }, { label: 'History', icon: , to: '/tracks', gradient: 'from-emerald-900/80 to-teal-950/80' }, ]; export default function Home() { const { setQueue, playTrack } = usePlaybackStore(); const history = useQuery({ queryKey: ['history'], queryFn: () => historyService.list(), }); const recentlyAdded = useQuery({ queryKey: ['recently-added'], queryFn: () => trackService.listTracks({ limit: 20 }), select: (tracks) => [...tracks].sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)).slice(0, 12), }); const mostPlayed = useQuery({ queryKey: ['most-played'], queryFn: () => trackService.listTracks({ limit: 12, sort_by: 'play_count', order: 'DESC' }), }); const playFrom = (list: Track[], index: number) => { setQueue(list.slice(index)); playTrack(list[index]); }; const historyTracks: Track[] = (history.data ?? []).slice(0, 12); return (

Good listening

Your music, your way.

{/* Quick access */}
{QUICK.map((card) => (
{card.icon} {card.label}
))}
{history.isLoading ? (

Loading…

) : historyTracks.length === 0 ? (

Nothing played yet.

) : ( historyTracks.map((track, i) => (
playFrom(historyTracks, i)} />
)) )}
{recentlyAdded.isLoading ? (

Loading…

) : (recentlyAdded.data ?? []).length === 0 ? (

No tracks yet.

) : ( (recentlyAdded.data ?? []).map((track, i) => (
playFrom(recentlyAdded.data!, i)} />
)) )}
{mostPlayed.isLoading ? (

Loading…

) : (mostPlayed.data ?? []).length === 0 ? (

No tracks yet.

) : ( (mostPlayed.data ?? []).map((track, i) => (
playFrom(mostPlayed.data!, i)} />
)) )}
); } ``` **Acceptance criteria:** Page shows 4 quick-access gradient cards + 3 horizontal shelves; clicking a media card plays from that position. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 11 — Restyle library pages (Tracks, Artists, ArtistDetail, Albums, AlbumDetail, Genres) **Goal:** Replace all hard-coded `zinc-*` classes with semantic tokens; replace `LibraryTrackRow` with `TrackRow`; use `MediaCard` / `Artwork` in grid views. **Files:** `Tracks.tsx`, `Artists.tsx`, `ArtistDetail.tsx`, `Albums.tsx`, `AlbumDetail.tsx`, `Genres.tsx` (all modify) **Steps:** 1. **`Tracks.tsx`** — swap `LibraryTrackRow` for `TrackRow`; restyle pagination buttons: ```tsx import { useState } from 'react'; import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { Music, ChevronLeft, ChevronRight } from 'lucide-react'; import { trackService } from '../services/trackService'; import { TrackRow } from '../components/TrackRow'; import type { Track } from '../types'; const PAGE_SIZE = 50; export default function Tracks() { const [page, setPage] = useState(0); const { data, isLoading, isError, isPlaceholderData } = useQuery({ queryKey: ['tracks', page], queryFn: () => trackService.listTracks({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, sort_by: 'title', order: 'ASC' }), placeholderData: keepPreviousData, }); const tracks = data ?? []; const hasNext = tracks.length === PAGE_SIZE; return (

Songs

{isLoading ?

Loading…

: isError ?

Couldn't load tracks.

: tracks.length === 0 ?

{page === 0 ? 'No tracks yet.' : 'No more tracks.'}

:
{tracks.map((t, i) => )}
}
Page {page + 1}
); } ``` 2. **`Artists.tsx`** — replace `zinc-*` with tokens; use `Artwork` for avatar: ```tsx import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { Users } from 'lucide-react'; import { artistService } from '../services/artistService'; import { Artwork } from '../components/Artwork'; import type { Artist } from '../types'; export default function Artists() { const { data, isLoading, isError } = useQuery({ queryKey: ['artists'], queryFn: () => artistService.listArtists(), }); return (

Artists

{isLoading ?

Loading…

: isError ?

Couldn't load artists.

: !data?.length ?

No artists yet.

: (
{data.map((artist) => (
{artist.name}
))}
)}
); } ``` 3. **`ArtistDetail.tsx`** — tokens; use `Artwork` for artist and album cards: ```tsx import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { ArrowLeft } from 'lucide-react'; import { artistDetailRoute } from '../router'; import { artistService } from '../services/artistService'; import { Artwork } from '../components/Artwork'; import type { ArtistWithAlbums } from '../types'; export default function ArtistDetail() { const { artistId } = artistDetailRoute.useParams(); const { data, isLoading, isError } = useQuery({ queryKey: ['artist', artistId], queryFn: () => artistService.getArtist(artistId), }); if (isLoading) return

Loading…

; if (isError || !data) return

Couldn't load artist.

; const albums = data.albums ?? []; return (
Artists

{data.name}

{albums.length} {albums.length === 1 ? 'album' : 'albums'}

Albums

{albums.length === 0 ?

No albums.

: (
{albums.map((album) => (
{album.title}
{album.year &&
{album.year}
}
))}
)}
); } ``` 4. **`Albums.tsx`** — tokens + `Artwork`: ```tsx import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { Disc3 } from 'lucide-react'; import { albumService } from '../services/albumService'; import { Artwork } from '../components/Artwork'; import type { Album } from '../types'; export default function Albums() { const { data, isLoading, isError } = useQuery({ queryKey: ['albums'], queryFn: () => albumService.listAlbums(), }); return (

Albums

{isLoading ?

Loading…

: isError ?

Couldn't load albums.

: !data?.length ?

No albums yet.

: (
{data.map((album) => (
{album.title}
{album.year &&
{album.year}
}
))}
)}
); } ``` 5. **`AlbumDetail.tsx`** — tokens + `Artwork` + `TrackRow`: ```tsx import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { Play, ArrowLeft } from 'lucide-react'; import { albumDetailRoute } from '../router'; import { albumService } from '../services/albumService'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { Artwork } from '../components/Artwork'; import { TrackRow } from '../components/TrackRow'; import type { AlbumWithTracks } from '../types'; export default function AlbumDetail() { const { albumId } = albumDetailRoute.useParams(); const { setQueue, playTrack } = usePlaybackStore(); const { data, isLoading, isError } = useQuery({ queryKey: ['album', albumId], queryFn: () => albumService.getAlbum(albumId), }); if (isLoading) return

Loading…

; if (isError || !data) return

Couldn't load album.

; const tracks = data.tracks ?? []; return (
Albums

{data.title}

{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}

{tracks.length === 0 ?

No tracks.

: tracks.map((t, i) => )}
); } ``` 6. **`Genres.tsx`** — tokens; genre cards use gradient derived from genre name: ```tsx import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Tag, Play, ArrowLeft } from 'lucide-react'; import { genreService } from '../services/genreService'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { TrackRow } from '../components/TrackRow'; import type { Genre, Track } from '../types'; function hueFrom(s: string) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; } export default function Genres() { const [selected, setSelected] = useState(null); const { setQueue, playTrack } = usePlaybackStore(); const genresQ = useQuery({ queryKey: ['genres'], queryFn: () => genreService.listGenres() }); const tracksQ = useQuery({ queryKey: ['genre-tracks', selected?.id], queryFn: () => genreService.getGenreTracks(selected!.id), enabled: !!selected, }); if (selected) { const tracks = tracksQ.data ?? []; return (

{selected.name}

{tracksQ.isLoading ?

Loading…

: tracks.length === 0 ?

No tracks.

:
{tracks.map((t, i) => )}
}
); } return (

Genres

{genresQ.isLoading ?

Loading…

: genresQ.isError ?

Couldn't load genres.

: !genresQ.data?.length ?

No genres yet.

: (
{genresQ.data.map((genre) => { const hue = hueFrom(genre.name); return ( ); })}
)}
); } ``` **Acceptance criteria:** All 6 pages compile; no `zinc-*` or `gray-*` hard-coded color references remain; `LibraryTrackRow` no longer imported. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 12 — Restyle Discover, Vibe, Search **Goal:** Apply semantic token classes; use `TrackRow` in Search; keep all logic intact. **Files:** `Discover.tsx`, `Vibe.tsx`, `Search.tsx` (all modify) **Steps:** 1. **`Discover.tsx`** — swap `zinc-*` for tokens; use `TrackRow` for the track list: - Replace `border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 hover:bg-zinc-800/70` → `border-line bg-surface hover:bg-surface-h` - Replace `text-zinc-400` → `text-muted`, `text-zinc-200` → `text-primary`, `text-white` → `text-primary` - The genre card active state `border-blue-500/70 bg-blue-500/10` → `border-accent/60 bg-accent/10` - Replace the inline `
))} )} ); } ``` **Acceptance criteria:** Page lists disliked tracks from the real backend; Restore clears the row and returns track to library; Delete prompts confirmation; countdown shows time remaining. **Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` --- ## Task 14 — Settings restyle + final typecheck **Goal:** Restyle Settings with semantic tokens; remove hard-coded `gray-800`/`gray-700` classes; keep theme + volume logic intact. Then run a final typecheck. **Files:** `frontend/src/pages/Settings.tsx` (modify) **Steps:** 1. Rewrite `frontend/src/pages/Settings.tsx` (logic unchanged, colors replaced): ```tsx import { useEffect, useState } from 'react'; import { Palette, Volume2, Info, Check } from 'lucide-react'; import { usePlaybackStore } from '../store/usePlaybackStore'; import api from '../services/api'; import { THEMES, DEFAULT_THEME_ID, STORAGE_KEYS, applyTheme, readStoredThemeId, readStoredVolume, type ThemePreset } from '../lib/theme'; export default function Settings() { const volume = usePlaybackStore((s) => s.volume); const setVolume = usePlaybackStore((s) => s.setVolume); const [themeId, setThemeId] = useState(DEFAULT_THEME_ID); useEffect(() => { const id = readStoredThemeId(); setThemeId(id); const t = THEMES.find((x) => x.id === id); if (t) applyTheme(t); setVolume(readStoredVolume(volume)); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const selectTheme = (theme: ThemePreset) => { setThemeId(theme.id); applyTheme(theme); try { localStorage.setItem(STORAGE_KEYS.theme, theme.id); } catch { /**/ } }; const handleVolume = (v: number) => { setVolume(v); try { localStorage.setItem(STORAGE_KEYS.volume, String(v)); } catch { /**/ } }; return (

Settings

Preferences are stored locally in this browser.

Theme

{THEMES.map((theme) => { const active = theme.id === themeId; return ( ); })}

Default volume

handleVolume(Number(e.target.value))} className="flex-1 accent-[var(--accent)]" aria-label="Volume" /> {Math.round(volume * 100)}%

About

Application
muzick
Version
0.1.0
API base
{api.defaults.baseURL ?? '/api'}
); } ``` 2. Also update `VibeTimeline.tsx` to use tokens (it's used by Vibe): ```tsx // Replace zinc-* with token classes throughout VibeTimeline.tsx: // bg-zinc-900/50 border-zinc-800 → bg-surface border-line // hover:border-zinc-700 hover:bg-zinc-800/70 → hover:bg-surface-h // text-zinc-200 → text-primary // text-zinc-400/500 → text-muted // text-zinc-600 → text-muted/60 // bg-blue-500/20 text-blue-300 → bg-accent/20 text-accent // bg-blue-500 text-white → bg-accent text-on-accent // bg-zinc-800 text-zinc-500 → bg-elevated text-muted // The 'Now playing' span: bg-blue-500 → bg-accent ``` 3. Delete `frontend/src/components/Layout.tsx`, `frontend/src/components/NowPlayingBar.tsx`, `frontend/src/pages/LibraryTrackRow.tsx`. 4. Run final typecheck: ```bash cd frontend && npm run typecheck ``` **Acceptance criteria:** Zero TypeScript errors. No remaining imports of `Layout`, `NowPlayingBar`, or `LibraryTrackRow`. **Verify:** ```bash cd /mnt/server/home/kami/apps/muzick/frontend && npm run typecheck 2>&1 | tail -5 # Expected: no output or "Found 0 errors." grep -r "LibraryTrackRow\|NowPlayingBar\|from.*Layout" src/ | grep -v "\.md" # Expected: no output ``` --- ## Execution order Tasks are ordered by dependency: ``` 1 (tokens) → 2 (Artwork) → 3 (TrackRow) → 4 (PlaybackBar) → 5 (NowPlayingPanel) → 6 (NavRail) → 7 (TopBar) → 8 (AppShell+router) → 9 (MediaCard+ShelfRow) → 10 (Home) → 11 (library pages) → 12 (Discover/Vibe/Search) → 13 (Quarantine) → 14 (Settings + typecheck) ``` Tasks 2–7 have no inter-dependencies and can be written in parallel; they all depend only on task 1. Tasks 11–13 depend on tasks 1–3. --- ## Execute now with `/implement`?