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

66 KiB
Raw Blame History

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:
/** @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: [],
};
  1. Replace the :root block in frontend/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;
}
  1. Replace frontend/src/lib/theme.ts — expand every preset's vars to 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 error0


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:
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 error0


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:
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 error0


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:
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 error0


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:
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 error0


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:
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 error0


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:
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 error0


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:
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>
  );
}
  1. In frontend/src/router.tsx, replace import Layout and 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 error0


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:
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>
  );
}
  1. 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 error0


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:
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 error0


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:
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>
  );
}
  1. Artists.tsx — replace zinc-* with tokens; use Artwork for 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>
  );
}
  1. ArtistDetail.tsx — tokens; use Artwork for 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>
  );
}
  1. 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>
  );
}
  1. 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>
  );
}
  1. 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 error0


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/70border-line bg-surface hover:bg-surface-h
  • Replace text-zinc-400text-muted, text-zinc-200text-primary, text-whitetext-primary
  • The genre card active state border-blue-500/70 bg-blue-500/10border-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/20border-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
  1. Vibe.tsx — same token swap; keep all logic:
  • All bg-zinc-900/50 border-zinc-800bg-surface border-line
  • hover:bg-zinc-800/70hover:bg-surface-h
  • text-zinc-400/500text-muted
  • text-zinc-200/300text-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)
  1. Search.tsx — swap LibraryTrackRow for TrackRow; 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 error0


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:
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;
}
  1. 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}`);
  },
};
  1. 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 error0


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):
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>
  );
}
  1. Also update VibeTimeline.tsx to 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
  1. Delete frontend/src/components/Layout.tsx, frontend/src/components/NowPlayingBar.tsx, frontend/src/pages/LibraryTrackRow.tsx.

  2. 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 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?