feat(ui): rework every page for comfort, and make a seeded Vibe play its seed
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

A pass over the whole app against the Ethos laws, then a focused pass on
Vibe with the operator reviewing each change.

Across the app:
- The player bar restores the last track it played, paused at zero, so a
  fresh tab opens on where the listener was instead of "nothing playing".
- Track titles link to their album, matching the artist links beside them.
  Playback stays on the artwork tile; a title that played was the surprise.
- The search field is bg-bg2. Tailwind cannot alpha-modify these var()
  colors, so bg-surface0/70 emitted no rule at all and the input fell back
  to the UA's white.
- Row hover is light falling off to the right, not a flat slab.
- The artwork placeholder can drop its note glyph, so TrackRow no longer
  layers a play icon on top of one.

Vibe:
- A seeded Vibe plays its seed first. The seed sits in front of the durable
  plan without being part of it, so the first advance consumes it locally
  and reports no plan feedback.
- The queue drops a second recording of a song it already holds — same
  title, different track id, which id-based dedup let through.
- Up next is read from the queue rather than the plan preview, since the
  seed is not a plan item.
- The header carries the live profile (energy, discovery, goal) and both
  verbs. Keep is gone: letting a track finish already reports `completed`,
  which the director weighs the same.
- The aura is one warm diffuse blob in the page background, warm-hued only
  and quieter on mobile.
- Compact artwork is 32px. It was h-8 w-8, which this remapped spacing
  scale renders as 64px inside a 44px row, and that overflow was the
  "stacked" look.

Verified by render at 1440x900 and 390x844, no horizontal overflow at
either. 26 frontend and 122 backend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-05 23:53:46 +04:00
parent a7d126787f
commit 93619824d8
34 changed files with 745 additions and 436 deletions
+7 -5
View File
@@ -9,6 +9,8 @@ interface ArtworkProps {
rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
/** Skip native lazy-loading — set true for above-the-fold artwork (e.g. PlaybackBar). */
eager?: boolean;
/** Drop the note glyph. For callers that draw their own icon on top (TrackRow's play overlay). */
glyph?: boolean;
}
const API_BASE = import.meta.env.VITE_API_URL || '/api';
@@ -25,13 +27,13 @@ function proxySrc(src: string): string {
return src;
}
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false }: ArtworkProps) {
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false, glyph = true }: ArtworkProps) {
const hue = hueFromString(seed);
// Symmetric top sheen over a diagonal base — the highlight is centered
// horizontally so it reads as even behind the centered note glyph.
const gradient =
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},55%,30%) 0%, transparent 60%), ` +
`linear-gradient(160deg, hsl(${hue},48%,23%), hsl(${(hue + 55) % 360},40%,11%))`;
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},42%,26%) 0%, transparent 60%), ` +
`linear-gradient(160deg, hsl(${hue},36%,19%), hsl(${hue - 6},30%,10%))`;
const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded];
// If an image source is supplied we render it lazily over the gradient
@@ -45,7 +47,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
const proxied = proxySrc(src);
return (
<div className={`relative overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
{!loaded && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
{!loaded && glyph && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
<img
src={proxied}
alt={seed}
@@ -60,7 +62,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
}
return (
<div className={`relative flex items-center justify-center overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
<Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />
{glyph && <Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
</div>
);
}
+1 -1
View File
@@ -86,7 +86,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
<div className="text-center text-sm text-muted italic">No track playing</div>
)}
<div className="space-y-1">
<div className="track-list">
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
+8 -5
View File
@@ -4,15 +4,18 @@ type ContainerWidth = 'sm' | 'md' | 'lg' | 'full';
interface PageContainerProps {
children: ReactNode;
/** Controls max-width. sm → max-w-2xl, md → max-w-3xl (default), lg → max-w-5xl, full → no constraint. */
/** Controls max-width. sm → reading width (prose, forms), md → default, lg → widest grid, full → no constraint. */
width?: ContainerWidth;
className?: string;
}
// Measured on a 1440px viewport: the old md (768px) left ~480px of the main
// area empty and pinned every grid to 45 columns. These track the content,
// not an arbitrary prose measure — only `sm` stays narrow, for forms and prose.
const WIDTH_CLASSES: Record<ContainerWidth, string> = {
sm: 'max-w-2xl',
md: 'max-w-3xl',
lg: 'max-w-5xl',
sm: 'max-w-3xl',
md: 'max-w-[1240px]',
lg: 'max-w-[1400px]',
full: '',
};
@@ -24,7 +27,7 @@ const WIDTH_CLASSES: Record<ContainerWidth, string> = {
*/
export function PageContainer({ children, width = 'md', className = '' }: PageContainerProps) {
return (
<div className={`mx-auto space-y-6 ${WIDTH_CLASSES[width]} ${className}`}>
<div className={`mx-auto space-y-4 ${WIDTH_CLASSES[width]} ${className}`}>
{children}
</div>
);
+20 -20
View File
@@ -1,35 +1,35 @@
import type { LucideIcon } from 'lucide-react';
interface PageHeaderProps {
icon?: LucideIcon;
title: string;
/** Human-written line under the title (sans). Keep it short or leave it out. */
subtitle?: string;
/** Machine values — counts, sizes, durations. Rendered mono, per Ethos law 1. */
meta?: string;
/** Optional right-aligned actions (buttons, toggles, etc.). */
actions?: React.ReactNode;
}
/**
* Consistent page heading: a gradient title with an optional accent icon chip,
* subtitle, and right-aligned action slot. Used across the library pages so
* every screen opens the same way.
* The one page heading for every screen (Ethos law 3 — shared shell). Editorial
* sans title, an optional human subtitle, and a mono `meta` line for whatever
* the machine knows: counts, page position, queue depth.
*
* Deliberately has no icon chip and no gradient fill. The nav rail and the
* breadcrumb already name the page; a glowing accent tile on every screen made
* the accent read as decoration rather than signal.
*/
export function PageHeader({ icon: Icon, title, subtitle, actions }: PageHeaderProps) {
export function PageHeader({ title, subtitle, meta, actions }: PageHeaderProps) {
return (
<div className="flex items-end justify-between gap-4 animate-rise">
<div className="flex items-center gap-4 min-w-0">
{Icon && (
<div className="flex h-12 w-12 flex-none items-center justify-center rounded-2xl bg-accent/15 text-accent ring-1 ring-accent/25 shadow-lg shadow-accent/10">
<Icon size={24} />
</div>
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-border pb-3 animate-rise">
<div className="min-w-0">
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">
{title}
</h1>
{subtitle && <p className="mt-1 text-sm text-secondary">{subtitle}</p>}
{meta && (
<p className="mt-1 truncate font-mono text-xs tabular-nums text-muted">{meta}</p>
)}
<div className="min-w-0">
<h1 className="text-gradient truncate text-3xl font-extrabold tracking-tight sm:text-4xl">
{title}
</h1>
{subtitle && <p className="mt-1 truncate text-sm text-muted">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex flex-none items-center gap-2">{actions}</div>}
{actions && <div className="flex flex-none flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
+9 -4
View File
@@ -60,7 +60,12 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
</button>
</>
) : (
<div className="text-sm text-muted italic">Nothing playing</div>
<div className="flex items-center gap-2.5">
<div className="h-12 w-12 flex-none rounded-lg border border-border bg-bg2" aria-hidden />
{/* Reached only before anything has ever played in this browser —
a returning tab restores its last track instead. */}
<div className="text-sm text-muted">Pick a track to start</div>
</div>
)}
</div>
@@ -98,8 +103,8 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
</button>
</div>
<div className="flex w-full max-w-lg items-center gap-1.5 sm:gap-2">
<span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span>
<div className="flex w-full max-w-xl items-center gap-2">
<span className="w-10 flex-none text-right font-mono text-xs tabular-nums text-machine">{formatDuration(position)}</span>
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
@@ -108,7 +113,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
className="flex-1 h-1 cursor-pointer"
aria-label="Seek"
/>
<span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span>
<span className="w-10 flex-none font-mono text-xs tabular-nums text-machine">{formatDuration(duration)}</span>
</div>
</div>
+5 -2
View File
@@ -163,7 +163,10 @@ export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationO
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search…"
className="w-full bg-surface0/70 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface0 transition-all"
/* bg-bg2, not bg-surface0/70: Tailwind cannot alpha-modify these
var() colors, so that class emitted nothing and the field fell
back to the UA's white — a white pill in a warm dark room. */
className="w-full bg-bg2 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface1 transition-colors"
/>
{q ? (
<button
@@ -175,7 +178,7 @@ export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationO
<X size={12} />
</button>
) : (
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-bg2 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-surface1 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
/
</kbd>
)}
+41 -30
View File
@@ -1,4 +1,4 @@
import { Play, Pause, ThumbsDown, Disc3, Sparkles } from 'lucide-react';
import { Play, Pause, ThumbsDown, Sparkles } from 'lucide-react';
import { Link, useRouter } from '@tanstack/react-router';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
@@ -74,12 +74,19 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
return (
<div
className={`group flex w-full items-center gap-3 rounded-lg border transition-colors ${
compact ? 'p-2' : 'p-2.5'
// With the title linking to its album, a seed row would otherwise only be
// selectable by its 36px artwork tile. The whole row takes the click.
onClick={onSelect ? handlePlay : undefined}
role={onSelect ? 'button' : undefined}
className={`group relative flex w-full items-center gap-2.5 rounded-md px-1.5 transition-colors ${onSelect ? 'cursor-pointer' : ''} ${
compact ? 'h-11' : 'h-[52px]'
} ${
isCurrent
? 'border-accent/60 bg-accent/10'
: 'border-border/70 bg-surface0/50 hover:border-accent/30 hover:bg-surface1'
? "bg-accent/10 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-accent before:content-['']"
// `.track-row` carries the hover light (see index.css) — a gradient that
// falls off to the right instead of a flat slab. Display-only rows still
// light up; what they skip is the play-icon ramp below.
: 'track-row'
}`}
>
{/* Artwork + play overlay */}
@@ -89,27 +96,42 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
disabled={!playable}
aria-label={playLabel}
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 disabled:cursor-default disabled:opacity-70 ${
compact ? 'h-9 w-9' : 'h-10 w-10'
// 32px, written as an arbitrary value on purpose: the spacing scale is
// remapped, so `h-8` is 64px and overflowed this 44px row — that overflow
// was the "stacked" look, not a design choice.
compact ? 'h-[32px] w-[32px]' : 'h-9 w-9'
}`}>
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
{/* glyph={false}: the play/pause icon below is the only glyph this tile gets. */}
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} glyph={false} className="absolute inset-0 w-full h-full" />
{isCurrent && isPlaying ? (
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
) : (
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-70 transition-opacity group-hover:opacity-100" />
<Play
size={compact ? 14 : 18}
className={`absolute z-20 text-text opacity-70 transition-opacity ${playable ? 'group-hover:opacity-100' : ''}`}
/>
)}
</button>
{/* Title + artist */}
<div className="min-w-0 flex-1">
<button
type="button"
onClick={handlePlay}
disabled={!playable}
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
aria-label={playLabel}
>
{track.title || 'Untitled'}
</button>
{/* The title navigates to the album, matching the artist links beside it.
Playback lives on the artwork tile; a title that played was the surprise. */}
{track.album_id ? (
<Link
to="/albums/$albumId"
params={{ albumId: track.album_id }}
onClick={(e) => e.stopPropagation()}
title={track.title || 'Untitled'}
className={`block max-w-full truncate rounded text-left text-sm font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'}`}
>
{track.title || 'Untitled'}
</Link>
) : (
<span className={`block max-w-full truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-text'}`}>
{track.title || 'Untitled'}
</span>
)}
<ArtistLinks
artists={track.artists}
fallback={track.artist}
@@ -126,18 +148,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
<Sparkles size={16} />
</button>
)}
{track.album_id && (
<Link
to="/albums/$albumId"
params={{ albumId: track.album_id }}
onClick={(e) => e.stopPropagation()}
aria-label="Go to album"
title="Go to album"
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
>
<Disc3 size={16} />
</Link>
)}
{/* The album disc button is gone — the title itself is the album link now. */}
<button type="button" onClick={handleDislike} aria-label={`Dislike ${track.title || 'track'}`} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
<ThumbsDown size={16} />
</button>
@@ -146,7 +157,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
{/* Duration (hidden in compact) */}
{!compact && (
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
<div className="flex-none pr-1 font-mono text-xs tabular-nums text-machine">{formatDuration(track.duration)}</div>
)}
</div>
);
+5 -9
View File
@@ -22,7 +22,9 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
const energy = clamp(profile.energy, 0.5);
const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
const { energyLabel, discoveryLabel } = describeProfile(energy, novelty);
const hue = Math.round(205 + novelty * 115 - energy * 35);
// Warm range only (amber → gold → orange). The old 205 base put a teal-blue
// glow in a warm brown room, which read as a different app's accent.
const hue = Math.round(28 + novelty * 30 - energy * 12);
const style = {
'--vibe-hue': String(hue),
'--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
@@ -53,17 +55,11 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
if (ambient) {
return (
<div
className="group absolute left-1/2 top-[58%] z-20 h-16 w-16 -translate-x-1/2 -translate-y-1/2 cursor-help rounded-full focus:outline-none"
className="vibe-aura-blob pointer-events-none absolute left-1/2 top-[28%] z-0 h-[190px] w-[230px] -translate-x-1/2 -translate-y-1/2 opacity-40 mix-blend-screen sm:h-[400px] sm:w-[480px] sm:opacity-70"
style={style}
role="img"
tabIndex={0}
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
title={`Current Vibe: ${energyLabel} energy, ${discoveryLabel} discovery`}
>
<div className="pointer-events-none absolute inset-0 scale-[21.6] opacity-50 mix-blend-screen">
{aura}
</div>
</div>
/>
);
}
+21 -8
View File
@@ -2,6 +2,13 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
RouterProvider,
createMemoryHistory,
createRootRoute,
createRouter,
} from '@tanstack/react-router';
import type { ReactNode } from 'react';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { VibeTimeline } from './VibeTimeline';
@@ -11,6 +18,17 @@ const track = (id: string): Track => ({
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
});
/** Track titles link to their album, so rows need a router in scope. */
function renderInRouter(ui: ReactNode) {
const rootRoute = createRootRoute({ component: () => ui });
const router = createRouter({ routeTree: rootRoute, history: createMemoryHistory() });
return render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<RouterProvider router={router as never} />
</QueryClientProvider>
);
}
describe('VibeTimeline', () => {
beforeEach(() => {
const current = track('current');
@@ -22,16 +40,11 @@ describe('VibeTimeline', () => {
it('renders upcoming plan entries as display-only so they cannot hand queue ownership to ordinary playback', async () => {
const user = userEvent.setup();
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />
</QueryClientProvider>
);
renderInRouter(<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />);
const queued = screen.getAllByRole('button', { name: 'upcoming is queued by Vibe' });
expect(queued).toHaveLength(2);
const queued = await screen.findAllByRole('button', { name: 'upcoming is queued by Vibe' });
expect(queued).toHaveLength(1);
expect(queued[0]).toBeDisabled();
expect(queued[1]).toBeDisabled();
await user.click(queued[0]);
expect(usePlaybackStore.getState()).toMatchObject({
+8 -6
View File
@@ -11,10 +11,10 @@ interface VibeTimelineProps {
export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-text">
<Radio size={18} className="text-accent" />
<h2 className="text-lg font-semibold">Incoming recommendations</h2>
<span className="text-xs text-muted">({upcoming.length} buffered)</span>
<div className="flex items-center gap-2 border-b border-border pb-2">
<Radio size={14} className="text-accent" />
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Up next</h2>
<span className="ml-auto font-mono text-xs tabular-nums text-machine">{upcoming.length} buffered</span>
</div>
{currentTrack && (
@@ -26,7 +26,9 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
showActions={false}
variant="compact"
/>
<Badge color="accent" className="absolute right-2 top-1/2 -translate-y-1/2">Now playing</Badge>
{/* Hidden under 640px: the badge sat on top of the title. The accent
title and the accent edge already mark the current row. */}
<Badge color="accent" className="absolute right-2 top-1/2 hidden -translate-y-1/2 sm:block">Now playing</Badge>
</div>
)}
@@ -35,7 +37,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
No upcoming tracks buffered yet.
</div>
) : (
<div className="space-y-0.5">
<div className="track-list">
{upcoming.map((track, i) => (
<TrackRow
key={`${track.id}-${i}`}
+6 -6
View File
@@ -11,13 +11,13 @@ export function Skeleton({ className = '' }: { className?: string }) {
/** Rows matching TrackRow height */
export function SkeletonRows({ count = 5 }: { count?: number }) {
return (
<div className="space-y-1">
<div>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-md px-3 py-2">
<Skeleton className="h-8 w-8 flex-none rounded" />
<div key={i} className="flex h-[52px] items-center gap-2.5 rounded-md px-1.5">
<Skeleton className="h-9 w-9 flex-none rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-2.5 w-1/4" />
<Skeleton className="h-3 w-1/4" />
<Skeleton className="h-2.5 w-1/6" />
</div>
<Skeleton className="h-3 w-8 flex-none" />
</div>
@@ -29,7 +29,7 @@ export function SkeletonRows({ count = 5 }: { count?: number }) {
/** Grid matching album/artist card layout */
export function SkeletonGrid({ count = 10 }: { count?: number }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex flex-col gap-2 rounded-md border border-border bg-surface0 p-2">
<Skeleton className="aspect-square rounded" />