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
+16
View File
@@ -1,6 +1,22 @@
// Barrel re-export for the library-related services. The previous version of this
// file pointed at `/library/*` paths, but the backend registers library routes at
// the `/api` root (see backend/src/app.ts). Use the per-entity services instead.
import api from './api';
export interface LibraryStats {
tracks: number;
albums: number;
artists: number;
/** Total playtime in seconds. */
duration: number;
}
// GET /api/library/stats
export async function fetchLibraryStats(): Promise<LibraryStats> {
const res = await api.get<LibraryStats>('/library/stats');
return res.data;
}
export { trackService } from './trackService';
export { artistService } from './artistService';
export { albumService } from './albumService';
+10 -2
View File
@@ -49,6 +49,14 @@ export interface VibeEventResponse extends DurableVibeSessionResponse {
idempotent: boolean;
}
/** Coarse local calendar context, used only for short-lived Vibe preferences. */
export interface VibeCalendarContext {
localHour: number;
weekday: number;
month: number;
timeZone?: string;
}
/** A durable, idempotent advancement past a plan item the player cannot load. */
export interface VibeUnplayableItemInput {
eventId: string;
@@ -58,8 +66,8 @@ export interface VibeUnplayableItemInput {
}
export const vibeService = {
async start(seedTrackId?: string): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId });
async start(seedTrackId?: string, context?: VibeCalendarContext): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId, context });
return res.data;
},
+48 -13
View File
@@ -41,20 +41,37 @@ describe('durable Vibe session client', () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
await expect(startVibeSession(track('one'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
expect(start).toHaveBeenCalledWith('one', expect.objectContaining({
localHour: expect.any(Number), weekday: expect.any(Number), month: expect.any(Number),
}));
expect(next).toHaveBeenCalledWith('session-a', 1);
expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] });
expect(usePlaybackStore.getState().currentTrack).toEqual(track('one'));
});
it('drops a second recording of a song already in the queue', async () => {
// Same title, different track: a cover or another artist's version. One sitting
// should not play the same song twice.
getTrack.mockImplementation((id: string) =>
Promise.resolve({ ...track(id), title: id === 'cover' ? 'One' : track(id).title })
);
start.mockResolvedValue(response(1, item('one'), [item('cover'), item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('cover'), item('two')]));
await startVibeSession({ ...track('one'), title: 'one' });
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two']);
});
it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next
.mockResolvedValueOnce(response(1, item('one', true), [item('stale')]))
.mockResolvedValueOnce(response(2, item('two', true), [item('three')]));
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
await advanceVibe('skipped');
@@ -70,7 +87,7 @@ describe('durable Vibe session client', () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false });
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
await reportVibeEvent('kept', 'one');
@@ -90,7 +107,7 @@ describe('durable Vibe session client', () => {
event: {},
idempotent: true,
});
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
await reportVibeEvent('kept', 'one');
@@ -104,7 +121,7 @@ describe('durable Vibe session client', () => {
event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
}));
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');
@@ -115,7 +132,7 @@ describe('durable Vibe session client', () => {
it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
const ordinary = track('ordinary');
const playback = usePlaybackStore.getState();
@@ -132,7 +149,7 @@ describe('durable Vibe session client', () => {
it('serializes material events and ignores an older plan revision', async () => {
start.mockResolvedValue(response(1, item('one'), [item('old')]));
next.mockResolvedValue(response(1, item('one', true), [item('old')]));
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
@@ -156,7 +173,7 @@ describe('durable Vibe session client', () => {
event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
...response(1), event: {}, idempotent: true,
});
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
await reportVibeEvent('progress', 'one', 30000, 180000);
@@ -173,7 +190,7 @@ describe('durable Vibe session client', () => {
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
: Promise.resolve(track(id)));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
await expect(startVibeSession(track('good'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
expect(next).toHaveBeenCalledTimes(1);
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
@@ -193,7 +210,7 @@ describe('durable Vibe session client', () => {
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
: Promise.resolve(track(id)));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({
await expect(startVibeSession(track('good'))).resolves.toMatchObject({
status: 'complete', tracks: [track('good'), track('later')],
});
@@ -214,7 +231,7 @@ describe('durable Vibe session client', () => {
? Promise.resolve({ ...track(id), state: 'MISSING' })
: Promise.resolve(track(id)));
await startVibeSession(track('seed'));
await startVibeSession(track('good'));
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
expect(advancePastUnplayable.mock.calls[0][2].eventId)
@@ -225,7 +242,7 @@ describe('durable Vibe session client', () => {
start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)]));
next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)]));
advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)]));
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
await advancePastUnplayableVibeTrack('one');
@@ -237,11 +254,29 @@ describe('durable Vibe session client', () => {
expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 });
});
it('plays the seed first and steps off it without reporting plan feedback', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('seed'));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('seed');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['seed', 'one', 'two']);
await advanceVibe('completed');
// The seed is not a plan item: no feedback, no second serve, and the plan's
// own first item is what plays next.
expect(event).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
});
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
end.mockResolvedValue(response(1));
await startVibeSession(track('seed'));
await startVibeSession(track('one'));
await endVibeSession();
+85 -8
View File
@@ -5,6 +5,7 @@ import { useVibeStore } from '../store/useVibeStore';
import {
vibeService,
type DurableVibeSessionResponse,
type VibeCalendarContext,
type VibeEventType,
type VibePlanItem,
} from './vibeService';
@@ -18,6 +19,13 @@ export interface StartedVibeSession {
let startInFlight: Promise<StartedVibeSession> | null = null;
let advanceInFlight: Promise<void> | null = null;
let materialTail: Promise<void> = Promise.resolve();
// A seeded Vibe plays its seed first — asking for a vibe "from this track" and
// getting a different track is the surprise. The seed sits in front of the
// durable plan without being part of it, so the first advance must consume it
// locally instead of reporting feedback and serving the next item.
// ponytail: no 'completed' event is sent for the seed. The listener chose it
// explicitly; the director already has that signal from the session's seed id.
let seedPendingTrackId: string | null = null;
interface PendingEvent {
sessionId: string;
@@ -50,6 +58,23 @@ function newEventId(): string {
});
}
function localCalendarContext(): VibeCalendarContext {
const now = new Date();
let timeZone: string | undefined;
try {
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
// Some embedded players omit Intl time-zone support. The coarse calendar
// fields still provide useful, non-identifying context.
}
return {
localHour: now.getHours(),
weekday: now.getDay(),
month: now.getMonth() + 1,
timeZone,
};
}
function isPlayable(track: Track): boolean {
return !['HIDDEN', 'MISSING', 'DELETED'].includes(track.state);
}
@@ -83,6 +108,24 @@ async function hydratePreview(items: VibePlanItem[]): Promise<Track[]> {
});
}
/**
* Two recordings of one song — a cover, a remaster, another artist's version —
* are distinct track ids but read as a duplicate in one sitting. The title is the
* key; remixes and live cuts name themselves in the title, so they survive.
* ponytail: title string match, no normalisation beyond case and edges. Add
* feat./punctuation stripping only if real duplicates keep getting through.
*/
const songKey = (track: Track) => (track.title || track.id).trim().toLowerCase();
function dedupeSongs(tracks: Track[], seen = new Set<string>()): Track[] {
return tracks.filter((track) => {
const key = songKey(track);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Replace only the queue after the currently playing Vibe track. */
function replaceUnplayedQueue(preview: Track[]): void {
const playback = usePlaybackStore.getState();
@@ -95,9 +138,18 @@ function replaceUnplayedQueue(preview: Track[]): void {
const history = queueIndex >= 0
? playback.queue.slice(0, queueIndex + 1)
: current ? [current] : [];
const seen = new Set(history.map((track) => track.id));
const future = preview.filter((track) => !seen.has(track.id));
playback.setVibeQueue([...history, ...future]);
// While the seed plays, the durable cursor's own track sits between it and the
// preview. A `preview` list never contains that served item, so keep it — but
// only while it is still the cursor, never after it has been retired.
const next = playback.queue[queueIndex + 1];
const served = queueIndex >= 0
&& playback.queue[queueIndex]?.id === seedPendingTrackId
&& next
&& useVibeStore.getState().currentPlanItem?.track_id === next.id
? [next]
: [];
const future = dedupeSongs(preview, new Set([...history, ...served].map(songKey)));
playback.setVibeQueue([...history, ...served, ...future]);
}
function isCurrentVibeOwner(sessionId: string): boolean {
@@ -188,6 +240,7 @@ async function resolvePlayableResponse(
}
function deactivateBrokenSession(): void {
seedPendingTrackId = null;
const playback = usePlaybackStore.getState();
playback.setVibeAdvanceHandler(null);
useVibeStore.getState().reset();
@@ -307,6 +360,16 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
const current = usePlaybackStore.getState().currentTrack;
if (!vibe.activeSessionId || !current || !isCurrentVibeOwner(vibe.activeSessionId)) return;
// The seed is not a plan item — step off it without touching the cursor.
if (seedPendingTrackId && current.id === seedPendingTrackId) {
seedPendingTrackId = null;
usePlaybackStore.getState().advance();
// A dislike still has to reach the director; a completed seed carries no
// information the session's seed id does not already hold.
if (reason !== 'completed') void sendEvent(reason, current.id).catch(() => undefined);
return;
}
try {
const feedback = await sendEvent(reason, current.id);
if (!feedback?.planVersion) {
@@ -350,7 +413,15 @@ export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
const vibe = useVibeStore.getState();
const playback = usePlaybackStore.getState();
const currentItem = vibe.currentPlanItem;
if (!vibe.activeSessionId || !currentItem || currentItem.track_id !== trackId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
if (!vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
// The seed has no durable cursor to advance — a seed that will not stream is
// simply stepped over, leaving the plan's first item to play next.
if (seedPendingTrackId === trackId) {
seedPendingTrackId = null;
playback.advance();
return;
}
if (!currentItem || currentItem.track_id !== trackId) return;
try {
const advanced = await advanceResponsePastUnplayable(vibe.activeSessionId, {
@@ -393,7 +464,7 @@ function installVibeAdvanceHandler(): void {
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
if (startInFlight) return startInFlight;
startInFlight = serializeMaterial<StartedVibeSession>(async () => {
const started = await vibeService.start(seed.id);
const started = await vibeService.start(seed.id, localCalendarContext());
if (!started.planVersion) return { status: 'exhausted', tracks: [] };
const served = await serveNextPlayable(started.sessionId, started.planVersion);
if (!served) return { status: 'exhausted', tracks: [] };
@@ -411,10 +482,15 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
vibe.setInitialBatchStatus('idle');
const playback = usePlaybackStore.getState();
playback.setVibeQueue([served.now, ...served.preview]);
playback.playTrack(served.now);
const seedFirst = isPlayable(seed) && seed.id !== served.now.id;
seedPendingTrackId = seedFirst ? seed.id : null;
const queue = dedupeSongs(
seedFirst ? [seed, served.now, ...served.preview] : [served.now, ...served.preview]
);
playback.setVibeQueue(queue);
playback.playTrack(queue[0]);
installVibeAdvanceHandler();
return { status: 'complete', tracks: [served.now, ...served.preview] };
return { status: 'complete', tracks: queue };
});
try {
return await startInFlight;
@@ -429,6 +505,7 @@ export async function endVibeSession(): Promise<void> {
try {
if (sessionId) await vibeService.end(sessionId);
} finally {
seedPendingTrackId = null;
const playback = usePlaybackStore.getState();
playback.setVibeAdvanceHandler(null);
useVibeStore.getState().reset();