feat(web-player): rebuild Inicio and Buscar to the design handoff

Applies the handoff's layout and image shapes while keeping the current
Musify design system (Inter, colors, tokens) — no font or palette swap.

Inicio now stacks distinct shelves instead of repeated square grids:
recently played rows, a rail of recently listened albums with an "Álbum"
badge, a "Hecho para ti" bento of mixes, a rail of playlists with a
trailing dashed "Ver todas" tile, tall "Canciones nuevas" posters with a
"Nuevo" badge, a rail of circular top-artist avatars, and a recommended
rail. Buscar splits into Álbumes (square cards + badge), Usuarios (a
circular-avatar directory) and a flat Canciones list.

Top artists and the search user directory are filler data until the
backend exposes them. New shared pieces: Rail, CoverBadge, ArtistAvatar,
MixBentoTile. The orphaned TrackTile and MixCard are removed.
This commit is contained in:
2026-07-22 04:40:14 +02:00
parent 105681e1db
commit 9132e67371
14 changed files with 612 additions and 391 deletions
@@ -0,0 +1,34 @@
<script lang="ts">
import { gradientForHue, hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
size?: number;
}
let { id, name, size = 108 }: Props = $props();
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
</script>
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:-translate-y-1 group-hover/artist:shadow-art-lg"
style="width:{size}px;height:{size}px;background:{gradientForHue(hueFor(id))}"
>
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
</div>
<div class="w-full truncate text-sm text-fg">{name}</div>
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
interface Props {
label: string;
class?: string;
}
let { label, class: klass = '' }: Props = $props();
</script>
<span
class="pointer-events-none absolute top-2 left-2 rounded-full bg-black/45 px-2 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-white uppercase backdrop-blur-sm {klass}"
>
{label}
</span>
@@ -0,0 +1,47 @@
<script lang="ts">
import MixArt from './MixArt.svelte';
import { hueFor } from '$lib/theme/color';
import type { Mix } from '$lib/mixes';
interface Props {
mix: Mix;
featured?: boolean;
banner?: boolean;
class?: string;
index?: number;
}
let { mix, featured = false, banner = false, class: klass = '', index = 0 }: Props = $props();
</script>
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms"
>
<MixArt
items={mix.items}
hue={hueFor(mix.id)}
class="absolute inset-0 h-full w-full transition duration-500 ease-out group-hover/tile:scale-[1.04]"
/>
<div
class="pointer-events-none absolute inset-0"
style="background:linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.32) 42%, rgba(0,0,0,0.05) 100%)"
></div>
<div
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
: ''}"
>
<div class="min-w-0">
<div
class="truncate font-semibold text-white {featured ? 'text-xl sm:text-2xl' : 'text-base'}"
>
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-0.5 truncate text-xs text-white/70 sm:text-sm">{mix.subtitle}</div>
{/if}
</div>
</div>
</a>
@@ -1,26 +0,0 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import MixArt from './MixArt.svelte';
import { hueFor } from '$lib/theme/color';
import type { Mix } from '$lib/mixes';
interface Props {
mix: Mix;
index?: number;
}
let { mix, index = 0 }: Props = $props();
const count = $derived(Number(mix.itemCount));
</script>
<MediaCard
href="/mixes/{mix.id}"
title={mix.title}
subtitle="{count} {count === 1 ? 'canción' : 'canciones'}"
{index}
>
{#snippet art(artClass)}
<MixArt items={mix.items} hue={hueFor(mix.id)} class={artClass} />
{/snippet}
</MediaCard>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<div class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
{@render children()}
</div>
<style>
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
</style>
@@ -12,11 +12,11 @@
{#if actions}
<div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass}">
<h2 class="text-lg font-semibold tracking-tight text-fg sm:text-xl">{title}</h2>
<h2 class="text-lg tracking-tight text-fg sm:text-[28px]">{title}</h2>
{@render actions()}
</div>
{:else}
<h2 class="mb-3 text-lg font-semibold tracking-tight text-fg sm:mb-4 sm:text-xl {klass}">
<h2 class="mb-3 text-lg tracking-tight text-fg sm:mb-4 sm:text-[28px] {klass}">
{title}
</h2>
{/if}