Files
Musify/web-player/docs/frontend-refactor-plan.md
T
Admin 58eb670fed web-player: unify Artwork and Avatar
Add src/lib/components/ui/Artwork.svelte and Avatar.svelte (B.3) and
migrate every use of Cover, PlaylistArt, MixArt and ArtistAvatar
(Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu,
TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader,
PlaylistHeader, Home, Explore, mix, user profile, TopBar,
MobileHeader). Remove the 4 old components.

Artwork: size decides both dimension and radius automatically
(cover-*/radius tokens from the earlier token pass); fallback chain
is src -> mosaic (>=4 trackIds) -> first cover -> placeholder,
cascading if each step fails (previously split across 3 components
with slightly different logic each). Adds a 'fill' size (aspect-square
w-full), not in the spec, needed for fluid grid cards until MediaCard
lands later.

TrackTitleCell's `coverSize` prop (free-form Tailwind classes)
becomes `size: 'sm'|'lg'`, delegating to Artwork. SearchResultRow
loses its `kind` prop and the per-type radius (now decided by
Artwork/Avatar).

Product decision (not blocking): remove the name under the profile
avatar on /u/[id] since it duplicated the CollectionHeader title.

Small radius/size deltas are documented in the plan's README
(auto-radius by size replaces ad-hoc classes; see table B.1.1/B.1.2).

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-20 22:39:05 +02:00

54 KiB
Raw Blame History

Plan — Refactor y unificación del frontend (web-player)

Plan de trabajo para agentes/IA con contexto limitado (~128k). Sustituye a design-system-refactor-plan.md (cuyo contenido ya está mayormente aplicado). Basado en una lectura completa de todos los .svelte, .css y .ts de cliente de web-player/src (2026-09-18, rama design-prot).

Cómo usar este documento

  1. Lee siempre la sección A (Contexto común) — es corta y todas las partes la asumen.
  2. Elige una parte (P0…P14). Cada parte indica: objetivo, prerrequisitos, archivos a leer, tareas, API exacta de lo que se crea, tabla de mapeo y criterios de cierre.
  3. Una parte = un commit/PR. No mezcles partes.
  4. Si una parte necesita algo de otra (p. ej. un token o un helper) y no existe todavía, créalo tú siguiendo la especificación de la sección B — están descritos con ruta y API exactas para que cualquier parte pueda crearlos de forma idéntica.
  5. Al terminar una parte, marca su casilla en la sección D (Registro).

Orden recomendado (las flechas son dependencias blandas, ver punto 4):

P0 → P1 → P2
        ↘ P3 (Artwork/Avatar) ─┐
        ↘ P4 (Botones)  ───────┤
        ↘ P5 (Helpers TS) ─────┼→ P6 (Menús) → P7 (Filas) → P8 (Tablas) → P9 (Tarjetas)
                               └→ P10 (Cabeceras) → P11 (Formularios) → P12 (Chrome) → P13 (Páginas) → P14 (Barrido final)

A. Contexto común (leer siempre)

A.1 Stack y reglas del proyecto (de web-player/CLAUDE.md)

  • SvelteKit 2 + Svelte 5 runes ($props() con interface Props, $state, $derived, $effect; nada de export let ni $:; {#each} siempre con clave).
  • Tailwind 4: no hay tailwind.config.js; la config vive en src/routes/layout.css (@theme, @utility). Tokens de color crudos en src/lib/theme/theme.css (--mf-*), tokens dinámicos por hue en src/lib/theme/tokens.ts (buildThemeTokens).
  • Sin comentarios en código salvo imprescindibles.
  • Cierre de cada parte: npm run check y npm run lint limpios (desde web-player/).
  • Iconos: @lucide/svelte/icons/<nombre>.

A.2 Gotchas conocidos (no reintroducirlos)

  1. Tokens animados por hue (--mf-bg, --mf-elevated, --mf-panel-bg, --mf-bar-bg, --mf-hairline, --mf-hero-bg, --mf-modal-glow, --mf-spotlight-bg, --mf-logo-grad, --mf-cover-grad, --mf-accent*, --mf-ambient) se aplican como estilo inline en <html> desde +layout.svelte. Un override en :root[data-theme='light'] no gana: la variante clara va en buildThemeTokens().
  2. Conflictos de clases Tailwind: nunca class="bg-a {cond ? 'bg-b' : ''}". Siempre mutuamente excluyente: class={cond ? 'bg-b' : 'bg-a'}. Overrides de la clase base de un componente compartido → prop/variante, no !important.
  3. Texto sin clase de tamaño hereda 16px del navegador. Todo texto visible debe tener una receta tipográfica (ver B.1.6).
  4. --mf-ink es "texto sobre CTA" y se invierte en claro; --mf-tile-shade, --mf-cover-grad, --mf-liked-grad son "arte" (no invertir).
  5. Referencia visual: Inicio (src/routes/+page.svelte). Un cambio de refactor no debe cambiar el aspecto salvo donde la parte lo diga explícitamente (columna "Δ").

A.3 Verificación de cada parte

cd web-player
npm run check
npm run lint

Visual (Browser pane, .claude/launch.json → musify-web; login de dev en la memoria del proyecto): abre cada página tocada y Inicio; prueba estado activo (una canción sonando), hover, móvil (375px) y modo claro (menú de cuenta → "Modo blanco").

A.4 Línea base medida (para saber si avanzas)

Métrica Hoy
Componentes .svelte (lib + rutas components/) 61
Líneas .svelte/.css/.ts cliente ~8.700
Clases arbitrarias x-[…] 44
Espaciados fraccionarios (h-3.75, gap-2.75…) ~190 apariciones, 60 valores distintos
Radios distintos usados 13 (6 tokens + 7 de Tailwind por defecto)
strokeWidth distintos 13 valores
Atributos style="…" 24
Pluralizaciones a mano === 1 ? 15
Errores de eslint 2 (liked/+page.svelte)

Objetivo global: ~61 → ~45 componentes, −1.500/−2.000 líneas, 0 arbitrarios fuera de una lista blanca, 1 escala por dimensión.


B. Especificaciones compartidas (fuente única)

Cualquier parte que necesite algo de aquí y no exista, lo crea exactamente así.

B.1 Tokens (en src/routes/layout.css, dentro de @theme)

Tailwind 4 genera utilidades a partir de estos namespaces: --radius-* → rounded-*, --spacing-* → size-* w-* h-* p-* m-* gap-*, --container-* → max-w-*, --tracking-* → tracking-*, --leading-* → leading-*.

B.1.1 Radios (--radius-*)

Token Valor Para qué
rounded-tag 0.25rem badges, celdas mini (ExplicitBadge, índice)
rounded-thumb 0.5rem carátulas ≤ 36px, logo
rounded-control 0.625rem inputs, filas, items de menú, botones
rounded-art 0.8125rem carátulas 42–88px, tarjetas de grid
rounded-art-lg 1.125rem carátulas ≥ 136px, dropzones
rounded-panel 1.125rem contenedores, menús, modales, tiles
rounded-panel-lg 1.375rem hero, spotlight, "mejor resultado"
rounded-full (Tailwind) pastillas, avatares, botones redondos

Eliminar --radius-art-round (0 usos). Mapeo de lo existente:

Hoy px Usos Pasa a Δ
rounded-sm, rounded 4 2 rounded-tag 0
rounded-md 6 1 rounded-thumb +2px
rounded-lg 8 4 rounded-thumb 0
rounded-xl 12 6 rounded-control −2px
rounded-2xl 16 8 rounded-art-lg (arte) / rounded-panel (contenedor) +2px
rounded-3xl 24 1 rounded-panel-lg −2px

B.1.2 Tamaños de carátula/arte (--spacing-cover-*)

Token Valor Usos actuales que absorbe (Δ)
cover-xs 2.25rem (36) thumbs de menús h-9; sidebar h-8.5 (+2)
cover-sm 2.625rem (42) filas de tabla h-10.5; cola y "Populares" h-10 (+2); player móvil h-11 (−2)
cover-md 3rem (48) player escritorio md:h-12
cover-lg 3.5rem (56) "Continuar" h-14; filas de búsqueda h-13 (+4); tarjeta playlist de Inicio h-15.5 (−6)
cover-xl 5.5rem (88) "Mejor resultado" h-22
cover-2xl 8.5rem (136) spotlight de Inicio h-34
cover-hero 9rem (144) cabeceras de colección, dropzones, avatar de perfil
cover-hero-sm 10.25rem (164) cabeceras de colección a partir de sm:

Uso: size-cover-sm (Tailwind 4 size-* = width+height). Nunca h-X w-X en pareja: siempre size-*.

B.1.3 Iconos (--spacing-icon-*) y trazo

Token Valor Absorbe
icon-xs 0.875rem h-3.5 (14)
icon-sm 1rem h-3.75 (15), h-4 (16), h-4.25 (17)
icon-md 1.125rem h-4.5 (18)
icon-lg 1.25rem h-5 (20), h-6 (24, login móvil)
icon-xl 2.25rem h-9/h-10 (estados vacíos, dropzone, Me gusta)

Trazo: en layout.css, fuera de @theme:

@layer base {
	svg.lucide {
		stroke-width: 1.8;
	}
}
@utility stroke-thin { stroke-width: 1.3; }
@utility stroke-regular { stroke-width: 1.8; }
@utility stroke-bold { stroke-width: 2.5; }

La propiedad CSS stroke-width gana al atributo de presentación que pone Lucide, así que el prop strokeWidth deja de tener efecto: se elimina de todos los usos. Mapeo: 1–1.4 → stroke-thin, 1.5–2 → (nada, default), 2.5–3 → stroke-bold, strokeWidth={0} + fill → clase stroke-0. Verifica en el navegador (DevTools → Computed stroke-width) que el default aplica antes de migrar en masa.

B.1.4 Anchos (--container-*) y layout

Token Valor Absorbe
max-w-prose-sm 26rem max-w-105 (error), max-w-108 (hero p), max-w-110, max-w-115 (sin resultados)
max-w-hero 34rem max-w-135 (hero, seekbar)
max-w-search 30rem max-w-120 (buscador TopBar)
max-w-cta 20rem max-w-80 (botones login)

Ya existen y se mantienen en theme.css: --mf-sidebar-w, --mf-queue-w, --mf-nav-h, --mf-player-h, --mf-safe-b. Añadir:

--mf-player-side-w: 16rem;   /* w-64 de PlayerBar/PlayerExtras */
--mf-menu-w: 15rem;          /* w-60 de menús contextuales (= MENU_WIDTH 240) */
--mf-submenu-w: 16rem;       /* w-64 (= SUBMENU_WIDTH 256) */
--mf-range-h: 3.5px;         /* barra de progreso y volumen */
--mf-range-thumb: 11px;
--mf-volume-w: 84px;

Las constantes TS MENU_WIDTH/SUBMENU_WIDTH (duplicadas hoy en TrackContextMenu y AlbumContextMenu) pasan a src/lib/config.ts con un comentario-no: el nombre basta.

B.1.5 Capas (z-index)

En theme.css :root: --z-raised: 10; --z-sticky: 20; --z-backdrop: 30; --z-menu: 40; --z-submenu: 50; --z-modal: 60; --z-grain: 80;. Uso: z-(--z-menu). Mapeo 1:1 con los valores actuales (z-10,z-20,z-30,z-40,z-50,z-[60], body::after 80). Nota: MobileHeader y PlayerDock usan z-40 pero son "sticky", no menú → --z-sticky (verificar que el menú de cuenta sigue encima del header: menú 40 > sticky 20).

B.1.6 Tipografía (recetas @utility en layout.css)

Escala tipográfica = la de Tailwind (text-xs…text-4xl), sin tamaños arbitrarios. Añadir en @theme: --tracking-display: -0.02em; --tracking-eyebrow: 0.1em; --leading-body: 1.65;. Recetas (el color no va en la receta, se añade aparte):

@utility text-display-1 { @apply font-display text-3xl font-medium tracking-tight sm:text-4xl; }
@utility text-display-2 { @apply font-display text-2xl font-medium tracking-tight; }
@utility text-display-3 { @apply font-display text-xl font-medium tracking-display; }
@utility text-display-4 { @apply font-display text-lg font-medium tracking-tight; }
@utility text-eyebrow   { @apply text-xs font-medium tracking-widest uppercase; }
@utility text-body      { @apply text-sm leading-body; }
@utility text-count     { @apply text-xs tabular-nums; }

Si @apply con variante sm: dentro de @utility no compila, usa @media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--text-4xl--line-height); } como hace page-x.

Mapeo de lo existente:

Patrón hoy Receta
font-display text-3xl … sm:text-4xl (PageHeader, CollectionHeader, hero Inicio, h1 búsqueda text-2xl sm:text-3xl font-semibold tracking-[-0.03em]) text-display-1 (Δ búsqueda: +1 paso)
font-display text-2xl (stats Inicio, título spotlight, "¡Subida!") text-display-2
font-display text-xl … tracking-[-0.02em] (Modal, EmptyState, +error, "Sin resultados", diálogos de borrado) text-display-3 (todos font-medium)
font-display text-lg (SectionHeading, "Mejor resultado", Queue "En cola" text-base) text-display-4
text-xs font-medium tracking-widest uppercase (+ variantes tracking-[0.14em], [0.16em], tracking-wider, text-sm font-semibold) text-eyebrow
text-sm leading-[1.65] text-fg-2 text-body text-fg-2
text-xs text-muted tabular-nums text-count text-muted
tracking-[-0.01em] (MixTile) quitar

B.1.7 Recetas de superficie y movimiento (@utility en layout.css)

@utility theme-transition { transition: background 0.5s ease-out; }        /* 8× transition-[background] duration-500 */
@utility glass-bar {                                                        /* PlayerBar ×2, TabsBar */
	background-image: var(--mf-bar-bg);
	border-top: 1px solid var(--mf-hairline);
	backdrop-filter: blur(24px);
	transition: background 0.5s ease-out;
}
@utility grid-cards {                                                       /* MediaGrid por defecto: 150/165 */
	display: grid;
	gap: --spacing(3);
	grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
	@media (width >= 40rem) {
		gap: --spacing(4);
		grid-template-columns: repeat(auto-fill, minmax(10.3125rem, 1fr));
	}
}
@utility grid-cards-lg {                                                    /* /playlists y /u/[id]: 180/210 */
	display: grid;
	gap: --spacing(3);
	grid-template-columns: repeat(auto-fill, minmax(11.25rem, 1fr));
	@media (width >= 40rem) {
		gap: --spacing(4);
		grid-template-columns: repeat(auto-fill, minmax(13.125rem, 1fr));
	}
}
@utility grid-tiles {                                                       /* mixes de Inicio: 164 */
	display: grid;
	gap: --spacing(5);
	grid-template-columns: repeat(auto-fill, minmax(10.25rem, 1fr));
}
@utility grid-wide {                                                        /* playlists de Inicio (258) y géneros (268) → 16.5rem, Δ ±6px */
	display: grid;
	gap: --spacing(3.5);
	grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
}

.glass-panel ya existe; Modal debe usarla en lugar de su style= inline duplicado.

Stagger (sustituye los 5 style="animation-delay:…" con 40 o 45ms):

.animate-enter { animation-delay: calc(min(var(--i, 0), 10) * 45ms); }

Uso: style="--i:{i}". Único style= permitido junto con los de B.4.

B.1.8 Colores: limpieza

  • Borrar utilidades sin uso: --color-accent-dim, --color-accent-glow, --color-accent-hair, --color-accent-line, --color-accent-muted (0 usos como clase; --mf-accent-dim sí se usa en ::selection, se mantiene la variable --mf-).
  • --color-on-accent es alias de --mf-on-art (igual que --color-on-art) → borrar y usar on-art. --color-hover = --mf-surface-2 (igual que surface-2) → mantener solo hover como semántico de hover y dejar surface-2 para fondos en reposo.
  • --mf-sidebar-bg no lo usa ningún componente (Sidebar usa bg-bg) → borrar de theme.css y de buildThemeTokens.
  • Botón: --mf-btn-bg = --mf-surface-2; --mf-btn-secondary-bg solo difiere en modo claro. Exponer en @theme --color-btn, --color-btn-hover, --color-btn-glass, --color-btn-glass-hover y eliminar los bg-[var(--mf-btn-…)] de Button.svelte.
  • Scrollbar: rgba(255,255,255,.18/.32) fijo en layout.css (roto en claro) → tokens --mf-scrollbar, --mf-scrollbar-hover con variante en :root[data-theme='light'].
  • MixTile: to-black/42, bg-ink/76, border-on-art/12 → to-scrim/40, receta de PlayButton (B.3).

B.2 Helpers TypeScript (fuente única)

Archivo (nuevo) API Sustituye
src/lib/actions/pressable.ts pressable(node, fn) → añade role="button", tabindex=0, click + Enter/Espacio 7 copias de onkeydown Enter/Space (Inicio ×5, Queue, TrackTitleCell)
src/lib/actions/clickOutside.ts clickOutside(node, fn) onDocumentClick de AccountMenu
src/lib/toggle.svelte.ts createToggle(initial=false) → { get open, show(), close(), toggle() } playlists.svelte.ts y player/queuePanel.svelte.ts (idénticos)
src/lib/format.ts (ampliar) plural(n, one, many) → "1 canción"; fmtCount(n,'canción','canciones'); minúscula siempre 15 pluralizaciones a mano, con mayúsculas inconsistentes ("Canción"/"canción")
src/lib/navigation.svelte.ts (ampliar) searchHref(q); appNavLinks(user, counts?) → {href,label,icon,count?,primary:boolean}[] buildHref en TopBar y explore; listas de links de Sidebar y TabsBar
src/lib/player/player.svelte.ts (ampliar) isQueueCurrent(items), playAllOrToggle(items), playShuffled(items); renombrar addToQueue → playNextItem playAll/isCurrentQueue en álbum, playlist, liked, mixes, spotlight de Inicio (5 copias)
src/lib/player/player.svelte.ts toQueueItem(track) único (normaliza duration/listensCount a number) mapeos a mano en recentlyPlayed.ts (×2), liked.svelte.ts (×2), liked/+page, tracks.ts#targetForQueueItem y los Number(…) dispersos
src/lib/menus.svelte.ts createTrackMenu() → { state, open(e, track), close(), playNext() } estado + handler de menú contextual en Inicio, Explorar, Mezcla

B.3 Componentes nuevos (API)

Componente ($lib/components/ui/) Props Sustituye / absorbe
Artwork.svelte src?, trackIds?: (string|number)[], size: 'xs'|'sm'|'md'|'lg'|'xl'|'2xl'|'hero', shape?: 'auto'|'round', imageSize?: 'small'|'medium'|'large' (auto por size), fallback?: 'music'|'gradient'|'none', class?, children? (overlay) Cover, PlaylistArt, MixArt; radio automático por tamaño (xs→thumb, sm–xl→art/control, 2xl/hero→art-lg). Prioridad: src → mosaico si ≥4 trackIds → primera carátula → fallback. Un único onerror.
Avatar.svelte name, src?, size: 'sm'|'md'|'lg'|'hero', class? iniciales/imagen en TopBar, MobileHeader, Explorar (×2), ArtistAvatar
ListRow.svelte href? | onclick?, oncontextmenu?, active?, size?: 'sm'|'md'|'lg', title, subtitle?, subtitleHref?, explicit?, meta?, leading?: Snippet, art: Snippet<[string]> | artwork props, trailing?: Snippet SearchResultRow + 7 filas a mano (ver P7)
MediaIdentity.svelte title, subtitle?, subtitleHref?, explicit?, active?, art?: Snippet | artwork props, titleClass? TrackTitleCell (renombrado + generalizado) y el bloque de player/TrackInfo
TrackList.svelte tracks: TrackLike[], columns?: ('album'|'added'|'uploaded'|'plays')[], index?, onPlay(i), rowAction?: {action, icon, label} | rowActionSnippet?, oncontextmenu?(e, track) TrackTable+TrackRow+TrackIndexCell+TrackMeta y las 6 tablas (P8)
ContextMenu.svelte x, y, openLeft, onClose, items: {icon,label,onclick}[], playlistAction?: {action: string, fields: Record<string,string>, label}, playlists TrackContextMenu + AlbumContextMenu (90% idénticos)
PlayButton.svelte playing?, size: 'sm'|'md'|'lg', variant: 'strong'|'glass', label, onclick?, as?: 'button'|'span' play de TransportControls, "Mejor resultado", overlay de MixTile
ConfirmDialog.svelte open, onClose, title, description, confirmLabel, action (form POST) 2 modales de borrado con estilos distintos
Slider.svelte value, max, label, oninput, variant: 'seek'|'volume' SeekBar (click-only) + VolumeControl (<style> propio)
Logo.svelte size: 'sm'|'lg' logo en Sidebar (sin marca), MobileHeader, login
Chip.svelte selected?, count?, href?|onclick? chips de filtro y de géneros sugeridos en Explorar

B.4 Lista blanca de style= y arbitrarios (tras el refactor)

Permitidos: style="--i:{i}" (stagger), gradientes de tile por hue en Explorar (--tile-hue), anchos de progreso (width:{pct}%), posición de menú (left/top). Arbitrarios permitidos: text-[clamp(…)] de login, grid-cols-[auto_1fr], max-h-[85dvh]. Todo lo demás, a token.


C. Partes

P0 — Línea base y bugs sueltos (independiente, ~30 min)

Objetivo: dejar lint en verde y corregir bugs detectados que no requieren refactor.

Tareas:

  1. src/routes/(app)/liked/+page.svelte: quitar imports Play y Pause (errores eslint).
  2. src/lib/components/ui/MediaCard.svelte: el título no tiene tamaño (hereda 16px) → text-sm (igual que MixTile).
  3. src/routes/+page.svelte, filas del spotlight: hover:bg-hover … {cond ? 'bg-accent-tint' : ''} → excluyente ({cond ? 'bg-accent-tint' : 'hover:bg-hover'}), igual que las otras filas.
  4. src/routes/(app)/albums/[id]/+page.svelte modal de borrado: text-base text-fg-3 → text-sm leading-[1.65] text-fg-2 y título igual al de playlists (se unificará en P10).
  5. Sidebar.svelte (botón +) y Queue.svelte (cerrar): quitar text-base inútil.
  6. Borrar src/lib/components/ui/Badge.svelte (0 importadores).
  7. Button.svelte: primary y secondary son la misma clase. No cambiar aspecto; solo dejar const variants = { primary: subtle, secondary: subtle, … } con un único subtle y anotar en D que P4 decide si primary debe diferenciarse (decisión de producto, preguntar al usuario).

Cierre: npm run lint 0 errores.


P1 — Tokens base (prerrequisito de P3–P14)

Objetivo: crear todo B.1 de forma aditiva (sin migrar componentes todavía, salvo lo indicado). Tras esta parte, nada cambia visualmente.

Leer: src/routes/layout.css, src/lib/theme/theme.css, src/lib/theme/tokens.ts.

Tareas:

  1. Añadir en @theme: radios B.1.1 (y borrar --radius-art-round), --spacing-cover-* B.1.2, --spacing-icon-* B.1.3, --container-* B.1.4, --tracking-*/--leading-* B.1.6, --color-btn* B.1.8.
  2. Añadir en theme.css :root: variables de layout B.1.4 y z-index B.1.5.
  3. Añadir @utility de B.1.3 (stroke), B.1.6 (tipografía), B.1.7 (superficies, grids); stagger en .animate-enter.
  4. Scrollbar a tokens con variante clara (B.1.8).
  5. Borrar utilidades de color sin uso y --mf-sidebar-bg (B.1.8). Antes de borrar cada una: grep -rn "<nombre>" src = 0.
  6. Crear una página de prueba temporal no commiteada o usar DevTools para comprobar que size-cover-sm, rounded-thumb, max-w-hero, text-display-1, z-(--z-menu) y stroke-thin generan CSS (Tailwind 4 solo genera lo que ve usado: prueba con una clase en un componente y revierte).

Cierre: check+lint; captura de Inicio antes/después idéntica.


P2 — Tema: una sola fuente de verdad + sin parpadeo en modo claro

Objetivo: eliminar la duplicación theme.css ↔ tokens.ts y el flash oscuro al cargar con modo claro guardado.

Hallazgos:

  • Los valores por defecto de los ~15 tokens dinámicos están escritos dos veces: en theme.css (:root, hue 145) y en buildThemeTokens(). El bloque claro de theme.css repite --mf-bg, --mf-elevated, --mf-panel-bg, --mf-bar-bg, --mf-hairline, --mf-hero-bg, --mf-spotlight-bg, --mf-modal-glow, que luego tokens.ts pisa inline.
  • mode.svelte.ts aplica data-theme solo tras hidratar → flash oscuro.

Tareas:

  1. src/app.html: script inline en <head> que lea localStorage['musify.theme'] y ponga document.documentElement.dataset.theme antes del primer paint (try/catch).
  2. +layout.svelte: en <svelte:head>, emitir <style>:root{…buildThemeTokens(ACCENT_HUE,'dark')}:root[data-theme=light]{…buildThemeTokens(ACCENT_HUE,'light')}</style> (serializado con un helper tokensToCss(tokens) en tokens.ts).
  3. Borrar de theme.css todos los tokens que genera buildThemeTokens (dark y light). theme.css queda solo con neutrales fijos, superficies, texto, layout y z-index.
  4. +layout.svelte: los dos div de fondo con style= → @utility app-backdrop / app-vignette en layout.css. main con padding-bottom: calc(…) inline → clase con --mf-dock-h que se activa con data-has-track en el propio main.

Cierre: con modo claro guardado, recarga dura sin flash; cambio de pista sigue animando el hue; check+lint.


P3 — Artwork y Avatar (arte unificado)

Objetivo: un único componente de carátula y uno de avatar.

Hallazgos:

  • Cover, PlaylistArt y MixArt implementan la misma cascada (imagen → mosaico 2×2 → primera carátula → placeholder). MixArt ≡ PlaylistArt con trackIds = items.map(i => i.trackId).
  • AlbumCard pasa el id del álbum como trackId a Cover solo para poder usar src (hack).
  • Avatar "imagen o inicial" reimplementado en TopBar, MobileHeader, Explorar (mejor resultado + fila de usuario) y ArtistAvatar (con rayado).
  • Cada uso decide su propio radio (rounded-lg, rounded-xl, rounded-2xl, rounded-control, rounded-art-lg…) y su tamaño (h-8.5, h-9, h-10, h-10.5, h-11 md:h-12, h-13, h-14, h-15.5, h-22, h-34, h-36 sm:h-41).

Tareas:

  1. Crear Artwork.svelte y Avatar.svelte según B.3 usando tokens B.1.1/B.1.2. size decide dimensión y radio; class solo para layout (shrink-0, márgenes).
  2. Migrar todos los usos (buscar Cover, PlaylistArt, MixArt, ArtistAvatar, user.picture, profilePictureUrl) aplicando la tabla B.1.2.
  3. ArtistAvatar → en u/[id]/+page.svelte usar <Avatar size="hero"> (el nombre ya lo muestra la cabecera; el label bajo el avatar era redundante — Δ visible, confirmar).
  4. Borrar Cover.svelte, PlaylistArt.svelte, MixArt.svelte, ArtistAvatar.svelte.

Cierre: grep -rn "Cover\b\|PlaylistArt\|MixArt\|ArtistAvatar" src solo en Artwork; visual en Inicio, Explorar (búsqueda), Sidebar, Cola, player, cabeceras.


P4 — Botones: Button, IconButton, PlayButton

Objetivo: que no quede ningún <button> estilado a mano.

Hallazgos (botones hechos a mano): TopBar atrás/adelante (círculos h-7.5), limpiar búsqueda, login (bg-accent-soft rounded-full), MobileHeader login, Sidebar + ×2, Cola cerrar/"Vaciar", PlayerExtras cola, TransportControls (shuffle, prev, play, next, repeat), TrackInfo corazón, "Mejor resultado" (círculo h-11.5), MixTile overlay (h-9.5), BackLink. Overrides con !: Inicio !bg-cta-strong !text-ink hover:!brightness-95 y !font-normal.

Tareas:

  1. Button.svelte: variantes subtle (hoy primary/secondary), glass (hoy secondaryOnGlow), accent, strong (cta-strong/ink — sustituye los !), danger. Mantener alias primary/secondary solo si P0 no obtuvo decisión. Radio → rounded-control. Quitar necesidad de !font-normal (peso normal en glass, o prop weight).
  2. IconButton.svelte: añadir shape: 'square'|'round', size: 'xs'|'sm'|'md'|'lg', tone: 'muted'|'subtle'|'plain', pressed?: boolean (→ aria-pressed + text-accent), href?, surface?: boolean (fondo surface-2). Iconos con size-icon-*.
  3. Crear PlayButton.svelte (B.3); iconos play/pausa SVG de TransportControls pasan a él (un único sitio para esos <svg>).
  4. Migrar todos los botones listados. TopBar "adelante" es un <span> decorativo deshabilitado y PlayerExtras tiene un icono AlignJustify decorativo sin función: preguntar si se eliminan (código muerto visual) o se mantienen por fidelidad al prototipo.

Cierre: grep -rn "<button" src | grep -v "ui/\(Button\|IconButton\|PlayButton\|Chip\|ListRow\)" → solo casos justificados (dropzones, selector segmentado).


P5 — Helpers TypeScript

Objetivo: crear B.2 y migrar sus usos (lógica, no estilos).

Tareas:

  1. Crear actions/pressable.ts, actions/clickOutside.ts, toggle.svelte.ts. createPlaylistModal y queuePanel → createToggle(); borrar los dos archivos viejos o reducirlos a export const queuePanel = createToggle().
  2. format.ts: plural(); migrar las 15 pluralizaciones (grep -rn "=== 1 ?" src). Unificar a minúscula ("3 canciones") — Δ visible en Playlists/Inicio/liked.
  3. player.svelte.ts: toQueueItem, isQueueCurrent, playAllOrToggle, playShuffled; renombrar addToQueue → playNextItem (el nombre actual confunde: inserta a continuación, no al final). Migrar álbum, playlist, liked, mixes, Inicio.
  4. recentlyPlayed.ts y liked.svelte.ts: usar toQueueItem.
  5. tracks.ts: el envoltorio TrackTarget = { track } + 8 accesores (targetTitle, targetArtist, …) no aporta nada. Sustituir por el ApiTrackLike directo y borrar accesores; dejar solo isCurrent(track) si hace falta. (Toca Inicio, Explorar, Mezcla, menús: hacerlo en esta parte para no dejar mezcla de estilos.)
  6. navigation.svelte.ts: searchHref, appNavLinks.
  7. menus.svelte.ts: createTrackMenu(); migrar Inicio, Explorar, Mezcla.

Cierre: check+lint; reproducir desde álbum, playlist, liked, mezcla, spotlight; menú contextual "Reproducir a continuación" sigue funcionando.


P6 — Menús: ContextMenu + AccountMenu + GlassMenu

Objetivo: un solo menú contextual.

Hallazgos: TrackContextMenu (109 líneas) y AlbumContextMenu (119) son copias: misma función de posicionamiento con las mismas constantes, mismo backdrop, mismo Escape, mismo submenú "Añadir a playlist" con <form use:enhance>; solo cambian los items y los campos ocultos. Sus items reimplementan a mano las clases de MenuItem (con strokeWidth={2} en vez de 1.8). El item de playlist reimplementa ListRow.

Tareas:

  1. Crear ContextMenu.svelte (B.3) usando GlassMenu + MenuItem + Artwork size="xs" y anchos --mf-menu-w/--mf-submenu-w; contextMenuPosition(event) exportada del módulo usando MENU_WIDTH/SUBMENU_WIDTH de config.ts.
  2. Sustituir usos en Inicio, Explorar (pista y álbum), Mezcla. Borrar los dos menús viejos.
  3. AccountMenu: usar clickOutside; eliminar el div.relative duplicado que TopBar/MobileHeader ponen alrededor; panelClass → prop width: 'sm'|'md'.
  4. Añadir Escape a AccountMenu (hoy no cierra con Escape — mejora a11y).

Cierre: menú de pista y de álbum en Explorar, submenú abre a izquierda cerca del borde, móvil (submenú debajo), Escape cierra.


P7 — Filas: ListRow + MediaIdentity + EqBars

Objetivo: una sola fila compacta para todas las listas que no son tabla.

Filas reimplementadas hoy (todas: arte + título + subtítulo + meta + estado activo):

Dónde Tamaño
SearchResultRow (Explorar ×4 grupos) lg
Inicio "Continuar escuchando" (tarjeta) lg, card
Inicio "Populares" (índice/EQ + meta) sm
Inicio spotlight (índice, sin arte) sm
Inicio "Tus playlists" (tarjeta) lg, card
Queue "Reproduciendo" + "A continuación" sm
Sidebar playlists xs
items de playlist de ContextMenu xs

Tareas:

  1. Renombrar/generalizar TrackTitleCell → MediaIdentity (B.3). Usar en player/TrackInfo (sustituye su bloque cover+título+artista).
  2. Crear ListRow (B.3) sobre MediaIdentity + pressable; variante card = padding p-2.25 pr-3.5 rounded-art (Continuar/Tus playlists).
  3. NowPlaying ≡ EqBars con overlay: añadir a EqBars prop overlay (fondo scrim/45, color on-art) y borrar NowPlaying.
  4. Migrar todas las filas de la tabla. Borrar SearchResultRow.

Cierre: Inicio, Explorar, Cola, Sidebar sin cambios visibles salvo Δ de B.1.2; teclado (Tab + Enter) reproduce en todas las filas.


P8 — Tablas de pistas: TrackList

Objetivo: tablas declarativas; borrar 3 celdas y 3 componentes de ruta.

Hallazgos:

  • 6 tablas (library, liked, mixes/[id], AlbumTrackTable, PlaylistTrackTable, LibraryPicker) repiten: {@const active}, TrackIndexCell, TrackTitleCell, N TrackMeta, y un <form method=POST use:enhance><input hidden trackId><IconButton revealOnHover>.
  • Anchos de columna pasados desde cada página e inconsistentes: "Escuchas" 106px en unas y 100px en otras.
  • La columna "Álbum" siempre pinta "—" (library, liked, playlist): placeholder sin datos. Decisión: eliminarla (recomendado) o dejarla en el preset.

Tareas:

  1. Crear TrackList.svelte (B.3) con presets de columna en el propio módulo: { album: {label:'Álbum', width:'76px'}, added: {'Añadida','124px'}, uploaded: {'Subida','124px'}, plays: {'Escuchas','104px'} } y columnas fijas (index 32px/28px, título minmax(180px,1fr)/minmax(120px,1fr), duración 104px/52px, acción 36px/32px) como constantes del módulo.
  2. rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }} genera el form+IconButton; para liked (onclick) aceptar rowAction.onclick.
  3. Migrar las 6 tablas; inlinear AlbumTrackTable, PlaylistTrackTable, LibraryPicker en sus páginas y borrarlos. Borrar TrackRow, TrackIndexCell, TrackMeta, TrackTable.
  4. LibraryPicker usa overlay NowPlaying en vez de índice: TrackList con index={false} muestra EQ sobre el arte (vía Artwork children).

Cierre: las 6 páginas; hover revela acción; activo muestra EQ; móvil oculta metas.


P9 — Tarjetas y grids

Objetivo: una tarjeta, cero wrappers.

Hallazgos: AlbumCard y PlaylistCard solo construyen el subtítulo y eligen el arte; MixTile es una MediaCard con overlay de play y tipografía distinta; MediaGrid es un <div> con variables inline; Inicio (mixes 164px, playlists 258px) y Explorar (géneros 268px) escriben su propio grid-cols-[repeat(auto-fill,minmax(…))].

Tareas:

  1. MediaCard: props href, title, subtitle?, onPlay? (overlay PlayButton variant="glass"), art snippet o props de Artwork, oncontextmenu?, index. Tipografía fija (text-sm text-fg / text-xs text-fg-3).
  2. Helpers en format.ts: albumMeta(album), playlistMeta(playlist).
  3. Sustituir AlbumCard, PlaylistCard, MixTile por MediaCard; MediaGrid por grid-cards/grid-cards-lg/grid-tiles/grid-wide (B.1.7). Borrar los 4 archivos.
  4. Tile de género de Explorar: style con --tile-hue y el gradiente en un @utility genre-tile que lea var(--tile-hue).

Cierre: /albums, /playlists, /u/[id], Inicio (mixes), Explorar (géneros).


P10 — Cabeceras, diálogos y tipografía

Objetivo: 1 cabecera de página, 1 de sección, 1 diálogo de confirmación.

Hallazgos:

  • PageHeader ≡ CollectionHeader sin portada/eyebrow. La cabecera de búsqueda de Explorar reimplementa ambos (eyebrow text-sm font-semibold tracking-[0.16em]).
  • AlbumHeader/PlaylistHeader (rutas) solo montan el meta y los botones.
  • Cada página decide el margen tras la cabecera (mt-7 sm:mt-8, mt-6 sm:mt-8, mt-9, mt-10 sm:mt-12…).
  • 2 diálogos de borrado con tipografías distintas.
  • EmptyState duplicado literalmente en Inicio ("Todavía no has escuchado nada" ×2).

Tareas:

  1. Fusionar en PageHeader: title, eyebrow?, description?, meta?, cover?, actions?, align?. La cabecera fija su propio margen inferior (mb-7 sm:mb-8); quitar los mt-* de lo que venga después. Borrar CollectionHeader.
  2. Inlinear AlbumHeader/PlaylistHeader en sus páginas (con plural() y el BackLink) y borrarlos. Iconos de botones: unificar (álbum tiene iconos, playlist no) → decisión: con iconos en ambos (recomendado) o sin ellos.
  3. SectionHeading: prop count? que pinte la línea + "N resultados" (hoy repetido 4× en Explorar) y href?/linkLabel? para "Ver todas".
  4. ConfirmDialog (B.3); migrar álbum y playlist. Modal usa .glass-panel y z-(--z-modal); su eyebrow y título con recetas.
  5. Aplicar recetas B.1.6 en: EmptyState, +error.svelte, "Sin resultados", hero de Inicio, stats, Queue, Sidebar ("Tus playlists"), PlaylistForm (visibilidad).
  6. Inicio: estado vacío repetido → snippet local.

Cierre: todas las páginas con cabecera; diálogos de borrado idénticos.


P11 — Formularios

Objetivo: un único esqueleto de formulario con portada.

Hallazgos:

  • AlbumForm y PlaylistForm comparten: use:enhance con submitting, ImageDropzone gradient class="h-36 w-36 rounded-2xl", Field/Input/Textarea, Alert, pie Cancelar/Enviar.
  • upload/+page.svelte reimplementa el selector de portada (label + input + preview + gradiente) que ya hace ImageDropzone.
  • Input y Textarea repiten la misma cadena de clases.
  • Toggle Privada/Pública (w-[calc(50%-4px)]) y chips de Explorar: selectores de una opción con estilos propios.

Tareas:

  1. CoverForm.svelte (esqueleto): props action, coverFallbackUrl?, coverIcon, requireCover?, formMessage?, submitLabel, submittingLabel, canSubmit, helperText?, onCancel, onSuccess?, snippet fields. AlbumForm y PlaylistForm quedan como ~30 líneas de campos cada uno (o se inlinean en sus páginas si solo se usan en un sitio: AlbumForm se usa en 2, PlaylistForm en 2 → mantener).
  2. ImageDropzone: size="hero" usando size-cover-hero + rounded-art-lg; upload la usa (Δ: icono de 64px → icon-xl).
  3. @utility field-control en layout.css con la cadena común de Input/Textarea.
  4. SegmentedControl.svelte (options, value bindable) para Privada/Pública; Chip (B.3) para Explorar.

Cierre: crear/editar álbum y playlist, subir canción (drag&drop incluido).


P12 — Chrome de la app (layout, navegación, reproductor)

Objetivo: una sola definición de navegación y un reproductor más pequeño.

Hallazgos:

  • Sidebar y TabsBar definen cada uno su lista de links + isActive.
  • MobileHeader compensa los links que faltan en TabsBar con items extra en el menú.
  • Logo: gradiente + texto en MobileHeader y login; solo texto en Sidebar.
  • PlayerBar repite dos veces la cadena de "barra de cristal" (igual que TabsBar).
  • PlayerExtras (22 líneas) solo compone volumen + botón de cola.
  • SeekBar es un <button> de click (sin teclado ni arrastre) con h-[3.5px]; VolumeControl es un <input range> con <style> propio (84px, 11px, 3.5px).
  • TrackInfo duplica el bloque identidad de pista (→ MediaIdentity, P7).

Tareas:

  1. appNavLinks() (B.2) en Sidebar y TabsBar (primary = los 4 de TabsBar).
  2. Logo.svelte; usar en Sidebar, MobileHeader y login (Δ: Sidebar gana el cuadrado de gradiente — confirmar).
  3. glass-bar en PlayerBar ×2 y TabsBar; theme-transition en Sidebar, Queue, etc.
  4. Fusionar PlayerExtras dentro de PlayerBar; anchos laterales --mf-player-side-w.
  5. Slider.svelte (B.3) con <input type="range"> estilado vía tokens --mf-range-h/--mf-range-thumb/--mf-volume-w; sustituye SeekBar y VolumeControl (mejora: arrastre y teclado en la barra de progreso). Borrar ambos.
  6. TopBar: usar searchHref, IconButton, Avatar; max-w-search.
  7. z-index de header/dock/menús con B.1.5.

Cierre: navegación desktop/tablet/móvil, cola abre/cierra, seek por click, arrastre y flechas, volumen.


P13 — Páginas grandes: Explorar (602 líneas) e Inicio (466)

Objetivo: que las páginas sean composición, no lógica ni marcado repetido. Hacer después de P7–P10 (usa ListRow, SectionHeading count, PageHeader).

Explorar:

  1. GENRE_INFO + genreTiles → src/lib/genres.ts.
  2. Lógica de búsqueda (topResult, searchCounts, searchChips, capped, showGroup, playlistMatches) → src/lib/search.ts (funciones puras).
  3. Los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) → un {#each groups} con SectionHeading count + ListRow.
  4. "Mejor resultado": un solo marcado con Artwork/Avatar según tipo + PlayButton as="span"; eliminar el snippet duplicado button/a usando ListRow-like o <svelte:element this={href ? 'a' : 'button'}>.
  5. Paginación loadMore → reutilizable si otra página la necesita; si no, dejar.

Inicio:

  1. Usar Page en vez de page-x pt-3 sm:pt-4 … a mano; gap-11.5 → token de sección (--spacing-section: 2.875rem, gap-section).
  2. Stats ×3 idénticos → {#each stats}.
  3. Hero/spotlight: style= de fondo → @utility hero-surface / spotlight-surface.
  4. Filas → ListRow (si P7 no lo hizo ya).

Cierre: Explorar sin query, con query, filtros, sin resultados, scroll infinito; Inicio completo con y sin datos.


P14 — Barrido final y guardarraíles

Objetivo: que no vuelva a aparecer lo que se ha limpiado.

Tareas:

  1. Script web-player/scripts/check-tokens.mjs + npm run lint:tokens (añadir a lint). Falla si encuentra en src/**/*.svelte:
    • -\[ arbitrario fuera de la lista blanca B.4;
    • rounded-(sm|md|lg|xl|2xl|3xl)\b o rounded\b a secas;
    • \b[hw]-\d con su pareja en el mismo elemento (debe ser size-*);
    • espaciado fraccionario -\d+\.\d+ en h-/w-/size- (tamaños → tokens);
    • #[0-9a-f]{3,8} en clases, rgba(, strokeWidth=, text-base sin receta;
    • style=" que no sea de la lista blanca.
  2. Espaciados fraccionarios restantes (gap-2.75, py-1.75, mt-3.25, p-2.25…): redondear al paso de 0.5 más cercano o a la escala entera cuando el Δ ≤ 1px. Tabla guía: .25/.75 → .5 más cercano; mt-0.75→mt-1; gap-1.75→gap-2; p-4.25→p-4; mb-4.5 (SectionHeading) se mantiene (Δ 2px visible en todas las secciones).
  3. Detector de huérfanos (repetir y borrar lo que salga):
    for f in $(find src/lib/components -name "*.svelte"); do n=$(basename "$f" .svelte); c=$(grep -rlw "$n" --include=*.svelte --include=*.ts src | grep -v "/$n.svelte$" | wc -l); [ "$c" -eq 0 ] && echo "$f"; done
    
  4. Actualizar web-player/CLAUDE.md → sección "Estilos" con: B.4, "usa size-cover-*, size-icon-*, recetas text-display-*/text-eyebrow/text-body/text-count, radios semánticos; componentes: Artwork, Avatar, ListRow, TrackList, MediaCard, PageHeader, ContextMenu…". Borrar docs/design-system-refactor-plan.md.
  5. Recalcular la tabla A.4 y anotarla en D.

D. Registro

Parte Estado Commit Notas / decisiones tomadas
P0 ✅ (pendiente) Button primary/secondary se mantienen iguales (decisión del usuario)
P1 ✅ (pendiente) Verificado con build (Tailwind compila @apply sm: dentro de @utility); sin QA visual autenticada (Docker no disponible en esta sesión)
P2 ✅ (pendiente) <style>{expr}</style> no interpola en Svelte (se trata como CSS del componente); solución: {@html \<style>${css}</style>`}`
P3 ✅ (pendiente) Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió size="fill" a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop kind/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible)
P4 ⬜
P5 ⬜
P6 ⬜
P7 ⬜
P8 ⬜
P9 ⬜
P10 ⬜
P11 ⬜
P12 ⬜
P13 ⬜
P14 ⬜

Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):

  • P0/P4: ¿Button primary debe verse distinto de secondary?
  • P3: ¿quitar el nombre bajo el avatar de perfil?
  • P4: ¿eliminar el botón "adelante" decorativo y el icono AlignJustify del player?
  • P5: pluralizaciones en minúscula en toda la app.
  • P8: ¿eliminar la columna "Álbum" que siempre muestra "—"?
  • P10: ¿iconos en los botones de cabecera de álbum y playlist?
  • P12: ¿logo con cuadrado de gradiente también en la Sidebar?

E. Inventario completo (veredicto por archivo)

Leyenda: = se mantiene · ~ se modifica · →X se fusiona en X · ✕ se borra.

src/lib/components/ui/

Archivo Líneas Veredicto Parte Motivo
AccountMenu 73 ~ P6 clickOutside, Escape, ancho por prop
AlbumCard 62 ✕ →MediaCard P9 wrapper de meta + hack trackId=albumId
AlbumForm 123 ~ (CoverForm) P11 esqueleto compartido
Alert 20 =
ArtistAvatar 36 ✕ →Avatar P3
BackLink 33 ~ P4 IconButton/tokens; quitar hover:text-accent-soft duplicado
Badge 23 ✕ P0 0 usos
Button 66 ~ P0/P4 variantes duplicadas, overrides !
Checkbox 29 ~ P14 size-*, tokens
CollectionHeader 45 ✕ →PageHeader P10
Cover 47 ✕ →Artwork P3
EmptyState 31 ~ P10 recetas
EqBars 23 ~ P7 absorbe NowPlaying
ExplicitBadge 13 ~ P1/P14 rounded-tag, size-4
Field 27 =
GlassMenu 15 = base de menús
IconButton 39 ~ P4 shape/size/tone/pressed/href
ImageDropzone 62 ~ P11 size="hero"; usado por upload
InfiniteScroll 39 =
Input 59 ~ P11 field-control
MediaCard 32 ~ P0/P9 bug 16px; absorbe MixTile
MediaGrid 27 ✕ →grid-* P9
MenuItem 48 ~ P6 iconos size-icon-md
MixArt 25 ✕ →Artwork P3
MixTile 51 ✕ →MediaCard P9
Modal 106 ~ P10 .glass-panel, z-token, recetas
NowPlaying 20 ✕ →EqBars P7
Page 14 = Inicio debe usarlo (P13)
PageHeader 26 ~ P10 absorbe CollectionHeader
PlaylistArt 61 ✕ →Artwork P3
PlaylistCard 23 ✕ →MediaCard P9
PlaylistForm 161 ~ (CoverForm) P11 + SegmentedControl
SearchResultRow 78 ✕ →ListRow P7
SectionHeading 32 ~ P10 count, "ver todas"
Surface 17 = (solo upload; aceptable)
Textarea 28 ~ P11 field-control
TrackIndexCell 27 ✕ →TrackList P8
TrackMeta 14 ✕ →TrackList P8
TrackRow 27 ✕ →TrackList P8
TrackTable 79 ✕ →TrackList P8
TrackTitleCell 77 → MediaIdentity P7 renombrar + generalizar
Nuevos Artwork, Avatar, ListRow, MediaIdentity, TrackList, ContextMenu, PlayButton, ConfirmDialog, Slider, Logo, Chip, SegmentedControl, CoverForm

src/lib/components/ y player/

Archivo Líneas Veredicto Parte Motivo
AlbumContextMenu 119 ✕ →ContextMenu P6 copia de TrackContextMenu
TrackContextMenu 109 ✕ →ContextMenu P6
MobileHeader 70 ~ P6/P12 Avatar, Logo, sin wrapper duplicado
Sidebar 125 ~ P7/P12 appNavLinks, ListRow
TabsBar 50 ~ P12 appNavLinks, glass-bar
TopBar 140 ~ P4/P12 IconButton, Avatar, searchHref
player/PlayerBar 28 ~ P12 glass-bar, absorbe PlayerExtras
player/PlayerDock 29 ~ P12 z-token
player/PlayerExtras 22 ✕ →PlayerBar P12
player/Queue 111 ~ P7 ListRow, recetas, pressable
player/SeekBar 48 ✕ →Slider P12
player/TrackInfo 65 ~ P7 MediaIdentity, IconButton pressed
player/TransportControls 75 ~ P4 PlayButton, IconButton pressed
player/VolumeControl 52 ✕ →Slider P12

Rutas

Archivo Líneas Veredicto Parte Motivo
+layout.svelte 142 ~ P2 tokens SSR, fondos a utilidades, padding del dock
+page.svelte (Inicio) 466 ~ P0/P5/P7/P13 4 filas a mano, stats ×3, vacío ×2, style=
+error.svelte 16 ~ P10 recetas, max-w-prose-sm
login/+page.svelte 50 ~ P12 Logo, max-w-cta
(app)/explore/+page.svelte 602 ~ P13 dividir datos/lógica; 4 grupos → each
(app)/upload/+page.svelte 288 ~ P11 ImageDropzone, recetas
(app)/library/+page.svelte 108 ~ P8 TrackList
(app)/liked/+page.svelte 123 ~ P0/P5/P8/P10 imports muertos, playAllOrToggle, TrackList
(app)/mixes/[id]/+page.svelte 134 ~ P5/P8/P10 menú, TrackList, PageHeader
(app)/albums/+page.svelte 74 ~ P9 MediaCard + grid-cards
(app)/albums/[id]/+page.svelte 91 ~ P8/P10 inline header/tablas, ConfirmDialog
albums/[id]/components/AlbumHeader 82 ✕ (inline) P10
albums/[id]/components/AlbumTrackTable 64 ✕ (inline) P8
albums/[id]/components/LibraryPicker 65 ✕ (inline) P8
(app)/playlists/+page.svelte 62 ~ P9
(app)/playlists/[id]/+page.svelte 95 ~ P8/P10
playlists/[id]/components/PlaylistHeader 62 ✕ (inline) P10
playlists/[id]/components/PlaylistTrackTable 71 ✕ (inline) P8
(app)/u/[id]/+page.svelte 97 ~ P3/P9/P10 Avatar, grid
routes/layout.css 238 ~ P1 todos los tokens y recetas

src/lib/ (cliente)

Archivo Veredicto Parte Motivo
theme/theme.css ~ P1/P2 quitar duplicados de tokens.ts, añadir layout/z
theme/tokens.ts ~ P2 tokensToCss, sin --mf-sidebar-bg
theme/palette.ts, theme/color.ts, theme/mode.svelte.ts = (mode pierde el flash con P2)
tracks.ts ~ P5 quitar envoltorio TrackTarget + 8 accesores
recentlyPlayed.ts ~ P5 toQueueItem
player/player.svelte.ts ~ P5 helpers de cola, toQueueItem, renombrar addToQueue
player/liked.svelte.ts ~ P5 toQueueItem
player/queuePanel.svelte.ts, playlists.svelte.ts → toggle.svelte.ts P5 idénticos
format.ts ~ P5/P9 plural, albumMeta, playlistMeta
navigation.svelte.ts ~ P5 searchHref, appNavLinks
config.ts ~ P6 MENU_WIDTH, SUBMENU_WIDTH
mixes.ts, albums.ts, collections.ts, types.ts =
lib/server/**, +page.server.ts, routes/api/**, auth/**, hooks.server.ts fuera de alcance no contienen UI ni estilos