Files
Musify/web-player/docs/frontend-refactor-plan.md
Admin 1b6d7ecb42 web-player: final sweep and guardrails
Add scripts/check-tokens.mjs (npm run lint:tokens, wired into lint) as
a guardrail: fails if src/**/*.svelte reintroduces arbitrary values
outside the B.4 allowlist, factory rounded-*, h-N w-N pairs (should
be size-N), quarter-step fractional spacing, or literal hex/rgba()
colors.

Cleans up the repo until the script reports 0 violations: ~40 h-N w-N
pairs -> size-N (mostly icons, via a one-off script), ~26 quarter-step
fractional spacings rounded to the nearest integer (delta <=1px, e.g.
py-2.75->py-3, gap-1.75->gap-2), stray factory radii (rounded-sm/lg,
bare rounded) migrated to the matching tokens, and two
tracking-[-0.02em] that matched the existing tracking-display token
exactly but had never been migrated. Removes the
stroke-thin/regular/bold recipes (added earlier, zero consumers) —
decision made, not blocking: strokeWidth={n} (13 values, across
almost every component) isn't migrated to CSS because there's no way
to verify without Docker that lucide-svelte honors stroke-width by
class instead of the prop, and a regression there would be silent
across the whole app; the script documents this gap explicitly
instead of pretending full coverage.

Orphan detector: 0 components with no importers. Removes
docs/design-system-refactor-plan.md (earlier plan, superseded by
frontend-refactor-plan.md). web-player/CLAUDE.md gains a "Styles"
section with the B.4 allowlist and the base components to reuse.
Recalculates the baseline table (A.4) and logs it: 61->47 components,
44->11 arbitrary values (the remaining 11 on the allowlist), 13->8
distinct radii, 0 lint:tokens violations.

Verified with npm run check (0 errors), npm run lint:tokens (0
violations) and npm run build. Smoke-tested /login with no console
errors attributable to this change.
2026-09-21 15:05:07 +02:00

64 KiB
Raw Permalink 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 ✅ (pendiente) Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono AlignJustify decorativo (PlayerExtras) por ser código muerto visual. TransportControls (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker
P5 🟡 (pendiente) Hecho: pressable/clickOutside, toggle.svelte.ts, plural() (+minúsculas, Δ visible), helpers de player.svelte.ts (toQueueItem/isQueueCurrent/playAllOrToggle/playShuffled, addToQueue→playNextItem), navigation.svelte.ts (searchHref/appNavLinks — appNavLinks creado pero su migración a Sidebar/TabsBar se deja para P12 tal como indica esa parte), menus.svelte.ts (createTrackMenu, migrado en Inicio/Explorar/Mezcla). Pendiente a propósito: la tarea 5 (eliminar el envoltorio TrackTarget de tracks.ts y sus 8 accesores) se difiere — es el cambio de mayor riesgo del plan (toca tipos en Inicio/Explorar/Mezcla/menús de forma transversal) y no se pudo verificar visualmente sin Docker; abordarlo junto con P13 cuando se reescriban esas páginas
P6 ✅ (pendiente) z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible)
P7 ✅ (pendiente) MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio use:pressable hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible)
P8 ✅ (pendiente) Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible)
P9 ✅ (pendiente) MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a onPlay (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible)
P10 ✅ (pendiente) Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop id sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el mb-7/8 de PageHeader en vez de un mt-5 ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible)
P11 ✅ (pendiente) CoverForm añade snippet extra (no está en la spec literal, solo fields) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con onselect (prop nueva) y se envuelve en {#key} para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible)
P12 ✅ (pendiente) PlayerExtras se fusiona en PlayerBar (volumen/cola inline); Slider sustituye a SeekBar+VolumeControl (confirmado por grep que el modo compact de SeekBar era código muerto). Sidebar y TabsBar migran a appNavLinks() (P5). Decisión tomada (sin bloquear): Sidebar gana la marca-gradiente Logo que no tenía antes, para consistencia con MobileHeader/login. z-index de PlayerDock/MobileHeader corregido a z-(--z-sticky) (son barras fijas, no menús) y SegmentedControl a z-(--z-raised), tal como indica B.1.5. glass-bar/theme-transition aplicados en PlayerBar (ambas variantes), TabsBar, Queue e Inicio (hero/spotlight), sustituyendo estilos duplicados. Sin QA visual autenticada (Docker no disponible); smoke test de /login sin errores de consola
P13 ✅ (pendiente) Se aborda junto con esta parte la tarea 5 diferida en P5: se elimina el envoltorio TrackTarget de tracks.ts y sus 8 accesores (targetId/targetTitle/targetArtist/targetOwnerUserId/targetExplicit/targetListensCount/isTargetCurrent/queueItemForTarget/targetForQueueItem); Explorar, Inicio, Mezcla, menus.svelte.ts y mixes.ts pasan a usar los objetos de pista (ApiTrackLike/LocalTrack) directamente, con toQueueItem/trackFromQueueItem (nuevo, en player.svelte.ts) como únicos conversores. TypeScript reveló que el campo isExplicit no existe en el schema real de /tracks — el badge "explicit" de Explorar ya era un no-op antes del refactor (siempre undefined ?? false); se retira el binding en vez de fingir soporte. Explorar: GENRE_INFO/genreTiles → src/lib/genres.ts; lógica de búsqueda (showGroup/capped/matchPlaylists/searchCounts/searchChips/findTopResult) → src/lib/search.ts (funciones puras, parametrizadas por sfilter/query en vez de cerrar sobre el estado del componente); "Mejor resultado" unifica el botón/enlace duplicado con <svelte:element this={...}>. Decisión tomada sin bloquear: no se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) en un único {#each groups} — cada grupo tiene snippets, contextmenu y props de ListRow genuinamente distintos (menú de pista vs. de álbum, overlay solo en canciones, trailing con formato distinto), y forzar una configuración genérica habría añadido más indirección de la que elimina. Inicio: usa Page (antes page-x pt-3 sm:pt-4 a mano); stats ×3 → {#each}; fondos de hero/spotlight (style= con var(--mf-hero-bg)/var(--mf-spotlight-bg)) → utilities hero-surface/spotlight-surface (nuevas, en layout.css) y token --spacing-section: 2.875rem → gap-section sustituye gap-11.5. Sin QA visual autenticada (Docker no disponible); /explore sin sesión redirige a /login porque +layout.server.ts exige allowAnonymousListening desde el backend (inalcanzable sin Docker) — comportamiento preexistente, no una regresión; smoke test de /login sin errores de consola
P14 ✅ (pendiente) scripts/check-tokens.mjs (npm run lint:tokens, añadido a lint) escanea src/**/*.svelte y falla en: arbitrarios -[…] fuera de la lista blanca de B.4, rounded-(sm/md/lg/xl/2xl/3xl)/rounded a secas, pares h-N w-N (deberían ser size-N), espaciado fraccionario en cuartos (.25/.75), hex/rgba() literales. Se limpió el repo hasta dejarlo en 0 violaciones: ~40 pares h-N w-N → size-N (icons sobre todo, vía script puntual), ~26 espaciados en cuartos redondeados al entero más cercano (Δ≤1px, p. ej. py-2.75→py-3, gap-1.75→gap-2), 3 rounded-sm/lg + 1 rounded a secas → tokens (rounded-tag/rounded-thumb/rounded-control), 2 tracking-[-0.02em] → tracking-display (coincidían exactamente con el token ya existente y no se habían migrado), 1 tracking-[0.14em] en Explorar → receta text-eyebrow, rounded-3xl del hero de Inicio → rounded-panel-lg (consistente con la tarjeta de playlist destacada). Se retiraron las recetas stroke-thin/regular/bold (creadas en P1, 0 consumidores) — decisión tomada, sin bloquear: no se migra strokeWidth={n} (13 valores, en casi todos los componentes) a esas recetas por CSS porque no se puede verificar sin Docker que lucide-svelte respete stroke-width por clase en vez del prop, y una regresión ahí sería silenciosa en toda la app; el script documenta este gap explícitamente y no lo exige. Se abordó también junto con la tarea 5 diferida de P5 (ver P13). Detector de huérfanos: 0 componentes sin importadores (los 47 de src/lib/components/ están todos en uso). docs/design-system-refactor-plan.md (plan anterior, superseded) borrado. web-player/CLAUDE.md gana sección "Estilos" con la lista blanca B.4, componentes base a reutilizar y el gap de strokeWidth documentado. Línea base recalculada (ver tabla debajo)

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?

D.1 Línea base recalculada (tras P14, cierre del refactor)

Recalculada con los mismos criterios que A.4 (grep sobre src/), en la misma sesión de cierre de P14.

Métrica A.4 (antes) Tras P14
Componentes .svelte en src/lib/components/ 61 47
Líneas .svelte/.css/.ts cliente ~8.700 ~8.310 (−~390; el objetivo de −1.500/−2.000 ya se había alcanzado en P3–P11, antes de que P13 añadiera genres.ts/search.ts como código nuevo bien separado)
Clases arbitrarias x-[…] 44 11 (las 11 restantes, en la lista blanca de B.4)
Espaciados fraccionarios en cuartos (.25/.75) ~190 apariciones 0 (redondeados al entero más cercano, Δ≤1px)
Radios distintos usados 13 8 (7 tokens B.1.1 + rounded-full)
strokeWidth distintos 13 12 (fuera de alcance, ver Registro P14 y CLAUDE.md)
Atributos style="…" 24 16 (en 12 archivos; todos dinámicos por instancia — stagger, posición de menú, var(--mf-*), animación — ninguno con color/medida literal)
Pluralizaciones a mano === 1 ? 15 2 (1 es la propia plural() en format.ts; 1 es concordancia de participio en library que plural() no puede cubrir)
Errores de eslint 2 0
npm run lint:tokens (nuevo, P14) — 0 violaciones

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