# Plan: Frontend refactor and unification (web-player) > 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`). ## How to use this document 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). Recommended order (the arrows are **soft** dependencies, see point 4): ``` P0 → P1 → P2 ↘ P3 (Artwork/Avatar) ─┐ ↘ 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. Common context (always read) ### A.1 Project stack and rules (from `web-player/CLAUDE.md`) - 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 Known gotchas (don't reintroduce them) 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`) 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 Verifying each part ```sh cd web-player npm run check npm run lint ``` 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 Measured baseline (to know if you're making progress) | 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`) | Overall goal: ~61 → ~45 components, −1,500/−2,000 lines, 0 arbitrary values outside an allow list, 1 scale per dimension. --- ## B. Shared specs (single source of truth) Any part that needs something from here and it doesn't exist yet creates it **exactly like this**. ### B.1 Tokens (in `src/routes/layout.css`, inside `@theme`) 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 Radii (`--radius-*`) | 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 | Remove `--radius-art-round` (0 uses). Mapping of what exists today: | 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 Cover/artwork sizes (`--spacing-cover-*`) | 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 | Usage: `size-cover-sm` (Tailwind 4's `size-*` = `width`+`height`). **Never** pair `h-X w-X`: always use `size-*`. #### B.1.3 Icons (`--spacing-icon-*`) and stroke | 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) | Stroke: in `layout.css`, outside `@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; } ``` 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 Widths (`--container-*`) and layout | 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) | 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 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; ``` 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 Layers (`z-index`) 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 Typography (`@utility` recipes in `layout.css`) 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; } ``` > 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); }` > the way `page-x` does. Mapping of what exists today: | 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 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 */ 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 default: 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 and /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 { /* Home mixes: 164 */ display: grid; gap: --spacing(5); grid-template-columns: repeat(auto-fill, minmax(10.25rem, 1fr)); } @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` already exists; `Modal` should use it instead of its duplicated inline `style=`. Stagger (replaces the 5 `style="animation-delay:…"` with 40 **or** 45ms): ```css .animate-enter { animation-delay: calc(min(var(--i, 0), 10) * 45ms); } ``` Usage: `style="--i:{i}"`. The only `style=` allowed alongside the ones in B.4. #### B.1.8 Colors: cleanup - 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 TypeScript helpers (single source of truth) | 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 New components (API) | Component (`$lib/components/ui/`) | Props | Replaces / absorbs | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Artwork.svelte` | `src?`, `trackIds?: (string\|number)[]`, `size: 'xs'\|'sm'\|'md'\|'lg'\|'xl'\|'2xl'\|'hero'`, `shape?: 'auto'\|'round'`, `imageSize?: 'small'\|'medium'\|'large'` (auto from `size`), `fallback?: 'music'\|'gradient'\|'none'`, `class?`, `children?` (overlay) | `Cover`, `PlaylistArt`, `MixArt`; radius picked automatically by size (xs→thumb, sm to xl→art/control, 2xl/hero→art-lg). Priority: `src` → mosaic if ≥4 `trackIds` → first cover → fallback. A single `onerror`. | | `Avatar.svelte` | `name`, `src?`, `size: 'sm'\|'md'\|'lg'\|'hero'`, `class?` | initials/image in TopBar, MobileHeader, Explore (×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 hand-rolled rows (see P7) | | `MediaIdentity.svelte` | `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `active?`, `art?: Snippet` \| `artwork` props, `titleClass?` | `TrackTitleCell` (renamed and generalized) and the `player/TrackInfo` block | | `TrackList.svelte` | `tracks: TrackLike[]`, `columns?: ('album'\|'added'\|'uploaded'\|'plays')[]`, `index?`, `onPlay(i)`, `rowAction?: {action, icon, label}` \| `rowActionSnippet?`, `oncontextmenu?(e, track)` | `TrackTable`+`TrackRow`+`TrackIndexCell`+`TrackMeta` and the 6 tables (P8) | | `ContextMenu.svelte` | `x`, `y`, `openLeft`, `onClose`, `items: {icon,label,onclick}[]`, `playlistAction?: {action: string, fields: Record, label}`, `playlists` | `TrackContextMenu` + `AlbumContextMenu` (90% identical) | | `PlayButton.svelte` | `playing?`, `size: 'sm'\|'md'\|'lg'`, `variant: 'strong'\|'glass'`, `label`, `onclick?`, `as?: 'button'\|'span'` | play button in TransportControls, "Best match", MixTile overlay | | `ConfirmDialog.svelte` | `open`, `onClose`, `title`, `description`, `confirmLabel`, `action` (form POST) | 2 delete modals with different styles | | `Slider.svelte` | `value`, `max`, `label`, `oninput`, `variant: 'seek'\|'volume'` | `SeekBar` (click-only) + `VolumeControl` (own `` (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. Closing: with light mode saved, a hard reload shows no flash; track changes still animate the hue; check+lint. --- ### P3: `Artwork` and `Avatar` (unified artwork) **Objective:** a single cover component and a single avatar component. 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` 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`). Tasks: 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: Buttons, `Button`, `IconButton`, `PlayButton` **Objective:** no `