66 KiB
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:
- Replace
frontend/tailwind.config.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: [],
};
- Replace the
:rootblock infrontend/src/index.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;
}
- Replace
frontend/src/lib/theme.ts— expand every preset'svarsto include all 12 tokens; add a "Default (Purple)" preset:
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:
- Create
frontend/src/components/Artwork.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:
- Create
frontend/src/components/TrackRow.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:
- Create
frontend/src/components/PlaybackBar.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:
- Create
frontend/src/components/NowPlayingPanel.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:
- Create
frontend/src/components/NavRail.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:
- Create
frontend/src/components/TopBar.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:
- Create
frontend/src/components/AppShell.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>
);
}
- In
frontend/src/router.tsx, replaceimport Layoutand its usage:
// 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:
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:
- Create
frontend/src/components/MediaCard.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>
);
}
- Create
frontend/src/components/ShelfRow.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:
- Replace
frontend/src/pages/Home.tsxentirely:
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:
Tracks.tsx— swapLibraryTrackRowforTrackRow; restyle pagination buttons:
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>
);
}
Artists.tsx— replacezinc-*with tokens; useArtworkfor avatar:
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>
);
}
ArtistDetail.tsx— tokens; useArtworkfor artist and album cards:
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>
);
}
Albums.tsx— tokens +Artwork:
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>
);
}
AlbumDetail.tsx— tokens +Artwork+TrackRow:
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>
);
}
Genres.tsx— tokens; genre cards use gradient derived from genre name:
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:
Discover.tsx— swapzinc-*for tokens; useTrackRowfor 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>(passshowActions={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
TrackRowimport and removing the inline button track row
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-htext-zinc-400/500→text-mutedtext-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/400accents →bg-accent/10 border-accent/60 text-accent VibeTimelineis used as-is (it will be restyled in its own file in a follow-up, but for now leave it)
Search.tsx— swapLibraryTrackRowforTrackRow; token classes on the input:
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:
- Add to
frontend/src/types.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;
}
- Create
frontend/src/services/quarantineService.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}`);
},
};
- Rewrite
frontend/src/pages/Quarantine.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:
- Rewrite
frontend/src/pages/Settings.tsx(logic unchanged, colors replaced):
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>
);
}
- Also update
VibeTimeline.tsxto use tokens (it's used by Vibe):
// 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
-
Delete
frontend/src/components/Layout.tsx,frontend/src/components/NowPlayingBar.tsx,frontend/src/pages/LibraryTrackRow.tsx. -
Run final typecheck:
cd frontend && npm run typecheck
Acceptance criteria: Zero TypeScript errors. No remaining imports of Layout, NowPlayingBar, or LibraryTrackRow.
Verify:
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.