From 5c8c0ae31872006843222bdb128258eededb4303 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 22 Sep 2026 14:35:36 +0200 Subject: [PATCH] Translate frontend-refactor-plan.md to English Same content and structure, just in English now and reformatted with prettier for consistent table alignment. Co-Authored-By: Claude Sonnet 5 --- web-player/docs/frontend-refactor-plan.md | 1385 +++++++++++---------- 1 file changed, 718 insertions(+), 667 deletions(-) diff --git a/web-player/docs/frontend-refactor-plan.md b/web-player/docs/frontend-refactor-plan.md index 8cc0074..f79a551 100644 --- a/web-player/docs/frontend-refactor-plan.md +++ b/web-player/docs/frontend-refactor-plan.md @@ -1,64 +1,64 @@ -# Plan — Refactor y unificación del frontend (web-player) +# Plan: Frontend refactor and unification (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`). +> Work plan for agents/AI with limited context (~128k). Replaces +> `design-system-refactor-plan.md` (whose content is now mostly applied). +> Based on a full read of **every** client `.svelte`, `.css` and `.ts` file +> in `web-player/src` (2026-09-18, branch `design-prot`). -## Cómo usar este documento +## How to use this document -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). +1. **Always read section A (Common context)**, it's short and every part assumes it. +2. Pick **one part** (P0 through P14). Each part lists: objective, prerequisites, files + to read, tasks, the exact API of what gets created, a mapping table, and closing criteria. +3. One part equals one commit/PR. Don't mix parts. +4. If a part needs something from another part (say, a token or a helper) and it + **doesn't exist yet**, create it yourself following the spec in section B. These are + described with exact paths and APIs so any part can create them identically. +5. When you finish a part, check its box in section D (Log). -Orden recomendado (las flechas son dependencias **blandas**, ver punto 4): +Recommended order (the arrows are **soft** dependencies, see point 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) + ↘ P4 (Buttons) ───────┤ + ↘ P5 (TS helpers) ─────┼→ P6 (Menus) → P7 (Rows) → P8 (Tables) → P9 (Cards) + └→ P10 (Headers) → P11 (Forms) → P12 (Chrome) → P13 (Pages) → P14 (Final sweep) ``` --- -## A. Contexto común (leer siempre) +## A. Common context (always read) -### A.1 Stack y reglas del proyecto (de `web-player/CLAUDE.md`) +### A.1 Project stack and rules (from `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/`. +- SvelteKit 2 + **Svelte 5 runes** (`$props()` with `interface Props`, `$state`, + `$derived`, `$effect`; no `export let` or `$:`; `{#each}` always keyed). +- **Tailwind 4**: there's no `tailwind.config.js`; the config lives in `src/routes/layout.css` + (`@theme`, `@utility`). Raw color tokens in `src/lib/theme/theme.css` (`--mf-*`), + hue-dependent dynamic tokens in `src/lib/theme/tokens.ts` (`buildThemeTokens`). +- No comments in code unless essential. +- Closing out each part: `npm run check` and `npm run lint` must be **clean** (from `web-player/`). +- Icons: `@lucide/svelte/icons/`. -### A.2 Gotchas conocidos (no reintroducirlos) +### A.2 Known gotchas (don't reintroduce them) -1. **Tokens animados por hue** (`--mf-bg`, `--mf-elevated`, `--mf-panel-bg`, `--mf-bar-bg`, +1. **Hue-animated tokens** (`--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 `` 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 "Δ"). + `--mf-logo-grad`, `--mf-cover-grad`, `--mf-accent*`, `--mf-ambient`) are applied as + an **inline style** on `` from `+layout.svelte`. An override in + `:root[data-theme='light']` **doesn't win**: the light variant belongs in `buildThemeTokens()`. +2. **Tailwind class conflicts**: never `class="bg-a {cond ? 'bg-b' : ''}"`. Always + mutually exclusive: `class={cond ? 'bg-b' : 'bg-a'}`. Overrides of a shared component's + base class go through a prop/variant, not `!important`. +3. Text without a size class inherits the browser's 16px. All visible text needs a + typography recipe (see B.1.6). +4. `--mf-ink` is "text on CTA" and inverts in light mode; `--mf-tile-shade`, + `--mf-cover-grad`, `--mf-liked-grad` are "artwork" (don't invert). +5. Visual reference: **Home** (`src/routes/+page.svelte`). A refactor change shouldn't + change the look except where the part explicitly says so (the "Δ" column). -### A.3 Verificación de cada parte +### A.3 Verifying each part ```sh cd web-player @@ -66,90 +66,90 @@ 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"). +Visual (Browser pane, `.claude/launch.json` → `musify-web`; dev login credentials are in the +project's memory): open each touched page **and** Home; test the active state (a track +playing), hover, mobile (375px) and light mode (account menu → "White mode"). -### A.4 Línea base medida (para saber si avanzas) +### A.4 Measured baseline (to know if you're making progress) -| 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`) | +| Metric | Today | +| ------------------------------------------------ | ------------------------------------ | +| `.svelte` components (lib + route `components/`) | 61 | +| Client `.svelte`/`.css`/`.ts` lines | ~8,700 | +| Arbitrary classes `x-[…]` | 44 | +| Fractional spacing (`h-3.75`, `gap-2.75`…) | ~190 occurrences, 60 distinct values | +| Distinct radii used | 13 (6 tokens + 7 Tailwind defaults) | +| Distinct `strokeWidth` values | 13 values | +| `style="…"` attributes | 24 | +| Hand-rolled pluralizations `=== 1 ?` | 15 | +| `eslint` errors | 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. +Overall goal: ~61 → ~45 components, −1,500/−2,000 lines, 0 arbitrary values outside an +allow list, 1 scale per dimension. --- -## B. Especificaciones compartidas (fuente única) +## B. Shared specs (single source of truth) -Cualquier parte que necesite algo de aquí y no exista, lo crea **exactamente así**. +Any part that needs something from here and it doesn't exist yet creates it **exactly like this**. -### B.1 Tokens (en `src/routes/layout.css`, dentro de `@theme`) +### B.1 Tokens (in `src/routes/layout.css`, inside `@theme`) -Tailwind 4 genera utilidades a partir de estos namespaces: `--radius-*` → `rounded-*`, +Tailwind 4 generates utilities from these namespaces: `--radius-*` → `rounded-*`, `--spacing-*` → `size-* w-* h-* p-* m-* gap-*`, `--container-*` → `max-w-*`, `--tracking-*` → `tracking-*`, `--leading-*` → `leading-*`. -#### B.1.1 Radios (`--radius-*`) +#### B.1.1 Radii (`--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 | +| Token | Value | What it's for | +| ------------------ | ----------- | ----------------------------------------- | +| `rounded-tag` | `0.25rem` | badges, mini cells (ExplicitBadge, index) | +| `rounded-thumb` | `0.5rem` | covers ≤ 36px, logo | +| `rounded-control` | `0.625rem` | inputs, rows, menu items, buttons | +| `rounded-art` | `0.8125rem` | covers 42 to 88px, grid cards | +| `rounded-art-lg` | `1.125rem` | covers ≥ 136px, dropzones | +| `rounded-panel` | `1.125rem` | containers, menus, modals, tiles | +| `rounded-panel-lg` | `1.375rem` | hero, spotlight, "best match" | +| `rounded-full` | (Tailwind) | pills, avatars, round buttons | -Eliminar `--radius-art-round` (0 usos). Mapeo de lo existente: +Remove `--radius-art-round` (0 uses). Mapping of what exists today: -| 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 | +| Today | px | Uses | Becomes | Δ | +| ----------------------- | --- | ---- | -------------------------------------------------------- | ---- | +| `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` (artwork) / `rounded-panel` (container) | +2px | +| `rounded-3xl` | 24 | 1 | `rounded-panel-lg` | −2px | -#### B.1.2 Tamaños de carátula/arte (`--spacing-cover-*`) +#### B.1.2 Cover/artwork sizes (`--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:` | +| Token | Value | Current uses it absorbs (Δ) | +| --------------- | ---------------- | ------------------------------------------------------------------------------- | +| `cover-xs` | `2.25rem` (36) | menu thumbs `h-9`; sidebar `h-8.5` (+2) | +| `cover-sm` | `2.625rem` (42) | table rows `h-10.5`; queue and "Popular" `h-10` (+2); mobile player `h-11` (−2) | +| `cover-md` | `3rem` (48) | desktop player `md:h-12` | +| `cover-lg` | `3.5rem` (56) | "Continue" `h-14`; search rows `h-13` (+4); Home playlist card `h-15.5` (−6) | +| `cover-xl` | `5.5rem` (88) | "Best match" `h-22` | +| `cover-2xl` | `8.5rem` (136) | Home spotlight `h-34` | +| `cover-hero` | `9rem` (144) | collection headers, dropzones, profile avatar | +| `cover-hero-sm` | `10.25rem` (164) | collection headers from `sm:` up | -Uso: `size-cover-sm` (Tailwind 4 `size-*` = `width`+`height`). **Nunca** `h-X w-X` en -pareja: siempre `size-*`. +Usage: `size-cover-sm` (Tailwind 4's `size-*` = `width`+`height`). **Never** pair `h-X w-X`: +always use `size-*`. -#### B.1.3 Iconos (`--spacing-icon-*`) y trazo +#### B.1.3 Icons (`--spacing-icon-*`) and stroke -| 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) | +| Token | Value | Absorbs | +| --------- | ---------- | -------------------------------------------- | +| `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, mobile login) | +| `icon-xl` | `2.25rem` | `h-9`/`h-10` (empty states, dropzone, Liked) | -Trazo: en `layout.css`, fuera de `@theme`: +Stroke: in `layout.css`, outside `@theme`: ```css @layer base { @@ -157,93 +157,117 @@ Trazo: en `layout.css`, fuera de `@theme`: 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; } +@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. +The CSS property `stroke-width` beats the presentation attribute Lucide sets, so the +`strokeWidth` prop stops doing anything: **remove it from every use**. Mapping: +`1 to 1.4` → `stroke-thin`, `1.5 to 2` → (nothing, default), `2.5 to 3` → `stroke-bold`, +`strokeWidth={0}` + `fill` → the `stroke-0` class. Verify in the browser (DevTools → +Computed `stroke-width`) that the default applies before migrating in bulk. -#### B.1.4 Anchos (`--container-*`) y layout +#### B.1.4 Widths (`--container-*`) and 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) | +| Token | Value | Absorbs | +| ---------------- | ------- | -------------------------------------------------------------------------------- | +| `max-w-prose-sm` | `26rem` | `max-w-105` (error), `max-w-108` (hero p), `max-w-110`, `max-w-115` (no results) | +| `max-w-hero` | `34rem` | `max-w-135` (hero, seekbar) | +| `max-w-search` | `30rem` | `max-w-120` (TopBar search box) | +| `max-w-cta` | `20rem` | `max-w-80` (login buttons) | -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: +Already exist and stay in `theme.css`: `--mf-sidebar-w`, `--mf-queue-w`, `--mf-nav-h`, +`--mf-player-h`, `--mf-safe-b`. Add: ```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-player-side-w: 16rem; /* w-64 from PlayerBar/PlayerExtras */ +--mf-menu-w: 15rem; /* w-60 for context menus (= MENU_WIDTH 240) */ +--mf-submenu-w: 16rem; /* w-64 (= SUBMENU_WIDTH 256) */ +--mf-range-h: 3.5px; /* progress bar and volume */ --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. +The TS constants `MENU_WIDTH`/`SUBMENU_WIDTH` (currently duplicated in `TrackContextMenu` +and `AlbumContextMenu`) move to `src/lib/config.ts`. No comment needed, the name speaks for itself. -#### B.1.5 Capas (`z-index`) +#### B.1.5 Layers (`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). +In `theme.css`'s `:root`: `--z-raised: 10; --z-sticky: 20; --z-backdrop: 30; --z-menu: 40; +--z-submenu: 50; --z-modal: 60; --z-grain: 80;`. Usage: `z-(--z-menu)`. Maps 1:1 to the +current values (`z-10`, `z-20`, `z-30`, `z-40`, `z-50`, `z-[60]`, `body::after` 80). Note: +`MobileHeader` and `PlayerDock` use `z-40` but they're "sticky", not menus, so they get +`--z-sticky` (verify the account menu still sits above the header: menu 40 > sticky 20). -#### B.1.6 Tipografía (recetas `@utility` en `layout.css`) +#### B.1.6 Typography (`@utility` recipes in `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): +Type scale stays Tailwind's (`text-xs` through `text-4xl`), **no arbitrary sizes**. +Add to `@theme`: `--tracking-display: -0.02em; --tracking-eyebrow: 0.1em; +--leading-body: 1.65;`. Recipes (color is **not** part of the recipe, it's added separately): ```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; } +@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 +> If `@apply` with an `sm:` variant doesn't compile inside `@utility`, use > `@media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--text-4xl--line-height); }` -> como hace `page-x`. +> the way `page-x` does. -Mapeo de lo existente: +Mapping of what exists today: -| 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 | +| Current pattern | Recipe | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `font-display text-3xl … sm:text-4xl` (PageHeader, CollectionHeader, Home hero, search h1 `text-2xl sm:text-3xl font-semibold tracking-[-0.03em]`) | `text-display-1` (Δ search: +1 step) | +| `font-display text-2xl` (Home stats, spotlight title, "Uploaded!") | `text-display-2` | +| `font-display text-xl … tracking-[-0.02em]` (Modal, EmptyState, +error, "No results", delete dialogs) | `text-display-3` (all `font-medium`) | +| `font-display text-lg` (SectionHeading, "Best match", Queue "Up next" `text-base`) | `text-display-4` | +| `text-xs font-medium tracking-widest uppercase` (plus variants `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) | remove | -#### B.1.7 Recetas de superficie y movimiento (`@utility` en `layout.css`) +#### B.1.7 Surface and motion recipes (`@utility` in `layout.css`) ```css -@utility theme-transition { transition: background 0.5s ease-out; } /* 8× transition-[background] duration-500 */ -@utility glass-bar { /* PlayerBar ×2, TabsBar */ +@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 */ +@utility grid-cards { + /* MediaGrid default: 150/165 */ display: grid; gap: --spacing(3); grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr)); @@ -252,7 +276,8 @@ Mapeo de lo existente: grid-template-columns: repeat(auto-fill, minmax(10.3125rem, 1fr)); } } -@utility grid-cards-lg { /* /playlists y /u/[id]: 180/210 */ +@utility grid-cards-lg { + /* /playlists and /u/[id]: 180/210 */ display: grid; gap: --spacing(3); grid-template-columns: repeat(auto-fill, minmax(11.25rem, 1fr)); @@ -261,671 +286,697 @@ Mapeo de lo existente: grid-template-columns: repeat(auto-fill, minmax(13.125rem, 1fr)); } } -@utility grid-tiles { /* mixes de Inicio: 164 */ +@utility grid-tiles { + /* Home mixes: 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 */ +@utility grid-wide { + /* Home playlists (258) and genres (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. +`.glass-panel` already exists; `Modal` should use it instead of its duplicated inline `style=`. -Stagger (sustituye los 5 `style="animation-delay:…"` con 40 **o** 45ms): +Stagger (replaces the 5 `style="animation-delay:…"` with 40 **or** 45ms): ```css -.animate-enter { animation-delay: calc(min(var(--i, 0), 10) * 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. +Usage: `style="--i:{i}"`. The only `style=` allowed alongside the ones in B.4. -#### B.1.8 Colores: limpieza +#### B.1.8 Colors: cleanup -- 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). +- Delete unused utilities: `--color-accent-dim`, `--color-accent-glow`, + `--color-accent-hair`, `--color-accent-line`, `--color-accent-muted` (0 uses as a + class; `--mf-accent-dim` is used in `::selection`, so that variable stays). +- `--color-on-accent` is an alias of `--mf-on-art` (same as `--color-on-art`), delete it and + use `on-art`. `--color-hover` = `--mf-surface-2` (same as `surface-2`), keep + only `hover` as the hover semantic and leave `surface-2` for resting-state backgrounds. +- `--mf-sidebar-bg` isn't used by any component (Sidebar uses `bg-bg`), delete it from + `theme.css` and from `buildThemeTokens`. +- Button: `--mf-btn-bg` = `--mf-surface-2`; `--mf-btn-secondary-bg` only differs in light + mode. Expose `--color-btn`, `--color-btn-hover`, `--color-btn-glass`, + `--color-btn-glass-hover` in `@theme` and remove the `bg-[var(--mf-btn-…)]` from `Button.svelte`. +- Scrollbar: `rgba(255,255,255,.18/.32)` hardcoded in `layout.css` (broken in light mode) → + tokens `--mf-scrollbar`, `--mf-scrollbar-hover` with a variant in `:root[data-theme='light']`. +- `MixTile`: `to-black/42`, `bg-ink/76`, `border-on-art/12` → `to-scrim/40`, `PlayButton` + recipe (B.3). -### B.2 Helpers TypeScript (fuente única) +### B.2 TypeScript helpers (single source of truth) -| 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 | +| File (new) | API | Replaces | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/lib/actions/pressable.ts` | `pressable(node, fn)` → adds `role="button"`, `tabindex=0`, click + Enter/Space | 7 copies of `onkeydown` Enter/Space (Home ×5, Queue, TrackTitleCell) | +| `src/lib/actions/clickOutside.ts` | `clickOutside(node, fn)` | `onDocumentClick` from `AccountMenu` | +| `src/lib/toggle.svelte.ts` | `createToggle(initial=false)` → `{ get open, show(), close(), toggle() }` | `playlists.svelte.ts` and `player/queuePanel.svelte.ts` (identical) | +| `src/lib/format.ts` (extend) | `plural(n, one, many)` → `"1 song"`; `fmtCount(n,'song','songs')`; **always lowercase** | 15 hand-rolled pluralizations, with inconsistent casing ("Song"/"song") | +| `src/lib/navigation.svelte.ts` (extend) | `searchHref(q)`; `appNavLinks(user, counts?)` → `{href,label,icon,count?,primary:boolean}[]` | `buildHref` in TopBar and explore; Sidebar and TabsBar link lists | +| `src/lib/player/player.svelte.ts` (extend) | `isQueueCurrent(items)`, `playAllOrToggle(items)`, `playShuffled(items)`; rename `addToQueue` → `playNextItem` | `playAll`/`isCurrentQueue` in album, playlist, liked, mixes, Home spotlight (5 copies) | +| `src/lib/player/player.svelte.ts` | single `toQueueItem(track)` (normalizes `duration`/`listensCount` to `number`) | hand-rolled mappings in `recentlyPlayed.ts` (×2), `liked.svelte.ts` (×2), `liked/+page`, `tracks.ts#targetForQueueItem` and the scattered `Number(…)` calls | +| `src/lib/menus.svelte.ts` | `createTrackMenu()` → `{ state, open(e, track), close(), playNext() }` | context menu state + handler in Home, Explore, Mix | -### B.3 Componentes nuevos (API) +### B.3 New components (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, 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` (`` - (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`. + (serialized with a `tokensToCss(tokens)` helper in `tokens.ts`). +3. Delete from `theme.css` every token that `buildThemeTokens` generates (dark and light). + `theme.css` is left with only fixed neutrals, surfaces, text, layout and z-index. +4. `+layout.svelte`: the two background `div`s with `style=` → `@utility app-backdrop` / + `app-vignette` in `layout.css`. The `main` with an inline `padding-bottom: calc(…)` → + a class using `--mf-dock-h`, activated via `data-has-track` on `main` itself. -Cierre: con modo claro guardado, recarga dura sin flash; cambio de pista sigue animando -el hue; check+lint. +Closing: with light mode saved, a hard reload shows no flash; track changes still animate +the hue; check+lint. --- -### P3 — `Artwork` y `Avatar` (arte unificado) +### P3: `Artwork` and `Avatar` (unified artwork) -**Objetivo:** un único componente de carátula y uno de avatar. +**Objective:** a single cover component and a single avatar component. -Hallazgos: -- `Cover`, `PlaylistArt` y `MixArt` implementan la misma cascada (imagen → mosaico 2×2 → - primera carátula → placeholder). `MixArt` ≡ `PlaylistArt` con +Findings: + +- `Cover`, `PlaylistArt` and `MixArt` implement the same cascade (image → 2×2 mosaic → + first cover → placeholder). `MixArt` is basically `PlaylistArt` with `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`, +- `AlbumCard` passes the **album id** as `trackId` to `Cover` just to be able to use `src` + (a hack). +- "Image or initial" avatars are reimplemented in `TopBar`, `MobileHeader`, Explore (best + match + user row) and `ArtistAvatar` (with a ring). +- Each usage picks its own radius (`rounded-lg`, `rounded-xl`, `rounded-2xl`, + `rounded-control`, `rounded-art-lg`…) and its own size (`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 `` (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`. +Tasks: -Cierre: `grep -rn "Cover\b\|PlaylistArt\|MixArt\|ArtistAvatar" src` solo en `Artwork`; -visual en Inicio, Explorar (búsqueda), Sidebar, Cola, player, cabeceras. +1. Create `Artwork.svelte` and `Avatar.svelte` per B.3 using tokens B.1.1/B.1.2. + `size` drives both dimension **and** radius; `class` is only for layout (`shrink-0`, margins). +2. Migrate **every** usage (search for `Cover`, `PlaylistArt`, `MixArt`, `ArtistAvatar`, + `user.picture`, `profilePictureUrl`), applying the B.1.2 table. +3. `ArtistAvatar`: on `u/[id]/+page.svelte`, use `` (the name is already + shown by the header; the label under the avatar was redundant, **visible Δ**, confirm this). +4. Delete `Cover.svelte`, `PlaylistArt.svelte`, `MixArt.svelte`, `ArtistAvatar.svelte`. + +Closing: `grep -rn "Cover\b\|PlaylistArt\|MixArt\|ArtistAvatar" src` only matches inside +`Artwork`; visual check on Home, Explore (search), Sidebar, Queue, player, headers. --- -### P4 — Botones: `Button`, `IconButton`, `PlayButton` +### P4: Buttons, `Button`, `IconButton`, `PlayButton` -**Objetivo:** que no quede ningún `