web-player: final sweep and guardrails

Add scripts/check-tokens.mjs (npm run lint:tokens, wired into lint) as
a guardrail: fails if src/**/*.svelte reintroduces arbitrary values
outside the B.4 allowlist, factory rounded-*, h-N w-N pairs (should
be size-N), quarter-step fractional spacing, or literal hex/rgba()
colors.

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

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

Verified with npm run check (0 errors), npm run lint:tokens (0
violations) and npm run build. Smoke-tested /login with no console
errors attributable to this change.
This commit is contained in:
2026-09-21 15:05:07 +02:00
parent b60a87adb5
commit 1b6d7ecb42
32 changed files with 200 additions and 453 deletions
+4 -4
View File
@@ -31,7 +31,7 @@
</script>
<aside
class="theme-transition hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 lg:flex"
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 theme-transition lg:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 pb-3">
@@ -48,7 +48,7 @@
? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.5} />
<link.icon class="size-4.5 shrink-0" strokeWidth={1.5} />
<span class="flex-1 truncate">{link.label}</span>
{#if link.count}
<span class="text-xs text-muted tabular-nums">{link.count}</span>
@@ -85,7 +85,7 @@
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
class="px-3 py-1.75"
class="px-3 py-2"
/>
{:else}
<button
@@ -93,7 +93,7 @@
onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
>
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
<Plus class="size-3.5" strokeWidth={1.8} />
Crear tu primera lista
</button>
{/each}
+3 -3
View File
@@ -18,7 +18,7 @@
</script>
<nav
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-1.75 lg:hidden {klass}"
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
@@ -26,11 +26,11 @@
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
class="flex flex-col items-center gap-1 rounded-control px-2.5 py-1.5 {active
? 'text-fg'
: 'text-fg-2'}"
>
<link.icon class="h-4.25 w-4.25" strokeWidth={1.6} />
<link.icon class="size-4" strokeWidth={1.6} />
<span class="text-xs font-medium">{link.label}</span>
</a>
{/each}
+3 -3
View File
@@ -69,7 +69,7 @@
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
>
<Search class="h-3.75 w-3.75 shrink-0 text-fg-3" strokeWidth={1.8} />
<Search class="size-4 shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
@@ -83,9 +83,9 @@
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
class="grid size-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
<X class="size-3.5" />
</button>
{/if}
</label>
@@ -15,7 +15,7 @@
</script>
<div
class="animate-enter pointer-events-auto flex items-center gap-4.5 glass-bar px-4 py-2.75 sm:hidden"
class="animate-enter pointer-events-auto flex items-center gap-4.5 glass-bar px-4 py-3 sm:hidden"
>
<div class="min-w-0 flex-1">
<TrackInfo compact />
@@ -24,14 +24,14 @@
</div>
<div
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-2.75 sm:flex"
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
>
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
<TrackInfo />
</div>
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls />
<div class="flex w-full max-w-hero items-center gap-2.75">
<div class="flex w-full max-w-hero items-center gap-3">
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
{fmtTime(player.progress)}
</span>
@@ -29,7 +29,7 @@
</div>
{#if player.current.id}
<p class="mb-2.75 text-eyebrow text-fg-3">Reproduciendo</p>
<p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p>
<div class="mb-6 rounded-control bg-accent-tint p-2.5">
<MediaIdentity
trackId={player.current.id}
@@ -41,7 +41,7 @@
</div>
{/if}
<div class="mb-2.75 flex items-center justify-between gap-3">
<div class="mb-3 flex items-center justify-between gap-3">
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
{#if upcoming.length > 0}
<button
@@ -64,7 +64,7 @@
subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId}
trackId={track.id}
class="p-2.25"
class="p-2"
>
{#snippet trailing()}
{#if track.duration}
@@ -28,7 +28,7 @@
}
</script>
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
{#if player.current.id}
<Artwork
trackIds={[player.current.id]}
@@ -12,7 +12,7 @@
const sideClass = $derived(
compact
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90'
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
);
</script>
@@ -26,7 +26,7 @@
aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
>
<Shuffle class="h-3.75 w-3.75" strokeWidth={1.8} />
<Shuffle class="size-4" strokeWidth={1.8} />
</button>
{/if}
@@ -58,7 +58,7 @@
aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
>
<Repeat class="h-3.75 w-3.75" strokeWidth={1.8} />
<Repeat class="size-4" strokeWidth={1.8} />
</button>
{/if}
</div>
@@ -58,7 +58,7 @@
{:else}
<button {type} disabled={disabled || loading} {onclick} class={classes}>
{#if loading}
<LoaderCircle class="h-4 w-4 animate-spin" />
<LoaderCircle class="size-4 animate-spin" />
{/if}
{@render children()}
</button>
@@ -13,15 +13,15 @@
</script>
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<span class="relative inline-grid size-5 shrink-0 place-items-center">
<input
type="checkbox"
{name}
bind:checked
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
+1 -1
View File
@@ -14,7 +14,7 @@
const classes = $derived(
href
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
: `flex items-center gap-1.75 rounded-full px-3.5 py-1.75 text-xs font-medium transition ${
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
selected
? 'bg-cta-strong text-ink'
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'
@@ -19,10 +19,10 @@
</script>
{#if overlay}
<div class="absolute inset-0 flex items-end justify-center gap-0.75 bg-scrim/45 pb-2.5 {klass}">
<div class="absolute inset-0 flex items-end justify-center gap-1 bg-scrim/45 pb-2.5 {klass}">
{#each delays as delay (delay)}
<span
class="h-3.5 w-0.75 origin-bottom rounded-full bg-on-art"
class="h-3.5 w-1 origin-bottom rounded-full bg-on-art"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
@@ -7,7 +7,7 @@
</script>
<span
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-xs leading-4 font-bold text-bg {klass}"
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-3 text-xs leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito">E</span
>
@@ -25,7 +25,7 @@
}: Props = $props();
const SIZE: Record<'default' | 'hero', string> = {
default: 'h-40 w-40 sm:h-52 sm:w-52 rounded-control',
default: 'size-40 sm:size-52 rounded-control',
hero: 'size-cover-hero rounded-art-lg'
};
@@ -34,6 +34,6 @@
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading}
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
<LoaderCircle class="size-5 animate-spin text-muted" />
{/if}
</div>
@@ -49,7 +49,7 @@
const classes = $derived(
`flex items-center gap-3 text-left transition-colors ${
variant === 'card' ? 'rounded-art p-2.25 pr-3.5' : 'rounded-control p-2'
variant === 'card' ? 'rounded-art p-2 pr-3.5' : 'rounded-control p-2'
} ${active ? 'bg-accent-tint' : 'hover:bg-hover'} ${klass}`
);
</script>
+1 -1
View File
@@ -6,7 +6,7 @@
let { size = 'sm' }: Props = $props();
const MARK: Record<'sm' | 'lg', string> = {
sm: 'size-5.5 rounded-lg',
sm: 'size-5.5 rounded-thumb',
lg: 'size-8 rounded-control'
};
@@ -95,7 +95,7 @@
</h2>
</div>
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
<X class="h-4 w-4" />
<X class="size-4" />
</IconButton>
</div>
{/if}
@@ -30,7 +30,7 @@
{title}
</h2>
{#if subtitle}
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
<p class="mt-1 text-xs text-fg-3">{subtitle}</p>
{/if}
{/snippet}
@@ -27,7 +27,7 @@
{#snippet actions()}
{#if items.length > 0}
<Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2.5} />
<Plus class="size-4" strokeWidth={2.5} />
Nuevo álbum
</Button>
{/if}
@@ -76,20 +76,20 @@
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
<Pause class="size-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
<Play class="size-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
<SquarePencil class="size-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
<Trash class="size-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
@@ -212,7 +212,7 @@
)} en canciones, álbumes, playlists y usuarios"
/>
<div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
<div class="mb-7 flex flex-wrap gap-2 sm:mb-8">
{#each chips as chip (chip.label)}
<Chip
selected={sfilter === chip.label}
@@ -249,11 +249,11 @@
{/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
<div class="text-xs leading-none font-semibold tracking-[0.14em] text-fg-3 uppercase">
<div class="text-eyebrow leading-none font-semibold text-fg-3">
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
</div>
<div
class="truncate font-display text-lg leading-none font-medium tracking-[-0.02em] text-fg"
class="truncate font-display text-lg leading-none font-medium tracking-display text-fg"
>
{topResult.kind === 'track'
? topResult.track.title
@@ -313,7 +313,7 @@
{#each genreTiles as genre, i (genre.query)}
<a
href={searchHref(genre.query)}
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4.25 transition-[filter] genre-tile hover:brightness-110"
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
style="--i:{i}; --tile-hue:{genre.hue}"
>
<div class="text-on-art">
@@ -34,7 +34,7 @@
{#snippet actions()}
{#if items.length > 0}
<Button href="/upload">
<Upload class="h-4 w-4" />
<Upload class="size-4" />
Subir canción
</Button>
{/if}
@@ -56,7 +56,7 @@
>
{#snippet actions()}
<Button href="/upload">
<Upload class="h-4 w-4" />
<Upload class="size-4" />
Sube la primera
</Button>
{/snippet}
@@ -75,10 +75,10 @@
{#snippet actions()}
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
<Pause class="size-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
<Play class="size-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -88,7 +88,7 @@
onclick={() => playShuffled(queue)}
disabled={queue.length === 0}
>
<Shuffle class="h-4 w-4" />
<Shuffle class="size-4" />
Aleatorio
</Button>
{/snippet}
@@ -66,19 +66,19 @@
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
<Pause class="size-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
<Play class="size-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
<SquarePencil class="size-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
<Trash class="size-4" strokeWidth={1.5} />
Eliminar
</Button>
{/snippet}
@@ -110,14 +110,13 @@
<Surface padding="sm" class={stepClass(stepStates[i])}>
<div class="flex items-center gap-3">
<span
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-sm {stepStates[
i
] === 'idle'
class="grid size-7 shrink-0 place-items-center rounded-full text-sm {stepStates[i] ===
'idle'
? 'bg-surface-2 text-fg-2'
: 'bg-accent-btn text-accent-soft'}"
>
{#if stepStates[i] === 'done'}
<Check class="h-3.5 w-3.5" strokeWidth={2.5} />
<Check class="size-3.5" strokeWidth={2.5} />
{:else}
{i + 1}
{/if}
@@ -132,10 +131,10 @@
{#if status === 'done'}
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="h-7 w-7" strokeWidth={2.5} />
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="size-7" strokeWidth={2.5} />
</span>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-[-0.02em] text-fg">¡Subida!</h2>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
<p class="mt-3 text-sm text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos.
@@ -192,9 +191,9 @@
aria-label="Seleccionar archivo de audio"
/>
{#if audioName === ''}
<Headphones class="h-9 w-9 text-muted sm:h-10 sm:w-10" strokeWidth={1.4} />
<Headphones class="size-9 text-muted sm:size-10" strokeWidth={1.4} />
{:else}
<Music class="h-9 w-9 text-accent-soft sm:h-10 sm:w-10" strokeWidth={1.4} />
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} />
{/if}
{#if audioName === ''}
<p class="mt-4 text-sm font-medium text-fg">
+1 -1
View File
@@ -135,7 +135,7 @@
<Page>
<div
class="relative overflow-hidden rounded-3xl hero-surface px-6 py-8 theme-transition sm:px-9 sm:py-8.5"
class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 theme-transition sm:px-9 sm:py-8.5"
>
<div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
-10
View File
@@ -97,16 +97,6 @@
scrollbar-gutter: stable;
}
@utility stroke-thin {
stroke-width: 1.3;
}
@utility stroke-regular {
stroke-width: 1.8;
}
@utility stroke-bold {
stroke-width: 2.5;
}
@utility text-display-1 {
@apply font-display text-3xl font-medium tracking-tight sm:text-4xl;
}