Files
Musify/web-player/docs/frontend-refactor-plan.md
T
Admin fb4a8ccc0e web-player: rows (ListRow, MediaIdentity, EqBars)
Add MediaIdentity.svelte (renames and generalizes TrackTitleCell,
purely presentational: art + title + subtitle, no click handling)
and ListRow.svelte on top of it (href|onclick, active, size xs/sm/lg,
variant row|card, leading/trailing/overlay). Also used by
player/TrackInfo... (TrackInfo itself is left for a later pass, which
already has an explicit task to touch that file).

Migrated to ListRow: SearchResultRow (Explore, 4 groups — component
removed), Home (Continue listening, Popular, spotlight, Your
playlists), Queue (Up next), Sidebar (playlists). The 6 track tables
(AlbumTrackTable, PlaylistTrackTable, library, liked, mixes,
LibraryPicker) migrate to MediaIdentity with their own local
use:pressable, since TrackList doesn't exist yet.

EqBars gains an `overlay` prop (scrim/45 background, on-art color)
replacing NowPlaying.svelte (removed). Along the way, fixes 6
plural() calls that a previous pass had left unmigrated (liked,
library, mixes, playlists, PlaylistHeader, Home).

Deltas documented in the plan's log: Sidebar's active row now uses
the accent tint (previously neutral gray); Home's "Your playlists"
card loses its redundant author line and its radius becomes
rounded-art; Queue rows lose the art's opacity-80.

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

914 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```sh
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`:
```css
@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:
```css
--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):
```css
@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`)
```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):
```css
.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):
```bash
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 | ⬜ | | |
| 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 |