diff --git a/web/src/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte
new file mode 100644
index 00000000..0f03714a
--- /dev/null
+++ b/web/src/routes/albums/[id]/+page.svelte
@@ -0,0 +1,71 @@
+
+
+
+ {#if notFound}
+
+ {:else if query.isError}
+
+ {:else if showSkeleton.value && !query.data}
+
+ {:else if query.data}
+ {@const album = query.data}
+
+
+ {#if album.tracks.length === 0}
+
This album has no tracks.
+ {:else}
+
+ {#each album.tracks as track (track.id)}
+
+ {/each}
+
+ {/if}
+ {/if}
+
diff --git a/web/src/routes/albums/[id]/album.test.ts b/web/src/routes/albums/[id]/album.test.ts
new file mode 100644
index 00000000..2f4bbca3
--- /dev/null
+++ b/web/src/routes/albums/[id]/album.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import { flushSync } from 'svelte';
+import { mockQuery } from '../../../test-utils/query';
+import type { AlbumDetail, TrackRef } from '$lib/api/types';
+
+const state = vi.hoisted(() => ({ pageParams: { id: 'xyz' } as Record }));
+
+vi.mock('$app/state', () => ({
+ page: {
+ get params() { return state.pageParams; },
+ get url() { return new URL('http://localhost/albums/' + state.pageParams.id); }
+ }
+}));
+
+vi.mock('$lib/api/queries', () => ({
+ qk: { album: (id: string) => ['album', id] },
+ createAlbumQuery: vi.fn()
+}));
+
+import AlbumPage from './+page.svelte';
+import { createAlbumQuery } from '$lib/api/queries';
+
+function track(id: string, title: string, number: number, dur: number): TrackRef {
+ return {
+ id, title,
+ album_id: 'xyz', album_title: 'Kind of Blue',
+ artist_id: 'md', artist_name: 'Miles Davis',
+ track_number: number, disc_number: 1,
+ duration_sec: dur,
+ stream_url: `/api/tracks/${id}/stream`
+ };
+}
+
+afterEach(() => {
+ vi.clearAllMocks();
+ state.pageParams = { id: 'xyz' };
+});
+
+describe('album detail page', () => {
+ test('renders hero metadata + one TrackRow per track', () => {
+ const detail: AlbumDetail = {
+ id: 'xyz', title: 'Kind of Blue',
+ artist_id: 'md', artist_name: 'Miles Davis',
+ year: 1959, track_count: 2, duration_sec: 544 + 565,
+ cover_url: '/api/albums/xyz/cover',
+ tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)]
+ };
+ (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail }));
+ render(AlbumPage);
+
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kind of Blue');
+ expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md');
+ expect(screen.getByText(/1959/)).toBeInTheDocument();
+ expect(screen.getByText(/2 tracks/i)).toBeInTheDocument();
+
+ expect(screen.getByText('So What')).toBeInTheDocument();
+ expect(screen.getByText('Freddie Freeloader')).toBeInTheDocument();
+
+ const img = screen.getByRole('img') as HTMLImageElement;
+ expect(img.src).toContain('/api/albums/xyz/cover');
+ });
+
+ test('back link points to /artists/:artistId with the artist name', () => {
+ const detail: AlbumDetail = {
+ id: 'xyz', title: 'T',
+ artist_id: 'md', artist_name: 'Miles Davis',
+ track_count: 0, duration_sec: 0,
+ cover_url: '/api/albums/xyz/cover',
+ tracks: []
+ };
+ (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail }));
+ render(AlbumPage);
+ const back = screen.getByRole('link', { name: /Miles Davis/ });
+ expect(back).toHaveAttribute('href', '/artists/md');
+ });
+
+ test('404 renders non-retryable "Album not found" with back link', () => {
+ (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({
+ isError: true,
+ error: { code: 'not_found', message: 'nope', status: 404 }
+ }));
+ render(AlbumPage);
+ expect(screen.getByText(/album not found/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument();
+ });
+
+ test('non-404 error renders the retry banner', () => {
+ (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({
+ isError: true,
+ error: { code: 'server_error', message: 'boom', status: 500 }
+ }));
+ render(AlbumPage);
+ expect(screen.getByRole('alert')).toHaveTextContent('boom');
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
+ });
+
+ test('pending (after delay) renders the album skeleton', () => {
+ (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true }));
+ vi.useFakeTimers();
+ try {
+ render(AlbumPage);
+ vi.advanceTimersByTime(200);
+ flushSync();
+ expect(screen.getByTestId('skeleton-album')).toBeInTheDocument();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});