diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index 80c500e0..1ecdfb48 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -7,6 +7,11 @@
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
+ import PlaylistCard from '$lib/components/PlaylistCard.svelte';
+ import PlaylistPlaceholderCard from '$lib/components/PlaylistPlaceholderCard.svelte';
+ import { createPlaylistsQuery } from '$lib/api/playlists';
+ import { createSystemPlaylistsStatusQuery } from '$lib/api/me';
+ import type { Playlist } from '$lib/api/types';
const queryStore = createHomeQuery();
const query = $derived($queryStore);
@@ -21,11 +26,91 @@
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
+
+ const systemPlaylistsStore = $derived(createPlaylistsQuery('system'));
+ const userPlaylistsStore = $derived(createPlaylistsQuery('user'));
+ const systemStatusStore = $derived(createSystemPlaylistsStatusQuery());
+
+ const systemPlaylistsQ = $derived($systemPlaylistsStore);
+ const userPlaylistsQ = $derived($userPlaylistsStore);
+ const systemStatusQ = $derived($systemStatusStore);
+
+ const forYouPlaylist = $derived(
+ (systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'for_you') ?? null
+ );
+
+ const songsLikePlaylists = $derived(
+ (systemPlaylistsQ.data?.owned ?? [])
+ .filter((p) => p.system_variant === 'songs_like_artist')
+ .slice(0, 3)
+ );
+
+ const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
+
+ type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
+ function placeholderVariant(slot: 'for-you' | 'songs-like'): PlaceholderVariant {
+ const s = systemStatusQ.data;
+ if (!s) return 'pending';
+ if (s.in_flight) return 'building';
+ if (s.last_error) return 'failed';
+ if (slot === 'songs-like' && s.last_run_at) return 'seed-needed';
+ return 'pending';
+ }
+
+ type PlaylistRowItem =
+ | { kind: 'real'; playlist: Playlist }
+ | { kind: 'placeholder'; label: string; variant: PlaceholderVariant };
+
+ const playlistsRow = $derived.by((): PlaylistRowItem[] => {
+ const out: PlaylistRowItem[] = [];
+
+ // Slot 1: For-You (real or placeholder).
+ if (forYouPlaylist) {
+ out.push({ kind: 'real', playlist: forYouPlaylist });
+ } else {
+ out.push({ kind: 'placeholder', label: 'For You', variant: placeholderVariant('for-you') });
+ }
+
+ // Slots 2-4: Songs-like (real first, padded with placeholders).
+ for (let i = 0; i < 3; i++) {
+ if (songsLikePlaylists[i]) {
+ out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
+ } else {
+ out.push({ kind: 'placeholder', label: 'Songs like…', variant: placeholderVariant('songs-like') });
+ }
+ }
+
+ // User playlists trail (server returns most-recently-updated first).
+ for (const p of userPlaylists) {
+ out.push({ kind: 'real', playlist: p });
+ }
+
+ return out;
+ });
{pageTitle('Home')}
+
+
+
+ {#snippet item(rowItem: PlaylistRowItem)}
+
+ {#if rowItem.kind === 'real'}
+
+ {:else}
+
+ {/if}
+
+ {/snippet}
+
+
+
{#if query.isError}
{:else if showSkeleton.value && !data}
diff --git a/web/src/routes/page.test.ts b/web/src/routes/page.test.ts
new file mode 100644
index 00000000..b489a916
--- /dev/null
+++ b/web/src/routes/page.test.ts
@@ -0,0 +1,117 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import { readable } from 'svelte/store';
+import type { Playlist } from '$lib/api/types';
+
+// Mock all the queries the home page constructs.
+vi.mock('$lib/api/home', () => ({
+ createHomeQuery: () =>
+ readable({
+ data: {
+ recently_added_albums: [],
+ most_played_tracks: [],
+ rediscover_albums: [],
+ rediscover_artists: [],
+ last_played_artists: []
+ },
+ isPending: false,
+ isError: false,
+ refetch: vi.fn()
+ })
+}));
+
+vi.mock('$lib/api/playlists', () => ({
+ createPlaylistsQuery: vi.fn()
+}));
+
+vi.mock('$lib/api/me', () => ({
+ createSystemPlaylistsStatusQuery: vi.fn()
+}));
+
+// LikeButton imports — needed because the home renders cards that may use it.
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () =>
+ readable({ data: { track_ids: [], album_ids: [], artist_ids: [] }, isPending: false, isError: false }),
+ likeEntity: vi.fn().mockResolvedValue(undefined),
+ unlikeEntity: vi.fn().mockResolvedValue(undefined)
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record
;
+ return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
+});
+
+import Page from './+page.svelte';
+import { createPlaylistsQuery } from '$lib/api/playlists';
+import { createSystemPlaylistsStatusQuery } from '$lib/api/me';
+
+const emptyPlaylistsResponse = { owned: [], public: [] };
+
+beforeEach(() => {
+ (createPlaylistsQuery as unknown as ReturnType).mockImplementation(() =>
+ readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
+ );
+ (createSystemPlaylistsStatusQuery as unknown as ReturnType).mockReturnValue(
+ readable({
+ data: { in_flight: false, last_run_at: null, last_error: null },
+ isPending: false,
+ isError: false
+ })
+ );
+});
+
+afterEach(() => vi.clearAllMocks());
+
+describe('home Playlists section', () => {
+ test('renders 4 placeholder cards when no playlists exist', () => {
+ render(Page);
+ const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
+ expect(placeholders).toHaveLength(4);
+ });
+
+ test('building status sets variant=building on placeholders', () => {
+ (createSystemPlaylistsStatusQuery as unknown as ReturnType).mockReturnValue(
+ readable({
+ data: { in_flight: true, last_run_at: null, last_error: null },
+ isPending: false,
+ isError: false
+ })
+ );
+ const { container } = render(Page);
+ const buildingPlaceholders = container.querySelectorAll('[data-variant="building"]');
+ expect(buildingPlaceholders.length).toBeGreaterThan(0);
+ });
+
+ test('renders For-You playlist + 3 placeholders when only for_you exists', () => {
+ const forYou: Playlist = {
+ id: 'fy',
+ user_id: 'u1',
+ owner_username: 'u1',
+ name: 'For You',
+ description: '',
+ is_public: false,
+ kind: 'system',
+ system_variant: 'for_you',
+ seed_artist_id: null,
+ cover_url: '',
+ track_count: 25,
+ duration_sec: 1500,
+ created_at: '2026-05-04T00:00:00Z',
+ updated_at: '2026-05-04T00:00:00Z'
+ };
+ (createPlaylistsQuery as unknown as ReturnType).mockImplementation(
+ (kind?: string) =>
+ kind === 'system'
+ ? readable({
+ data: { owned: [forYou], public: [] },
+ isPending: false,
+ isError: false
+ })
+ : readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
+ );
+ render(Page);
+ expect(screen.getByText('For You')).toBeInTheDocument();
+ const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
+ expect(placeholders).toHaveLength(3); // 3 songs-like slots
+ });
+});