web-player: P10 — cabeceras, diálogos y tipografía

PageHeader fusiona CollectionHeader (title/eyebrow/description/meta/
cover?/actions?/align?): sin cover se comporta como el antiguo
PageHeader simple (acciones en línea), con cover como el antiguo
CollectionHeader (acciones debajo). Fija su propio margen inferior
(mb-7/8); se quitan los mt-* que ponía cada página después. Migra los
9 sitios que usaban uno u otro (álbumes, biblioteca, listas, subir,
Explorar ×2, me gusta, mezcla, perfil). CollectionHeader se borra.

AlbumHeader y PlaylistHeader se inlinean en sus páginas y se borran.
Decisión (recomendada, sin bloquear): PlaylistHeader gana iconos en
Editar/Eliminar/Reproducir, igual que ya tenía AlbumHeader.

ConfirmDialog nuevo sustituye los dos diálogos de borrado (álbum y
playlist), que tenían tipografías distintas. Modal pasa a usar
.glass-panel (en vez de duplicar el style= inline) y z-(--z-modal); su
eyebrow y título usan las recetas text-eyebrow/text-display-3.

SectionHeading gana `count?` (línea "N resultados", sustituye 4 copias
en Explorar) y `href?`/`linkLabel?` ("Ver todas", sustituye la de
Inicio). Aplica recetas de tipografía (text-display-2/3,
text-eyebrow, text-body) y tokens de ancho (max-w-prose-sm, max-w-hero)
en EmptyState, +error, "Sin resultados", hero y stats de Inicio, Queue
y Sidebar. El estado vacío "Todavía no has escuchado nada" (repetido
2 veces en Inicio) pasa a un snippet local.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 00:48:23 +02:00
co-authored by Claude Sonnet 5
parent 5dec00faf1
commit 2cf3e9f02d
24 changed files with 310 additions and 388 deletions
@@ -1,45 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
eyebrow: string;
title: string;
description?: string;
meta?: string;
align?: 'center' | 'end';
cover: Snippet;
actions?: Snippet;
}
let { eyebrow, title, description, meta, align = 'end', cover, actions }: Props = $props();
</script>
<div
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {align === 'end'
? 'mt-3 sm:mt-4 sm:items-end'
: 'sm:items-center'}"
>
{@render cover()}
<div class="min-w-0 flex-1">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">{eyebrow}</p>
<h1
class="{align === 'end'
? 'mt-1.5'
: 'mt-3'} font-display text-3xl font-medium tracking-tight wrap-break-word text-fg sm:text-4xl"
>
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if}
</div>
</div>
{#if actions}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Button from './Button.svelte';
interface Props {
open: boolean;
onClose: () => void;
title: string;
description: string;
confirmLabel: string;
action: string;
}
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script>
<Modal {open} {onClose} maxWidth="max-w-sm">
<h2 class="text-display-3 text-fg">{title}</h2>
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button>
</form>
</div>
</Modal>
@@ -15,10 +15,10 @@
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon}
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.25} />
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
{/if}
{#if title}
<h3 class="mt-6 font-display text-xl font-medium tracking-tight text-fg">
<h3 class="mt-6 text-display-3 text-fg">
{title}
</h3>
{/if}
@@ -70,14 +70,13 @@
{#if open}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 backdrop-blur-xl transition-[background] duration-500 outline-none sm:p-6 {panelClass}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -87,11 +86,11 @@
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-2.5 text-xs font-medium tracking-widest text-muted uppercase">
<p class="mb-2.5 text-eyebrow text-muted">
{eyebrow}
</p>
{/if}
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg">
<h2 class="text-display-3 text-fg">
{title}
</h2>
</div>
@@ -3,24 +3,59 @@
interface Props {
title: string;
subtitle?: string;
class?: string;
eyebrow?: string;
description?: string;
meta?: string;
cover?: Snippet;
actions?: Snippet;
align?: 'center' | 'end';
class?: string;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
let {
title,
eyebrow,
description,
meta,
cover,
actions,
align = 'end',
class: klass = ''
}: Props = $props();
</script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0">
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl">
{title}
</h1>
{#if subtitle}
<p class="mt-2 text-sm text-fg-2">{subtitle}</p>
<div class="mb-7 sm:mb-8 {klass}">
<div
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {cover
? align === 'end'
? 'sm:items-end'
: 'sm:items-center'
: 'sm:items-end sm:justify-between'}"
>
{#if cover}
{@render cover()}
{/if}
<div class="min-w-0 flex-1">
{#if eyebrow}
<p class="text-eyebrow text-fg-2">{eyebrow}</p>
{/if}
<h1 class="text-display-1 wrap-break-word text-fg {eyebrow ? 'mt-1.5' : ''}">
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if}
</div>
{#if actions && !cover}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{#if actions && cover}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
</div>
@@ -1,18 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { plural } from '$lib/format';
interface Props {
title: string;
subtitle?: string;
count?: number;
href?: string;
linkLabel?: string;
class?: string;
actions?: Snippet;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
let {
title,
subtitle,
count,
href,
linkLabel = 'Ver todas',
class: klass = '',
actions
}: Props = $props();
const hasActions = $derived(!!actions || count !== undefined || !!href);
</script>
{#snippet heading()}
<h2 class="font-display text-lg font-medium tracking-tight text-fg">
<h2 class="text-display-4 text-fg">
{title}
</h2>
{#if subtitle}
@@ -20,10 +34,31 @@
{/if}
{/snippet}
{#if actions}
{#snippet autoActions()}
{#if count !== undefined}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(count, 'resultado', 'resultados')}
</span>
{/if}
{#if href}
<a
{href}
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
>
{linkLabel}
</a>
{/if}
{/snippet}
{#if hasActions}
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
<div>{@render heading()}</div>
{@render actions()}
{#if actions}
{@render actions()}
{:else}
{@render autoActions()}
{/if}
</div>
{:else}
<div class="mb-4 sm:mb-4.5 {klass}">