Files
muzick/docs/plans/2026-06-08-ui-overhaul.md
T

1707 lines
66 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.
# UI Overhaul — Implementation Plan
**Date:** 2026-06-08
**Phases:** 14 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<string, string>;
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 `<Artwork>` 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 <img src={src} alt={seed} className={`object-cover ${r} ${className}`} />;
}
return <div className={`${r} ${className}`} style={{ background: gradient }} />;
}
```
**Acceptance criteria:** Component renders with a gradient when no `src` given; renders an `<img>` when `src` is provided.
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error``0`
---
## Task 3 — TrackRow component
**Goal:** A single reusable `<TrackRow>` 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 (
<div
onClick={handlePlay}
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border p-2.5 transition-colors ${
isCurrent
? 'border-accent/60 bg-accent/10'
: 'border-line bg-surface/50 hover:border-line hover:bg-surface-h'
}`}
>
<div className="relative flex h-10 w-10 flex-none items-center justify-center rounded overflow-hidden">
<Artwork seed={`${track.title} ${track.artist}`} className="absolute inset-0 w-full h-full" />
{trackNumber !== undefined ? (
<span className={`relative z-10 text-sm tabular-nums text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`}>
{trackNumber}
</span>
) : (
<Music size={18} className={`relative z-10 text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`} />
)}
{isCurrent && isPlaying ? (
<Pause size={18} className="absolute z-20 text-primary opacity-100" />
) : (
<Play size={18} className="absolute z-20 text-primary opacity-0 group-hover:opacity-100" />
)}
</div>
<div className="min-w-0 flex-1">
<div className={`truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-primary'}`}>
{track.title || 'Untitled'}
</div>
<div className="truncate text-xs text-muted">{track.artist || 'Unknown artist'}</div>
</div>
{showActions && (
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button onClick={handleFavorite} title="Favorite" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-pink-400">
<Heart size={16} />
</button>
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-red-400">
<ThumbsDown size={16} />
</button>
</div>
)}
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
</div>
);
}
```
**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 (
<div className="h-20 bg-elevated border-t border-line px-4 flex items-center gap-4 shrink-0">
{/* Track info */}
<div className="flex items-center gap-3 w-56 min-w-0 shrink-0">
{currentTrack ? (
<>
<div className="w-12 h-12 flex-none rounded overflow-hidden">
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} className="w-full h-full" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-primary truncate">{currentTrack.title}</div>
<div className="text-xs text-muted truncate">{currentTrack.artist}</div>
</div>
</>
) : (
<div className="text-sm text-muted italic">Nothing playing</div>
)}
</div>
{/* Controls + scrubber */}
<div className="flex-1 flex flex-col items-center gap-1">
<div className="flex items-center gap-5">
<button onClick={prev} className="text-muted hover:text-primary" aria-label="Previous">
<SkipBack size={20} />
</button>
<button
onClick={() => isPlaying ? pause() : play()}
disabled={!currentTrack}
className="w-9 h-9 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40 transition-colors"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={next} className="text-muted hover:text-primary" aria-label="Next">
<SkipForward size={20} />
</button>
</div>
<div className="flex w-full max-w-lg items-center gap-2">
<span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span>
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
onChange={(e) => setPosition(Number(e.target.value))}
disabled={!currentTrack || duration <= 0}
className="flex-1 h-1 cursor-pointer accent-[var(--accent)]"
aria-label="Seek"
/>
<span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span>
</div>
</div>
{/* Volume + panel toggle */}
<div className="flex items-center gap-3 w-48 justify-end shrink-0">
<Volume2 size={18} className="text-muted flex-none" />
<input
type="range" min={0} max={1} step={0.01} value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="w-20 h-1 cursor-pointer accent-[var(--accent)]"
aria-label="Volume"
/>
<button
onClick={onTogglePanel}
className={`p-2 rounded-md transition-colors ${panelOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-primary'}`}
aria-label="Toggle queue panel"
title="Up Next"
>
<ListMusic size={18} />
</button>
</div>
</div>
);
}
```
**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 (
<aside className="w-72 flex flex-col border-l border-line bg-elevated overflow-hidden shrink-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-line">
<span className="text-sm font-semibold text-primary">Now Playing</span>
<button onClick={onClose} className="text-muted hover:text-primary p-1 rounded">
<X size={16} />
</button>
</div>
<div className="p-4 space-y-4">
<div className="aspect-square rounded-xl overflow-hidden">
<Artwork
seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'}
className="w-full h-full"
rounded="xl"
/>
</div>
{currentTrack ? (
<div className="text-center space-y-0.5">
<div className="font-bold text-primary truncate">{currentTrack.title}</div>
<div className="text-sm text-muted truncate">{currentTrack.artist}</div>
</div>
) : (
<div className="text-center text-sm text-muted italic">No track playing</div>
)}
<div className="space-y-1">
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
onChange={(e) => setPosition(Number(e.target.value))}
disabled={!currentTrack || duration <= 0}
className="w-full h-1 cursor-pointer accent-[var(--accent)]"
/>
<div className="flex justify-between text-xs text-muted tabular-nums">
<span>{formatDuration(position)}</span>
<span>{formatDuration(duration)}</span>
</div>
</div>
<div className="flex items-center justify-center gap-6">
<button onClick={prev} className="text-muted hover:text-primary"><SkipBack size={20} /></button>
<button
onClick={() => isPlaying ? pause() : play()}
disabled={!currentTrack}
className="w-10 h-10 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40"
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={next} className="text-muted hover:text-primary"><SkipForward size={20} /></button>
</div>
</div>
<div className="flex-1 overflow-y-auto border-t border-line">
<div className="px-4 py-2 text-xs font-semibold text-muted uppercase tracking-wide">
Up Next ({upNext.length})
</div>
{upNext.length === 0 ? (
<div className="px-4 pb-4 text-sm text-muted italic">Queue is empty.</div>
) : (
<ul>
{upNext.map((track, i) => (
<li key={`${track.id}-${i}`}>
<button
onClick={() => { setQueue(upNext.slice(i)); playTrack(track); }}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-surface-h"
>
<div className="w-8 h-8 flex-none rounded overflow-hidden">
<Artwork seed={`${track.title} ${track.artist}`} className="w-full h-full" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-primary">{track.title}</div>
<div className="truncate text-xs text-muted">{track.artist}</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
</aside>
);
}
```
**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 (
<aside className="w-56 flex flex-col bg-surface border-r border-line shrink-0 overflow-y-auto">
<div className="px-4 py-5">
<span className="text-xl font-bold text-accent tracking-tight">Muzick</span>
</div>
<nav className="flex-1 px-3 space-y-5 pb-4">
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<div className="px-3 mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted/60">
{group.label}
</div>
<ul className="space-y-0.5">
{group.items.map(({ to, icon: Icon, label }) => (
<li key={to}>
<Link
to={to}
activeOptions={{ exact: to === '/' }}
activeProps={{ className: `${base} ${active}` }}
inactiveProps={{ className: `${base} ${inactive}` }}
>
<Icon size={18} />
{label}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
);
}
```
**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 (
<header className="h-14 bg-elevated border-b border-line flex items-center px-4 gap-4 shrink-0">
<div className="w-56 shrink-0" /> {/* aligns with NavRail width */}
<form onSubmit={handleSubmit} className="flex-1 max-w-xl">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
<input
type="search"
value={q}
onChange={(e) => 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"
/>
</div>
</form>
</header>
);
}
```
**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 (
<div className="flex flex-col h-screen bg-background text-primary overflow-hidden">
<TopBar />
<div className="flex flex-1 overflow-hidden">
<NavRail />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
{panelOpen && <NowPlayingPanel onClose={() => setPanelOpen(false)} />}
</div>
<PlaybackBar panelOpen={panelOpen} onTogglePanel={() => setPanelOpen((o) => !o)} />
<AudioEngine />
</div>
);
}
```
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:
// <Layout><Outlet /></Layout>
// with:
// <AppShell />
// (AppShell renders <Outlet /> 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:** `<MediaCard>` is a square artwork card with hover-play overlay used in grids and carousels. `<ShelfRow>` 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 (
<button
onClick={onClick}
className="group flex flex-col gap-2 text-left w-full bg-surface hover:bg-surface-h border border-line rounded-xl p-3 transition-colors"
>
<div className="relative aspect-square rounded-lg overflow-hidden w-full">
<Artwork seed={seed} src={artSrc} className="w-full h-full" rounded="lg" />
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-10 h-10 rounded-full bg-accent flex items-center justify-center shadow-lg">
<Play size={18} fill="white" className="text-on-accent ml-0.5" />
</div>
</div>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-primary">{title}</div>
{subtitle && <div className="truncate text-xs text-muted mt-0.5">{subtitle}</div>}
</div>
</button>
);
}
```
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 (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-primary">{title}</h2>
{viewAllTo && (
<Link to={viewAllTo} className="flex items-center gap-0.5 text-xs text-muted hover:text-accent transition-colors">
View all <ChevronRight size={14} />
</Link>
)}
</div>
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide">
{children}
</div>
</section>
);
}
```
**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: <Heart size={20} />, to: '/tracks', gradient: 'from-pink-900/80 to-rose-950/80' },
{ label: 'Recently Added', icon: <PlusCircle size={20} />, to: '/tracks', gradient: 'from-blue-900/80 to-indigo-950/80' },
{ label: 'Most Played', icon: <Star size={20} />, to: '/tracks', gradient: 'from-amber-900/80 to-orange-950/80' },
{ label: 'History', icon: <Clock size={20} />, to: '/tracks', gradient: 'from-emerald-900/80 to-teal-950/80' },
];
export default function Home() {
const { setQueue, playTrack } = usePlaybackStore();
const history = useQuery<HistoryEntry[]>({
queryKey: ['history'],
queryFn: () => historyService.list(),
});
const recentlyAdded = useQuery<Track[]>({
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<Track[]>({
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 (
<div className="space-y-8 max-w-5xl">
<div>
<h1 className="text-3xl font-bold text-primary">Good listening</h1>
<p className="text-muted mt-1">Your music, your way.</p>
</div>
{/* Quick access */}
<section>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{QUICK.map((card) => (
<div
key={card.label}
className={`flex items-center gap-3 rounded-lg bg-gradient-to-br ${card.gradient} border border-line/40 px-4 py-3 cursor-pointer hover:opacity-90 transition-opacity`}
>
<span className="text-primary/70">{card.icon}</span>
<span className="text-sm font-semibold text-primary">{card.label}</span>
</div>
))}
</div>
</section>
<ShelfRow title="Continue Listening" viewAllTo="/tracks">
{history.isLoading ? (
<p className="text-sm text-muted py-4">Loading</p>
) : historyTracks.length === 0 ? (
<p className="text-sm text-muted py-4">Nothing played yet.</p>
) : (
historyTracks.map((track, i) => (
<div key={`${track.id}-${i}`} className="w-36 shrink-0">
<MediaCard
seed={`${track.title} ${track.artist}`}
title={track.title}
subtitle={track.artist}
onClick={() => playFrom(historyTracks, i)}
/>
</div>
))
)}
</ShelfRow>
<ShelfRow title="Recently Added" viewAllTo="/tracks">
{recentlyAdded.isLoading ? (
<p className="text-sm text-muted py-4">Loading</p>
) : (recentlyAdded.data ?? []).length === 0 ? (
<p className="text-sm text-muted py-4">No tracks yet.</p>
) : (
(recentlyAdded.data ?? []).map((track, i) => (
<div key={track.id} className="w-36 shrink-0">
<MediaCard
seed={`${track.title} ${track.artist}`}
title={track.title}
subtitle={track.artist}
onClick={() => playFrom(recentlyAdded.data!, i)}
/>
</div>
))
)}
</ShelfRow>
<ShelfRow title="Most Played" viewAllTo="/tracks">
{mostPlayed.isLoading ? (
<p className="text-sm text-muted py-4">Loading</p>
) : (mostPlayed.data ?? []).length === 0 ? (
<p className="text-sm text-muted py-4">No tracks yet.</p>
) : (
(mostPlayed.data ?? []).map((track, i) => (
<div key={track.id} className="w-36 shrink-0">
<MediaCard
seed={`${track.title} ${track.artist}`}
title={track.title}
subtitle={`${track.play_count} plays`}
onClick={() => playFrom(mostPlayed.data!, i)}
/>
</div>
))
)}
</ShelfRow>
</div>
);
}
```
**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<Track[]>({
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 (
<div className="space-y-6 max-w-3xl">
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
<Music size={28} className="text-accent" /> Songs
</h1>
{isLoading ? <p className="text-sm text-muted">Loading</p>
: isError ? <p className="text-sm text-muted">Couldn't load tracks.</p>
: tracks.length === 0 ? <p className="text-sm text-muted">{page === 0 ? 'No tracks yet.' : 'No more tracks.'}</p>
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
<div className="flex items-center justify-between pt-2">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0 || isPlaceholderData}
className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40">
<ChevronLeft size={16} /> Prev
</button>
<span className="text-sm text-muted">Page {page + 1}</span>
<button onClick={() => setPage((p) => p + 1)} disabled={!hasNext || isPlaceholderData}
className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40">
Next <ChevronRight size={16} />
</button>
</div>
</div>
);
}
```
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<Artist[]>({
queryKey: ['artists'],
queryFn: () => artistService.listArtists(),
});
return (
<div className="space-y-6">
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
<Users size={28} className="text-accent" /> Artists
</h1>
{isLoading ? <p className="text-sm text-muted">Loading</p>
: isError ? <p className="text-sm text-muted">Couldn't load artists.</p>
: !data?.length ? <p className="text-sm text-muted">No artists yet.</p>
: (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{data.map((artist) => (
<Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }}
className="group flex flex-col items-center gap-3 rounded-xl border border-line bg-surface p-4 hover:bg-surface-h transition-colors">
<div className="w-24 h-24 rounded-full overflow-hidden">
<Artwork seed={artist.name} src={artist.image_path} className="w-full h-full" rounded="full" />
</div>
<div className="text-sm font-medium text-primary truncate w-full text-center">{artist.name}</div>
</Link>
))}
</div>
)}
</div>
);
}
```
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<ArtistWithAlbums>({
queryKey: ['artist', artistId],
queryFn: () => artistService.getArtist(artistId),
});
if (isLoading) return <p className="text-sm text-muted">Loading</p>;
if (isError || !data) return <p className="text-sm text-muted">Couldn't load artist.</p>;
const albums = data.albums ?? [];
return (
<div className="space-y-8 max-w-4xl">
<Link to="/artists" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary">
<ArrowLeft size={16} /> Artists
</Link>
<div className="flex items-center gap-5">
<div className="w-28 h-28 flex-none rounded-full overflow-hidden">
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" />
</div>
<div>
<h1 className="text-4xl font-bold text-primary">{data.name}</h1>
<p className="text-sm text-muted mt-1">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p>
</div>
</div>
<section className="space-y-4">
<h2 className="text-xl font-semibold text-primary">Albums</h2>
{albums.length === 0 ? <p className="text-sm text-muted">No albums.</p> : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{albums.map((album) => (
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
className="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors">
<div className="aspect-square rounded-lg overflow-hidden">
<Artwork seed={`${album.title} ${data.name}`} className="w-full h-full" rounded="lg" />
</div>
<div>
<div className="truncate text-sm font-medium text-primary">{album.title}</div>
{album.year && <div className="text-xs text-muted">{album.year}</div>}
</div>
</Link>
))}
</div>
)}
</section>
</div>
);
}
```
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<Album[]>({
queryKey: ['albums'],
queryFn: () => albumService.listAlbums(),
});
return (
<div className="space-y-6">
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
<Disc3 size={28} className="text-accent" /> Albums
</h1>
{isLoading ? <p className="text-sm text-muted">Loading</p>
: isError ? <p className="text-sm text-muted">Couldn't load albums.</p>
: !data?.length ? <p className="text-sm text-muted">No albums yet.</p>
: (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{data.map((album) => (
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
className="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors">
<div className="aspect-square rounded-lg overflow-hidden">
<Artwork seed={album.title} className="w-full h-full" rounded="lg" />
</div>
<div>
<div className="truncate text-sm font-medium text-primary">{album.title}</div>
{album.year && <div className="text-xs text-muted">{album.year}</div>}
</div>
</Link>
))}
</div>
)}
</div>
);
}
```
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<AlbumWithTracks>({
queryKey: ['album', albumId],
queryFn: () => albumService.getAlbum(albumId),
});
if (isLoading) return <p className="text-sm text-muted">Loading</p>;
if (isError || !data) return <p className="text-sm text-muted">Couldn't load album.</p>;
const tracks = data.tracks ?? [];
return (
<div className="space-y-8 max-w-3xl">
<Link to="/albums" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary">
<ArrowLeft size={16} /> Albums
</Link>
<div className="flex items-end gap-5">
<div className="w-40 h-40 flex-none rounded-xl overflow-hidden">
<Artwork seed={data.title} className="w-full h-full" rounded="xl" />
</div>
<div className="space-y-2">
<h1 className="text-4xl font-bold text-primary">{data.title}</h1>
<p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p>
<button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-5 py-2 text-sm font-semibold text-on-accent disabled:opacity-50 transition-colors">
<Play size={16} fill="currentColor" /> Play album
</button>
</div>
</div>
<section className="space-y-1">
{tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p>
: tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} trackNumber={i + 1} />)}
</section>
</div>
);
}
```
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<Genre | null>(null);
const { setQueue, playTrack } = usePlaybackStore();
const genresQ = useQuery<Genre[]>({ queryKey: ['genres'], queryFn: () => genreService.listGenres() });
const tracksQ = useQuery<Track[]>({
queryKey: ['genre-tracks', selected?.id],
queryFn: () => genreService.getGenreTracks(selected!.id),
enabled: !!selected,
});
if (selected) {
const tracks = tracksQ.data ?? [];
return (
<div className="space-y-6 max-w-3xl">
<button onClick={() => setSelected(null)} className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary">
<ArrowLeft size={16} /> Genres
</button>
<div className="flex items-center justify-between">
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />{selected.name}</h1>
<button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }} disabled={!tracks.length}
className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-4 py-2 text-sm font-semibold text-on-accent disabled:opacity-50">
<Play size={16} fill="currentColor" /> Play all
</button>
</div>
{tracksQ.isLoading ? <p className="text-sm text-muted">Loading</p>
: tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p>
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
</div>
);
}
return (
<div className="space-y-6">
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />Genres</h1>
{genresQ.isLoading ? <p className="text-sm text-muted">Loading</p>
: genresQ.isError ? <p className="text-sm text-muted">Couldn't load genres.</p>
: !genresQ.data?.length ? <p className="text-sm text-muted">No genres yet.</p>
: (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{genresQ.data.map((genre) => {
const hue = hueFrom(genre.name);
return (
<button key={genre.id} onClick={() => setSelected(genre)}
className="group flex flex-col items-start gap-2 rounded-xl border border-line p-4 text-left transition-colors hover:border-accent/40"
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
<Tag size={20} className="text-muted group-hover:text-accent" />
<div className="w-full truncate font-medium text-primary">{genre.name}</div>
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
</button>
);
})}
</div>
)}
</div>
);
}
```
**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 `<button>` track list rows with `<TrackRow>` (pass `showActions={false}`)
- The "Start a vibe" button: `border-blue-500/60 bg-blue-500/10 text-blue-300 hover:bg-blue-500/20``border-accent/60 bg-accent/10 text-accent hover:bg-accent/20`
- Keep all logic, hooks, imports unchanged except adding `TrackRow` import and removing the inline button track row
2. **`Vibe.tsx`** — same token swap; keep all logic:
- All `bg-zinc-900/50 border-zinc-800``bg-surface border-line`
- `hover:bg-zinc-800/70``hover:bg-surface-h`
- `text-zinc-400/500``text-muted`
- `text-zinc-200/300``text-primary`
- The seed-picker list buttons swap to `<TrackRow showActions={false}>` for the seed picker list
- The session buttons ("Keep", "Dislike & skip", "End Vibe"): token border/text classes
- The `bg-blue-500/10 border-blue-500/60 text-blue-300/400` accents → `bg-accent/10 border-accent/60 text-accent`
- `VibeTimeline` is used as-is (it will be restyled in its own file in a follow-up, but for now leave it)
3. **`Search.tsx`** — swap `LibraryTrackRow` for `TrackRow`; token classes on the input:
```tsx
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search as SearchIcon } from 'lucide-react';
import { searchService } from '../services/searchService';
import { TrackRow } from '../components/TrackRow';
import type { SearchResponse, Track } from '../types';
export default function Search() {
const [input, setInput] = useState('');
const [query, setQuery] = useState('');
useEffect(() => { const id = setTimeout(() => setQuery(input.trim()), 300); return () => clearTimeout(id); }, [input]);
const { data, isLoading, isError, isFetching } = useQuery<SearchResponse>({
queryKey: ['search', query],
queryFn: () => searchService.search(query),
enabled: query.length > 0,
});
const tracks: Track[] = (data?.hits ?? []).map((h) => h.document).filter((t): t is Track => Boolean(t));
return (
<div className="space-y-6 max-w-3xl">
<h1 className="text-3xl font-bold text-primary">Search</h1>
<div className="relative max-w-xl">
<SearchIcon size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
<input type="search" value={input} onChange={(e) => setInput(e.target.value)}
placeholder="Search tracks, artists…" autoFocus
className="w-full rounded-lg border border-line bg-surface/70 py-2.5 pl-10 pr-4 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors" />
</div>
{query.length === 0 ? <p className="text-sm text-muted">Type to search.</p>
: isLoading || isFetching ? <p className="text-sm text-muted">Searching</p>
: isError ? <p className="text-sm text-muted">Search failed.</p>
: tracks.length === 0 ? <p className="text-sm text-muted">No results for "{query}".</p>
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
</div>
);
}
```
**Acceptance criteria:** No `LibraryTrackRow` import in any of the three files; no hard-coded `zinc-*`/`gray-*` colors.
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error``0`
---
## Task 13 — Real Quarantine page + quarantineService
**Goal:** Add `DislikeEntry` to `types.ts`; create `quarantineService.ts`; rewrite `Quarantine.tsx` with a real list, countdowns, Restore, and hard-Delete actions.
**Files:** `frontend/src/types.ts` (modify), `frontend/src/services/quarantineService.ts` (create), `frontend/src/pages/Quarantine.tsx` (rewrite)
**Steps:**
1. Add to `frontend/src/types.ts`:
```ts
export interface DislikeEntry {
track_id: string;
disliked_at: string;
warned_at: string | null;
deleted_at: string | null;
grace_hours: number;
state: 'HIDDEN' | 'WARNED' | 'DELETED' | string;
track_title: string;
track_artist: string;
track_path: string;
}
```
2. Create `frontend/src/services/quarantineService.ts`:
```ts
import api from './api';
import type { DislikeEntry } from '../types';
export const quarantineService = {
async list(): Promise<DislikeEntry[]> {
const res = await api.get<DislikeEntry[]>('/dislikes');
return res.data;
},
async restore(trackId: string): Promise<void> {
await api.post(`/dislikes/${trackId}/restore`);
},
async hardDelete(trackId: string): Promise<void> {
await api.delete(`/dislikes/${trackId}`);
},
};
```
3. Rewrite `frontend/src/pages/Quarantine.tsx`:
```tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ShieldAlert, RotateCcw, Trash2, Clock } from 'lucide-react';
import { quarantineService } from '../services/quarantineService';
import type { DislikeEntry } from '../types';
function countdown(entry: DislikeEntry): string {
const base = entry.warned_at
? new Date(entry.warned_at).getTime() + 24 * 3600 * 1000
: new Date(entry.disliked_at).getTime() + entry.grace_hours * 3600 * 1000;
const ms = base - Date.now();
if (ms <= 0) return 'Deleting soon';
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
return h > 0 ? `${h}h ${m}m remaining` : `${m}m remaining`;
}
function stateLabel(state: string) {
if (state === 'WARNED') return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-medium">Warning sent</span>;
return <span className="text-xs px-2 py-0.5 rounded-full bg-surface-h text-muted font-medium">Grace period</span>;
}
export default function Quarantine() {
const qc = useQueryClient();
const { data, isLoading, isError } = useQuery<DislikeEntry[]>({
queryKey: ['dislikes'],
queryFn: () => quarantineService.list(),
refetchInterval: 60_000,
});
const restore = useMutation({
mutationFn: (trackId: string) => quarantineService.restore(trackId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['dislikes'] }),
});
const hardDelete = useMutation({
mutationFn: (trackId: string) => quarantineService.hardDelete(trackId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['dislikes'] }),
});
const entries = data ?? [];
return (
<div className="space-y-6 max-w-3xl">
<div>
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
<ShieldAlert size={28} className="text-accent" /> Quarantine
</h1>
<p className="text-muted mt-1">Disliked tracks pending deletion. Restore before the timer expires.</p>
</div>
{isLoading ? <p className="text-sm text-muted">Loading</p>
: isError ? <p className="text-sm text-muted">Couldn't load quarantine list.</p>
: entries.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-line border-dashed py-16 text-center">
<ShieldAlert size={32} className="text-muted/40" />
<p className="text-muted">No tracks in quarantine.</p>
<p className="text-sm text-muted/60">Disliked tracks will appear here during the grace period.</p>
</div>
) : (
<ul className="space-y-2">
{entries.map((entry) => (
<li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-line bg-surface p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-primary truncate">{entry.track_title}</span>
{stateLabel(entry.state)}
</div>
<div className="text-xs text-muted">{entry.track_artist}</div>
<div className="flex items-center gap-1 mt-1 text-xs text-muted">
<Clock size={12} /> {countdown(entry)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => restore.mutate(entry.track_id)}
disabled={restore.isPending}
title="Restore to library"
className="flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-50 transition-colors"
>
<RotateCcw size={14} /> Restore
</button>
<button
onClick={() => { if (confirm(`Permanently delete "${entry.track_title}"?`)) hardDelete.mutate(entry.track_id); }}
disabled={hardDelete.isPending}
title="Delete now"
className="flex items-center gap-1.5 rounded-lg border border-red-500/40 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors"
>
<Trash2 size={14} /> Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
```
**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<string>(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 (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-3xl font-bold text-primary">Settings</h1>
<p className="text-muted mt-1">Preferences are stored locally in this browser.</p>
</div>
<section className="rounded-xl border border-line bg-surface p-5 space-y-4">
<h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Palette size={20} className="text-accent" />Theme</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{THEMES.map((theme) => {
const active = theme.id === themeId;
return (
<button key={theme.id} onClick={() => selectTheme(theme)}
className={`relative flex flex-col items-start gap-2 rounded-lg border p-3 text-left transition-colors ${active ? 'border-accent ring-2 ring-accent/30' : 'border-line hover:border-accent/40'}`}>
<span className="h-10 w-full rounded-md border border-black/20" style={{ backgroundColor: theme.swatch }} />
<span className="text-sm font-medium text-primary">{theme.name}</span>
{active && <Check size={14} className="absolute right-2 top-2 text-accent" />}
</button>
);
})}
</div>
</section>
<section className="rounded-xl border border-line bg-surface p-5 space-y-4">
<h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Volume2 size={20} className="text-accent" />Default volume</h2>
<div className="flex items-center gap-4">
<input type="range" min={0} max={1} step={0.01} value={volume}
onChange={(e) => handleVolume(Number(e.target.value))}
className="flex-1 accent-[var(--accent)]" aria-label="Volume" />
<span className="w-12 text-right text-sm tabular-nums text-primary">{Math.round(volume * 100)}%</span>
</div>
</section>
<section className="rounded-xl border border-line bg-surface p-5 space-y-3">
<h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Info size={20} className="text-accent" />About</h2>
<dl className="space-y-2 text-sm">
<div className="flex justify-between"><dt className="text-muted">Application</dt><dd className="text-primary font-medium">muzick</dd></div>
<div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-primary font-medium tabular-nums">0.1.0</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted">API base</dt><dd className="font-mono text-xs text-primary break-all">{api.defaults.baseURL ?? '/api'}</dd></div>
</dl>
</section>
</div>
);
}
```
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 27 have no inter-dependencies and can be written in parallel; they all depend only on task 1.
Tasks 1113 depend on tasks 13.
---
## Execute now with `/implement`?