Simplify design

This commit is contained in:
2026-09-23 01:33:25 +02:00
parent d7199b4d1d
commit 2660c124d5
49 changed files with 356 additions and 195 deletions
+2
View File
@@ -9,3 +9,5 @@ package
package-lock.json package-lock.json
src/lib/api/schema.d.ts src/lib/api/schema.d.ts
static static
.agents
skills-lock.json
+1
View File
@@ -3,6 +3,7 @@
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 100, "printWidth": 100,
"endOfLine": "auto",
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/routes/layout.css", "tailwindStylesheet": "./src/routes/layout.css",
"overrides": [ "overrides": [
+8
View File
@@ -57,6 +57,14 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`. - Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass. - Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass.
## Motion
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding.
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
## Auth ## Auth
`hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`. `hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`.
+1 -1
View File
@@ -120,7 +120,7 @@ A fixed bottom bar, visible while something is playing. Shows track info, transp
## Available backend (summary) ## Available backend (summary)
| Resource | Endpoints | | Resource | Endpoints |
| --------- | --------------------------------------------------------------------- | | --------- | ----------------------------------------------------------------- |
| Tracks | list/search, detail, by user, stream, create, delete, upload-urls | | Tracks | list/search, detail, by user, stream, create, delete, upload-urls |
| Playlists | CRUD, playlist tracks, add/remove track, upload-picture | | Playlists | CRUD, playlist tracks, add/remove track, upload-picture |
| Users | list/search, detail, create | | Users | list/search, detail, create |
@@ -6,7 +6,6 @@
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import ListRow from '../ui/media/ListRow.svelte'; import ListRow from '../ui/media/ListRow.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Logo from '../ui/primitives/Logo.svelte';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist { interface SidebarPlaylist {
@@ -40,14 +39,14 @@
<a <a
href={link.href} href={link.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors duration-150 {active class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors {active
? 'bg-surface-2 text-fg' ? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}" : 'text-fg-2 hover:bg-hover hover:text-fg'}"
> >
<link.icon class="size-icon-md shrink-0" strokeWidth={1.5} /> <link.icon class="size-icon-md shrink-0" strokeWidth={1.5} />
<span class="flex-1 truncate">{link.label}</span> <span class="flex-1 truncate">{link.label}</span>
{#if link.count} {#if link.count}
<span class="text-xs text-muted tabular-nums">{link.count}</span> <span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
{/if} {/if}
</a> </a>
{/each} {/each}
@@ -56,7 +55,7 @@
{#if user} {#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5"> <div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-eyebrow text-muted">Tus playlists</span> <span class="text-eyebrow text-fg-2">Tus playlists</span>
{#if playlists.length > 0} {#if playlists.length > 0}
<IconButton <IconButton
label="Crear playlist" label="Crear playlist"
@@ -64,7 +63,7 @@
size="xs" size="xs"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
> >
<Plus class="size-icon-sm text-muted" strokeWidth={1.25} /> <Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} />
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
@@ -87,7 +86,7 @@
<button <button
type="button" type="button"
onclick={() => createPlaylistModal.show()} 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" class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg-2"
> >
<Plus class="size-3.5" strokeWidth={1.8} /> <Plus class="size-3.5" strokeWidth={1.8} />
Crear tu primera lista Crear tu primera lista
@@ -69,21 +69,21 @@
<label <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" 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="size-4 shrink-0 text-fg-3" strokeWidth={1.8} /> <Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} />
<input <input
type="text" type="text"
bind:this={searchInput} bind:this={searchInput}
bind:value={query} bind:value={query}
oninput={onInput} oninput={onInput}
placeholder="Canciones, artistas, álbumes o playlists" placeholder="Canciones, artistas, álbumes o playlists"
class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-muted focus:outline-none" class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
/> />
{#if query} {#if query}
<button <button
type="button" type="button"
onclick={clearQuery} onclick={clearQuery}
aria-label="Borrar búsqueda" aria-label="Borrar búsqueda"
class="grid size-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-fg-2 transition hover:text-fg"
> >
<X class="size-3.5" /> <X class="size-3.5" />
</button> </button>
@@ -32,7 +32,7 @@
<div class="flex min-w-0 flex-1 flex-col items-center gap-2"> <div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls /> <TransportControls />
<div class="flex w-full max-w-hero items-center gap-3"> <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"> <span class="min-w-8 text-right text-xs text-fg-2 tabular-nums">
{fmtTime(player.progress)} {fmtTime(player.progress)}
</span> </span>
<Slider <Slider
@@ -42,13 +42,13 @@
oninput={seek} oninput={seek}
variant="seek" variant="seek"
/> />
<span class="min-w-8 text-xs text-fg-3 tabular-nums"> <span class="min-w-8 text-xs text-fg-2 tabular-nums">
{fmtTime(player.current.duration)} {fmtTime(player.current.duration)}
</span> </span>
</div> </div>
</div> </div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex"> <div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<Volume2 class="size-icon-sm shrink-0 text-fg-3" strokeWidth={1.8} /> <Volume2 class="size-icon-sm shrink-0 text-fg-2" strokeWidth={1.8} />
<Slider <Slider
value={player.volume} value={player.volume}
max={100} max={100}
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import PlayerBar from './PlayerBar.svelte'; import PlayerBar from './PlayerBar.svelte';
import TabsBar from '../layout/TabsBar.svelte'; import TabsBar from '../layout/TabsBar.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
@@ -17,11 +16,6 @@
<div <div
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)" class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
> >
{#if player.error}
<div class="pointer-events-auto px-4 pb-2.5">
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
{#if hasTrack} {#if hasTrack}
<PlayerBar /> <PlayerBar />
{/if} {/if}
@@ -7,7 +7,7 @@
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/utils/format';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { expoOut } from 'svelte/easing';
const upcoming = $derived.by(() => { const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId); const idx = player.tracks.findIndex((t) => t.id === player.currentId);
@@ -17,7 +17,7 @@
{#if queuePanel.open} {#if queuePanel.open}
<aside <aside
transition:fly={{ x: 24, duration: 220, easing: cubicOut }} transition:fly={{ x: 24, duration: 200, easing: expoOut }}
class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex" class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)" style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
> >
@@ -29,8 +29,8 @@
</div> </div>
{#if player.current.id} {#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p> <p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 rounded-control bg-accent-tint p-2.5"> <div class="mb-6 rounded-control surface-active p-2.5">
<MediaIdentity <MediaIdentity
trackId={player.current.id} trackId={player.current.id}
title={player.current.title} title={player.current.title}
@@ -42,12 +42,12 @@
{/if} {/if}
<div class="mb-3 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> <span class="min-w-0 flex-1 truncate text-eyebrow text-fg-2"> A continuación </span>
{#if upcoming.length > 0} {#if upcoming.length > 0}
<button <button
type="button" type="button"
onclick={() => player.clearUpcoming()} onclick={() => player.clearUpcoming()}
class="shrink-0 text-xs text-fg-3 transition-colors hover:text-fg" class="shrink-0 text-xs text-fg-2 transition-colors hover:text-fg"
> >
Vaciar Vaciar
</button> </button>
@@ -68,13 +68,12 @@
> >
{#snippet trailing()} {#snippet trailing()}
{#if track.duration} {#if track.duration}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span <span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(track.duration)}</span>
>
{/if} {/if}
{/snippet} {/snippet}
</ListRow> </ListRow>
{:else} {:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p> <p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
{/each} {/each}
</div> </div>
</aside> </aside>
@@ -49,7 +49,7 @@
<ArtistLink <ArtistLink
name={player.current.artist || '—'} name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-3" class="mt-0.5 text-xs text-fg-2"
/> />
</div> </div>
{#if !compact} {#if !compact}
@@ -13,7 +13,7 @@
const sideClass = $derived( const sideClass = $derived(
compact compact
? 'grid size-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' : 'flex text-fg-2 transition hover:text-fg active:scale-90'
); );
</script> </script>
@@ -24,7 +24,7 @@
onclick={() => player.toggleShuffle()} onclick={() => player.toggleShuffle()}
aria-pressed={player.shuffle} aria-pressed={player.shuffle}
aria-label="Aleatorio" aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}" class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
> >
<Shuffle class="size-4" strokeWidth={1.8} /> <Shuffle class="size-4" strokeWidth={1.8} />
</button> </button>
@@ -56,7 +56,7 @@
onclick={() => player.toggleRepeat()} onclick={() => player.toggleRepeat()}
aria-pressed={player.repeat} aria-pressed={player.repeat}
aria-label="Repetir" aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}" class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
> >
<Repeat class="size-4" strokeWidth={1.8} /> <Repeat class="size-4" strokeWidth={1.8} />
</button> </button>
@@ -80,7 +80,7 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
{#if helperText} {#if helperText}
<p class="text-sm text-muted">{helperText}</p> <p class="text-sm text-fg-2">{helperText}</p>
{:else} {:else}
<span></span> <span></span>
{/if} {/if}
@@ -68,6 +68,6 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} /> <Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" strokeWidth={1.3} />
{/if} {/if}
</label> </label>
@@ -88,7 +88,7 @@
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5"> <div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
<div class="min-w-0"> <div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div> <div class="text-sm font-medium text-fg-2">Visibilidad</div>
<div class="mt-0.5 text-xs text-muted"> <div class="mt-0.5 text-xs text-fg-2">
{visibility === 'private' {visibility === 'private'
? 'Solo tú la ves en tu biblioteca' ? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'} : 'Cualquiera con el enlace puede verla'}
@@ -30,21 +30,21 @@
{title} {title}
</h2> </h2>
{#if subtitle} {#if subtitle}
<p class="mt-1 text-xs text-fg-3">{subtitle}</p> <p class="mt-1 text-xs text-fg-2">{subtitle}</p>
{/if} {/if}
{/snippet} {/snippet}
{#snippet autoActions()} {#snippet autoActions()}
{#if count !== undefined} {#if count !== undefined}
<div class="h-px flex-1 self-center bg-line"></div> <div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums"> <span class="shrink-0 text-xs text-fg-2 tabular-nums">
{plural(count, 'resultado', 'resultados')} {plural(count, 'resultado', 'resultados')}
</span> </span>
{/if} {/if}
{#if href} {#if href}
<a <a
{href} {href}
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg" class="shrink-0 text-xs font-medium tracking-wider text-fg-2 uppercase transition-colors hover:text-fg"
> >
{linkLabel} {linkLabel}
</a> </a>
@@ -118,7 +118,7 @@
/> />
{:else if fallback === 'music'} {:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface"> <div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3"> <svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
<path <path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z" d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/> />
@@ -7,7 +7,7 @@
</script> </script>
<span <span
class="grid size-4 shrink-0 place-items-center rounded-tag 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-2 text-xs leading-4 font-bold text-bg {klass}"
title="Contenido explícito" title="Contenido explícito"
aria-label="Contenido explícito">E</span aria-label="Contenido explícito">E</span
> >
@@ -50,7 +50,7 @@
const classes = $derived( const classes = $derived(
`flex items-center gap-3 text-left transition-colors ${ `flex items-center gap-3 text-left transition-colors ${
variant === 'card' ? 'rounded-art p-2 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}` } ${active ? 'surface-active' : 'hover:bg-hover'} ${klass}`
); );
</script> </script>
@@ -38,7 +38,7 @@
{trackIds} {trackIds}
size="fill" size="fill"
alt={title} alt={title}
class="rounded-art transition duration-300 group-hover:scale-105" class="rounded-art transition duration-300 ease-snappy group-hover:scale-105"
/> />
{/if} {/if}
{#if onPlay} {#if onPlay}
@@ -50,12 +50,12 @@
size="sm" size="sm"
onclick={onPlay} onclick={onPlay}
label="Reproducir {title}" label="Reproducir {title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100" class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity group-hover:opacity-100"
/> />
{/if} {/if}
</div> </div>
<div class="mt-2.5 truncate text-sm text-fg">{title}</div> <div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#if subtitle} {#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div> <div class="mt-0.5 truncate text-xs text-fg-2">{subtitle}</div>
{/if} {/if}
</a> </a>
@@ -63,6 +63,6 @@
> >
{@render badge?.()} {@render badge?.()}
</div> </div>
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-3" /> <ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
</div> </div>
</div> </div>
@@ -26,12 +26,12 @@
}; };
const VARIANT: Record<'strong' | 'glass', string> = { const VARIANT: Record<'strong' | 'glass', string> = {
strong: 'bg-cta-strong text-ink hover:brightness-95', strong: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur' glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur'
}; };
const classes = $derived( const classes = $derived(
`grid shrink-0 place-items-center rounded-full transition duration-150 ${SIZE[size]} ${VARIANT[variant]} ${klass}` `grid shrink-0 place-items-center rounded-full transition ${SIZE[size]} ${VARIANT[variant]} ${klass}`
); );
</script> </script>
@@ -91,7 +91,7 @@
<div class="overflow-x-auto {klass}"> <div class="overflow-x-auto {klass}">
<div <div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3" class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}" style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
> >
{#if index} {#if index}
@@ -106,13 +106,14 @@
<span></span> <span></span>
{/if} {/if}
</div> </div>
<div class="mt-1 flex flex-col gap-0.5"> <div class="mt-1 flex flex-col gap-0.5" role="list">
{#each tracks as track, i (track.id)} {#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id} {@const active = player.current.id === track.id}
<div <div
role="listitem"
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
? 'bg-accent-tint' ? 'surface-active'
: 'hover:bg-hover'}" : 'hover:bg-hover'}"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}" style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined} oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
@@ -122,7 +123,7 @@
type="button" type="button"
onclick={() => onPlay(i)} onclick={() => onPlay(i)}
aria-label={active && player.playing ? 'Pausar' : 'Reproducir'} aria-label={active && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums" class="relative grid size-8 place-items-center rounded-tag text-xs text-fg-2 tabular-nums"
> >
{#if active} {#if active}
<EqBars paused={!player.playing} /> <EqBars paused={!player.playing} />
@@ -138,6 +139,7 @@
tabindex="0" tabindex="0"
use:pressable={() => onPlay(i)} use:pressable={() => onPlay(i)}
onclick={(event) => event.stopPropagation()} onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left" class="flex min-w-0 flex-1 text-left"
> >
<MediaIdentity <MediaIdentity
@@ -156,7 +158,7 @@
</MediaIdentity> </MediaIdentity>
</div> </div>
{#each columns as column (column)} {#each columns as column (column)}
<span class="hidden truncate text-center text-xs text-muted tabular-nums sm:block"> <span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
{#if column === 'plays'} {#if column === 'plays'}
{Number(track.listensCount ?? 0)} {Number(track.listensCount ?? 0)}
{:else if column === 'added' && track.addedAt} {:else if column === 'added' && track.addedAt}
@@ -166,7 +168,7 @@
{/if} {/if}
</span> </span>
{/each} {/each}
<span class="truncate text-center text-xs text-muted tabular-nums"> <span class="truncate text-center text-xs text-fg-2 tabular-nums">
{fmtTime(Number(track.duration))} {fmtTime(Number(track.duration))}
</span> </span>
{#if rowAction} {#if rowAction}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import Modal from './Modal.svelte'; import Dialog from './Dialog.svelte';
import Button from '../primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
interface Props { interface Props {
@@ -14,13 +14,11 @@
let { open, onClose, title, description, confirmLabel, action }: Props = $props(); let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script> </script>
<Modal {open} {onClose} maxWidth="max-w-sm"> <Dialog {open} {onClose} {title} {description}>
<h2 class="text-display-3 text-fg">{title}</h2> {#snippet actions()}
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={onClose}>Cancelar</Button> <Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}> <form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button> <Button type="submit" variant="danger">{confirmLabel}</Button>
</form> </form>
</div> {/snippet}
</Modal> </Dialog>
@@ -63,7 +63,7 @@
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover" class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
> >
<span class="flex-1 cursor-default">{playlistAction.label}</span> <span class="flex-1 cursor-default">{playlistAction.label}</span>
<ChevronRight class="size-icon-sm shrink-0 text-fg-3" /> <ChevronRight class="size-icon-sm shrink-0 text-fg-2" />
</div> </div>
<div <div
class="invisible absolute top-0 z-(--z-submenu) opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {openLeft class="invisible absolute top-0 z-(--z-submenu) opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {openLeft
@@ -75,7 +75,7 @@
> >
<div class="max-h-80 overflow-y-auto"> <div class="max-h-80 overflow-y-auto">
{#if playlists.length === 0} {#if playlists.length === 0}
<p class="px-2 py-2.5 text-sm text-fg-3">Todavía no tienes playlists.</p> <p class="px-2 py-2.5 text-sm text-fg-2">Todavía no tienes playlists.</p>
{/if} {/if}
{#each playlists as playlist (playlist.id)} {#each playlists as playlist (playlist.id)}
<form <form
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import Modal from './Modal.svelte';
interface Props {
open: boolean;
onClose: () => void;
title: string;
description?: string;
tone?: 'neutral' | 'danger';
children?: Snippet;
actions?: Snippet;
}
let { open, onClose, title, description, tone = 'neutral', children, actions }: Props = $props();
</script>
<Modal
{open}
{onClose}
maxWidth="max-w-sm"
panelClass={tone === 'danger' ? 'glass-panel-danger' : ''}
>
{#if tone === 'danger'}
<span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg">
<CircleAlert class="size-icon-md" strokeWidth={2} />
</span>
{/if}
<h2 class="text-display-3 text-fg">{title}</h2>
{#if description}
<p class="mt-2.5 text-body text-fg-2">{description}</p>
{/if}
{@render children?.()}
{#if actions}
<div class="mt-6 flex flex-wrap items-center justify-end gap-3">
{@render actions()}
</div>
{/if}
</Modal>
@@ -0,0 +1,34 @@
<script lang="ts">
import Dialog from './Dialog.svelte';
import Button from '../primitives/Button.svelte';
import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte';
// Keep the last dialog's content so the exit transition doesn't render an empty panel.
let last = $state<DialogOptions | null>(null);
$effect.pre(() => {
if (dialog.current) last = dialog.current;
});
const current = $derived(dialog.current ?? last);
const buttons: DialogAction[] = $derived(
current?.actions?.length ? current.actions : [{ label: 'Entendido' }]
);
</script>
<Dialog
open={dialog.current !== null}
onClose={() => dialog.close()}
title={current?.title ?? ''}
description={current?.description}
tone={current?.tone}
>
{#snippet actions()}
{#each buttons as action, i (action.label)}
<Button
variant={action.variant ?? (i === buttons.length - 1 ? 'primary' : 'secondary')}
onclick={() => dialog.run(action)}
>
{action.label}
</Button>
{/each}
{/snippet}
</Dialog>
@@ -12,7 +12,9 @@
</script> </script>
<div <div
class="glass-panel animate-pop rounded-panel p-1.5 {border ? 'border border-hairline' : ''} {klass}" class="glass-panel animate-pop rounded-panel p-1.5 {border
? 'border border-hairline'
: ''} {klass}"
{style} {style}
> >
{@render children()} {@render children()}
@@ -2,6 +2,9 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte'; import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition';
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -45,6 +48,15 @@
); );
} }
function pop(node: Element, { duration = 200 }: { duration?: number } = {}): TransitionConfig {
return {
duration,
easing: expoOut,
css: (t) =>
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
function onWindowKeydown(event: KeyboardEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (!open) return; if (!open) return;
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -70,13 +82,15 @@
{#if open} {#if open}
<div <div
class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm" transition:fade={{ duration: 200 }}
class="fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
role="presentation" role="presentation"
onclick={onBackdropClick} onclick={onBackdropClick}
> >
<div <div
bind:this={panel} bind:this={panel}
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}" transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
@@ -86,7 +100,7 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
{#if eyebrow} {#if eyebrow}
<p class="mb-2.5 text-eyebrow text-muted"> <p class="mb-2.5 text-eyebrow text-fg-2">
{eyebrow} {eyebrow}
</p> </p>
{/if} {/if}
@@ -24,7 +24,7 @@
<a <a
href={target.url} href={target.url}
{onclick} {onclick}
class="inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft" class="inline-flex items-center text-fg-2 transition-colors hover:text-accent-soft"
> >
<span class="grid size-8 place-items-center"> <span class="grid size-8 place-items-center">
<ArrowLeft class="size-icon-sm" /> <ArrowLeft class="size-icon-sm" />
@@ -29,20 +29,20 @@
}: Props = $props(); }: Props = $props();
const sizes = { const sizes = {
sm: 'px-4 py-2.5 text-xs', sm: 'rounded-control px-4 py-2 text-xs',
md: 'px-5 py-3 text-sm', md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
lg: 'px-6 py-3.5 text-sm' lg: 'rounded-btn px-6 py-3.5 text-sm'
}; };
const variants = { const variants = {
primary: 'bg-cta-strong text-ink hover:brightness-95', primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
secondary: 'bg-btn text-fg hover:bg-btn-hover', secondary: 'bg-btn text-fg hover:bg-btn-hover',
accent: 'bg-accent-tint text-accent-soft hover:bg-accent-btn-hover', accent: 'surface-active text-accent-soft hover:surface-active-hover',
danger: 'bg-danger text-on-art hover:brightness-110' danger: 'bg-danger text-on-art hover:brightness-110'
}; };
const classes = $derived( const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control btn-transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}` `inline-flex items-center justify-center gap-2 font-medium btn-transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
); );
</script> </script>
@@ -15,9 +15,7 @@
href 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' ? '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-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${ : `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
selected selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
? 'bg-cta-strong text-ink'
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'
}` }`
); );
</script> </script>
@@ -25,7 +23,7 @@
{#snippet content()} {#snippet content()}
{@render children()} {@render children()}
{#if count !== undefined} {#if count !== undefined}
<span class="tabular-nums {selected ? 'opacity-55' : 'text-muted'}">{count}</span> <span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
{/if} {/if}
{/snippet} {/snippet}
@@ -20,7 +20,7 @@
{/if} {/if}
{@render children()} {@render children()}
{#if hint} {#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted"> <div class="mt-2 flex items-center justify-between gap-3 text-xs text-fg-2">
{@render hint()} {@render hint()}
</div> </div>
{/if} {/if}
@@ -41,7 +41,7 @@
}; };
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = { const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
muted: 'text-muted hover:text-fg', muted: 'text-fg-2 hover:text-fg',
subtle: 'text-fg-2 hover:text-fg', subtle: 'text-fg-2 hover:text-fg',
plain: 'text-fg-2 hover:text-fg' plain: 'text-fg-2 hover:text-fg'
}; };
@@ -34,6 +34,6 @@
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8"> <div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading} {#if loading}
<LoaderCircle class="size-5 animate-spin text-muted" /> <LoaderCircle class="size-5 animate-spin text-fg-2" />
{/if} {/if}
</div> </div>
@@ -36,7 +36,7 @@
<div class="relative w-full"> <div class="relative w-full">
{#if Icon} {#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted"> <span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-fg-2">
<Icon class="size-icon-lg" /> <Icon class="size-icon-lg" />
</span> </span>
{/if} {/if}
@@ -16,7 +16,7 @@
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1"> <div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<div <div
class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-300 ease-out" class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-200 ease-snappy"
style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))" style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))"
></div> ></div>
{#each options as option (option.value)} {#each options as option (option.value)}
@@ -26,7 +26,7 @@
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value === class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
option.value option.value
? 'text-ink' ? 'text-ink'
: 'text-muted hover:text-fg-2'}" : 'text-fg-2 hover:text-fg-2'}"
> >
{option.label} {option.label}
</button> </button>
+15 -6
View File
@@ -2,6 +2,7 @@ import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color'; import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette'; import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections'; import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte';
export interface PlayerTrack { export interface PlayerTrack {
id: string | number; id: string | number;
@@ -119,7 +120,6 @@ class PlayerState {
progress = $state(0); progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100); volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
loading = $state(false); loading = $state(false);
error = $state('');
recentlyPlayed = $state<PlayerTrack[]>([]); recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]); playlists = $state<Playlist[]>([]);
shuffle = $state(false); shuffle = $state(false);
@@ -173,7 +173,7 @@ class PlayerState {
this.#stopProgressLoop(); this.#stopProgressLoop();
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
this.error = 'No se pudo reproducir la pista.'; this.#fail();
}); });
this.#audio = audio; this.#audio = audio;
this.#setupMediaSession(audio); this.#setupMediaSession(audio);
@@ -268,7 +268,6 @@ class PlayerState {
if (track) this.#syncMediaSessionMetadata(track); if (track) this.#syncMediaSessionMetadata(track);
const token = ++this.#loadToken; const token = ++this.#loadToken;
this.loading = true; this.loading = true;
this.error = '';
this.progress = 0; this.progress = 0;
try { try {
const src = await this.#resolveLocalSrc(String(id)); const src = await this.#resolveLocalSrc(String(id));
@@ -283,13 +282,23 @@ class PlayerState {
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
this.error = if (exception instanceof DOMException && exception.name === 'AbortError') return;
exception instanceof Error && exception.message this.#fail(
exception instanceof Error && !(exception instanceof DOMException)
? exception.message ? exception.message
: 'No se pudo reproducir la pista.'; : undefined
);
} }
} }
#fail(detail?: string) {
dialog.error(
'No se pudo reproducir la pista',
detail || 'El archivo no está disponible o su formato no es compatible.',
[{ label: 'Cerrar' }]
);
}
async #resolveLocalSrc(id: string): Promise<string> { async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`); const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res)); if (!res.ok) throw new Error(await readErrorMessage(res));
+35
View File
@@ -0,0 +1,35 @@
export interface DialogAction {
label: string;
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
onclick?: () => void;
}
export interface DialogOptions {
title: string;
description?: string;
tone?: 'neutral' | 'danger';
actions?: DialogAction[];
}
class DialogStore {
current = $state<DialogOptions | null>(null);
show(options: DialogOptions) {
this.current = options;
}
error(title: string, description?: string, actions?: DialogAction[]) {
this.show({ title, description, tone: 'danger', actions });
}
close() {
this.current = null;
}
run(action: DialogAction) {
this.close();
action.onclick?.();
}
}
export const dialog = new DialogStore();
+4 -1
View File
@@ -1,4 +1,7 @@
import { contextMenuPosition, type MenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte'; import {
contextMenuPosition,
type MenuPosition
} from '$lib/components/ui/overlay/ContextMenu.svelte';
import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte'; import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte';
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition; export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
+2 -2
View File
@@ -1,5 +1,5 @@
export const ACCENT_LIGHTNESS = 74; export const ACCENT_LIGHTNESS = 74;
export const ACCENT_CHROMA = 0.145; export const ACCENT_CHROMA = 0.06;
export const ACCENT_HUE = 145; export const ACCENT_HUE = 30;
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`; export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
+23 -14
View File
@@ -7,14 +7,14 @@
--mf-tile-shade-c: 0.035; --mf-tile-shade-c: 0.035;
--mf-text: #d9d8dc; --mf-text: #d9d8dc;
--mf-text-2: #8a8a8e; --mf-text-2: #b1b0b5;
--mf-text-3: #7d7d81;
--mf-text-4: #646468;
--mf-surface: rgba(255, 255, 255, 0.022); --mf-surface: rgba(255, 255, 255, 0.022);
--mf-surface-2: rgba(255, 255, 255, 0.045); --mf-surface-2: rgba(255, 255, 255, 0.055);
--mf-surface-hover: rgba(255, 255, 255, 0.06); --mf-surface-hover: rgba(255, 255, 255, 0.06);
--mf-btn-bg-hover: var(--mf-surface-hover); --mf-row-hover: rgba(255, 255, 255, 0.035);
--mf-btn-bg: rgba(255, 255, 255, 0.055);
--mf-btn-bg-hover: rgba(255, 255, 255, 0.09);
--mf-border: rgba(255, 255, 255, 0.04); --mf-border: rgba(255, 255, 255, 0.04);
--mf-border-strong: rgba(255, 255, 255, 0.09); --mf-border-strong: rgba(255, 255, 255, 0.09);
@@ -24,7 +24,14 @@
--mf-on-art: #fefdfb; --mf-on-art: #fefdfb;
--mf-on-art-2: #cac8c3; --mf-on-art-2: #cac8c3;
--mf-ink: #0a0a0d; --mf-ink: #0a0a0d;
--mf-cta-strong-bg: #c4c4c8; --mf-cta-strong-bg: #c6c4cb;
--mf-cta-strong-hover: #d8d7dc;
--mf-danger-fg: oklch(0.885 0.062 18.334);
--mf-motion-fast: 150ms;
--mf-motion-base: 200ms;
--mf-motion-slow: 300ms;
--mf-ease: cubic-bezier(0.16, 1, 0.3, 1);
--mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); --mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
@@ -71,15 +78,15 @@
} }
:root[data-theme='light'] { :root[data-theme='light'] {
--mf-text: #17171a; --mf-text: #17161c;
--mf-text-2: #4a4a4e; --mf-text-2: #5d5c66;
--mf-text-3: #6b6b6f;
--mf-text-4: #8c8c90;
--mf-surface: rgba(0, 0, 0, 0.035); --mf-surface: rgba(0, 0, 0, 0.035);
--mf-surface-2: rgba(0, 0, 0, 0.06); --mf-surface-2: rgba(0, 0, 0, 0.06);
--mf-surface-hover: #e8eceb; --mf-surface-hover: rgba(26, 24, 38, 0.06);
--mf-btn-bg-hover: var(--mf-surface-hover); --mf-row-hover: rgba(26, 24, 38, 0.04);
--mf-btn-bg: rgba(255, 255, 255, 0.7);
--mf-btn-bg-hover: #ffffff;
--mf-scrollbar: rgba(0, 0, 0, 0.18); --mf-scrollbar: rgba(0, 0, 0, 0.18);
--mf-scrollbar-hover: rgba(0, 0, 0, 0.32); --mf-scrollbar-hover: rgba(0, 0, 0, 0.32);
@@ -89,8 +96,10 @@
--mf-track: rgba(0, 0, 0, 0.12); --mf-track: rgba(0, 0, 0, 0.12);
--mf-scrim: #ffffff; --mf-scrim: #ffffff;
--mf-ink: #ffffff; --mf-ink: #f7f6f9;
--mf-cta-strong-bg: #17171a; --mf-cta-strong-bg: #1b1a20;
--mf-cta-strong-hover: #2e2d36;
--mf-danger-fg: oklch(0.5 0.16 25);
--mf-tile-l: 0.58; --mf-tile-l: 0.58;
--mf-tile-c: 0.085; --mf-tile-c: 0.085;
+45 -44
View File
@@ -2,70 +2,71 @@ function r(value: number, digits = 4): string {
return value.toFixed(digits); return value.toFixed(digits);
} }
function tc(alpha: number, l: number, hue: number): string {
const lightness = 0.37 - l / 380;
const chroma = Math.max(0, 0.15 - l / 1600);
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
}
function tcLight(alpha: number, l: number, hue: number): string {
const depth = (88 - l) / 58;
const lightness = 0.9 - 0.14 * depth;
const chroma = 0.05 + 0.13 * depth;
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
}
export interface ThemeTokens { export interface ThemeTokens {
[cssVar: string]: string; [cssVar: string]: string;
} }
export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens { export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens {
const h = ((hue % 360) + 360) % 360; const h = ((hue % 360) + 360) % 360;
const h2 = (h + 40) % 360;
const H = r(h, 1); const H = r(h, 1);
const H2 = r(h2, 1);
const light = mode === 'light'; const light = mode === 'light';
const accentL = light ? 0.8 : 0.8; const accentL = light ? 0.5 : 0.72;
const accentC = light ? 0.4 : 0.2; const accentC = light ? 0.07 : 0.06;
const titleL = light ? 0.5 : 0.8; const titleL = light ? 0.36 : 0.86;
const titleC = light ? 0.1 : 0.05; const titleC = light ? 0.06 : 0.03;
const mutedL = light ? 0.48 : 0.79; const mutedL = light ? 0.42 : 0.8;
const mutedC = light ? 0.06 : 0.035; const mutedC = light ? 0.05 : 0.02;
const accent = `oklch(${r(accentL)} ${r(accentC)} ${H})`;
const g = (l1: number, c1: number, a1: number, l2: number, c2: number, a2: number) =>
`linear-gradient(90deg, oklch(${l1} ${c1} ${H} / ${a1}), oklch(${l2} ${c2} ${H} / ${a2}))`;
const gActive = light
? g(0.82, 0.05, 0.55, 0.87, 0.035, 0.36)
: g(0.46, 0.06, 0.34, 0.4, 0.045, 0.22);
const gHover = light
? g(0.86, 0.035, 0.38, 0.9, 0.025, 0.24)
: g(0.42, 0.045, 0.26, 0.38, 0.035, 0.17);
const glow = (l: number, c: number, a: number) => `oklch(${l} ${c} ${H} / ${a})`;
// Fixed danger hue so error dialogs read red whatever the theme hue is.
const dangerGlow = (l: number, c: number, a: number) => `oklch(${l} ${c} 25 / ${a})`;
return { return {
'--mf-accent': `oklch(${r(accentL)} ${r(accentC)} ${H})`, '--mf-accent': accent,
'--mf-accent-title': `oklch(${r(titleL)} ${r(titleC)} ${H})`, '--mf-accent-title': `oklch(${r(titleL)} ${r(titleC)} ${H})`,
'--mf-accent-muted': `oklch(${r(mutedL)} ${r(mutedC)} ${H})`, '--mf-accent-muted': `oklch(${r(mutedL)} ${r(mutedC)} ${H})`,
'--mf-accent-soft-bg': `color-mix(in srgb, oklch(${r(accentL)} ${r(accentC)} ${H}) 8%, var(--mf-bg))`, '--mf-accent-soft-bg': `color-mix(in srgb, ${accent} 12%, var(--mf-bg))`,
'--mf-accent-line': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.2)`, '--mf-accent-line': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.2)`,
'--mf-accent-hair': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.09)`, '--mf-accent-hair': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.09)`,
'--mf-accent-btn-bg': `color-mix(in srgb, oklch(${r(accentL)} ${r(accentC)} ${H}) 15%, var(--mf-bg))`, '--mf-accent-btn-bg': `color-mix(in srgb, ${accent} 18%, var(--mf-bg))`,
'--mf-accent-btn-bg-hover': `color-mix(in srgb, oklch(${r(accentL)} ${r(accentC)} ${H}) 24%, var(--mf-bg))`, '--mf-accent-btn-bg-hover': `color-mix(in srgb, ${accent} 26%, var(--mf-bg))`,
'--mf-bg': light ? `oklch(0.97 0.014 ${H})` : `oklch(0.075 0.02 ${H})`, '--mf-g-active': gActive,
'--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.025 ${H})`, '--mf-g-hover': gHover,
'--mf-panel-bg': light ? `oklch(0.97 0.014 ${H} / 0.7)` : `oklch(0.1 0.022 ${H} / 0.28)`, '--mf-bg': light ? `oklch(0.975 0.012 ${H})` : `oklch(0.068 0.008 ${H})`,
'--mf-bar-bg': light ? `oklch(0.97 0.014 ${H} / 0.9)` : `oklch(0.115 0.025 ${H} / 0.9)`, '--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.012 ${H})`,
'--mf-hairline': light ? `oklch(0.3 0.035 ${H} / 0.1)` : `oklch(0.62 0.05 ${H} / 0.08)`, '--mf-panel-bg': light ? 'rgba(255, 255, 255, 0.28)' : 'rgba(0, 0, 0, 0.16)',
'--mf-bar-bg': light ? `oklch(0.99 0.01 ${H} / 0.86)` : `oklch(0.105 0.012 ${H} / 0.9)`,
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.06)` : `oklch(0.62 0.02 ${H} / 0.05)`,
'--mf-ambient': light '--mf-ambient': light
? 'none' ? `radial-gradient(80% 70% at 12% 0%, ${glow(0.9, 0.05, 0.55)}, transparent 75%), radial-gradient(70% 60% at 88% 8%, ${glow(0.92, 0.035, 0.4)}, transparent 75%), radial-gradient(90% 70% at 50% 110%, ${glow(0.93, 0.03, 0.35)}, transparent 80%), linear-gradient(180deg, ${glow(0.96, 0.02, 0.4)}, transparent 60%)`
: `radial-gradient(95% 85% at 20% 0%, ${tc(0.12, 30, h)}, transparent 88%), radial-gradient(90% 80% at 80% 0%, ${tc(0.09, 40, h)}, transparent 88%)`, : `radial-gradient(75% 65% at 12% 0%, ${glow(0.3, 0.045, 0.5)}, transparent 75%), radial-gradient(65% 55% at 85% 5%, ${glow(0.24, 0.035, 0.35)}, transparent 75%), radial-gradient(90% 70% at 50% 105%, ${glow(0.18, 0.025, 0.3)}, transparent 80%), linear-gradient(180deg, ${glow(0.14, 0.02, 0.35)}, transparent 55%)`,
'--mf-hero-tint': light ? tcLight(1, 55, h) : tc(1, 42, h), '--mf-hero-tint': light ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`,
'--mf-hero-bg': light '--mf-hero-bg': light
? `linear-gradient(${tcLight(0.1, 55, h)}, ${tcLight(0.1, 55, h)})` ? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.96, 0.02, 0.4)} 60%)`
: `linear-gradient(${tc(0.1, 38, h)}, ${tc(0.1, 38, h)})`, : `linear-gradient(120deg, ${glow(0.24, 0.04, 0.55)}, ${glow(0.14, 0.02, 0.35)} 60%, ${glow(0.12, 0.015, 0.25)})`,
'--mf-modal-glow': light '--mf-modal-glow': light
? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.2, 55, h)} 30%, ${tcLight(0.1, 70, h)} 55%, ${tcLight(0.04, 85, h)} 80%, ${tcLight(0.02, 88, h)} 100%)` ? `linear-gradient(135deg, ${glow(0.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 28%, ${tc(0.03, 88, h)} 50%, rgba(6,6,9,0) 70%)`, : `linear-gradient(135deg, ${glow(0.3, 0.045, 0.4)} 0%, ${glow(0.2, 0.03, 0.15)} 35%, transparent 70%)`,
'--mf-modal-glow-danger': light
? `linear-gradient(135deg, ${dangerGlow(0.86, 0.07, 0.55)} 0%, ${dangerGlow(0.92, 0.04, 0.22)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${dangerGlow(0.34, 0.1, 0.45)} 0%, ${dangerGlow(0.22, 0.06, 0.18)} 35%, transparent 70%)`,
'--mf-spotlight-bg': light '--mf-spotlight-bg': light
? `linear-gradient(${tcLight(0.1, 55, h)}, ${tcLight(0.1, 55, h)})` ? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)`
: `linear-gradient(${tc(0.1, 38, h)}, ${tc(0.1, 38, h)})`, : `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`,
'--mf-logo-grad': light '--mf-logo-grad': `linear-gradient(${accent}, ${accent})`,
? `linear-gradient(140deg, oklch(0.74 0.18 ${H}), oklch(0.58 0.16 ${H2}) 92%)`
: `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`,
'--mf-cover-grad': light '--mf-cover-grad': light
? `linear-gradient(150deg, oklch(0.7 0.16 ${H}), oklch(0.54 0.11 ${H}))` ? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))`
: `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))` : `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))`
}; };
} }
@@ -78,9 +78,7 @@
{/if} {/if}
</Button> </Button>
{#if isOwner} {#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}> <Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}> <Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
Eliminar Eliminar
</Button> </Button>
@@ -249,7 +249,7 @@
{/if} {/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3"> <div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
<div class="text-eyebrow leading-none font-semibold text-fg-3"> <div class="text-eyebrow leading-none font-semibold text-fg-2">
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]} Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
</div> </div>
<div <div
@@ -264,7 +264,7 @@
: topResult.user.name} : topResult.user.name}
</div> </div>
{#if topResult.kind === 'track' && topResult.track.artist} {#if topResult.kind === 'track' && topResult.track.artist}
<div class="truncate text-sm leading-none text-fg-3"> <div class="truncate text-sm leading-none text-fg-2">
{topResult.track.artist} {topResult.track.artist}
</div> </div>
{/if} {/if}
@@ -276,6 +276,7 @@
<svelte:element <svelte:element
this={topResult.kind === 'track' ? 'button' : 'a'} this={topResult.kind === 'track' ? 'button' : 'a'}
type={topResult.kind === 'track' ? 'button' : undefined} type={topResult.kind === 'track' ? 'button' : undefined}
role={topResult.kind === 'track' ? 'button' : 'link'}
href={topResult.kind === 'track' ? undefined : topResultHref} href={topResult.kind === 'track' ? undefined : topResultHref}
onclick={topResult.kind === 'track' ? playTopResult : undefined} onclick={topResult.kind === 'track' ? playTopResult : undefined}
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover" class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
@@ -348,7 +349,7 @@
{/if} {/if}
{/snippet} {/snippet}
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtTime(seconds)} · {fmtPlays(track.listensCount)} {fmtTime(seconds)} · {fmtPlays(track.listensCount)}
</span> </span>
{/snippet} {/snippet}
@@ -383,7 +384,7 @@
oncontextmenu={(e) => openAlbumMenu(e, album.id)} oncontextmenu={(e) => openAlbumMenu(e, album.id)}
> >
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{Number(album.trackCount)} canciones {Number(album.trackCount)} canciones
</span> </span>
{/snippet} {/snippet}
@@ -67,9 +67,7 @@
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
<Button size="sm" variant="secondary" onclick={() => (editing = true)}> <Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}> <Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
Eliminar Eliminar
</Button> </Button>
@@ -123,7 +123,7 @@
</span> </span>
<span class="text-sm font-medium text-fg">{step.label}</span> <span class="text-sm font-medium text-fg">{step.label}</span>
</div> </div>
<p class="mt-2.5 text-xs text-muted">{step.hint}</p> <p class="mt-2.5 text-xs text-fg-2">{step.hint}</p>
</Surface> </Surface>
</li> </li>
{/each} {/each}
@@ -191,7 +191,7 @@
aria-label="Seleccionar archivo de audio" aria-label="Seleccionar archivo de audio"
/> />
{#if audioName === ''} {#if audioName === ''}
<Headphones class="size-9 text-muted sm:size-10" strokeWidth={1.4} /> <Headphones class="size-9 text-fg-2 sm:size-10" strokeWidth={1.4} />
{:else} {:else}
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} /> <Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} />
{/if} {/if}
@@ -199,12 +199,12 @@
<p class="mt-4 text-sm font-medium text-fg"> <p class="mt-4 text-sm font-medium text-fg">
Arrastra tu audio aquí o haz clic para elegir Arrastra tu audio aquí o haz clic para elegir
</p> </p>
<p class="mt-1.5 text-xs text-muted">MP3, FLAC, WAV…</p> <p class="mt-1.5 text-xs text-fg-2">MP3, FLAC, WAV…</p>
{:else} {:else}
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg"> <p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
{audioName} {audioName}
</p> </p>
<p class="mt-1.5 text-xs text-muted">{formatSize(audioSize)} · Listo para subir</p> <p class="mt-1.5 text-xs text-fg-2">{formatSize(audioSize)} · Listo para subir</p>
{/if} {/if}
</div> </div>
@@ -251,7 +251,7 @@
<Surface padding="sm"> <Surface padding="sm">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="font-medium text-fg">Subiendo y creando la pista…</span> <span class="font-medium text-fg">Subiendo y creando la pista…</span>
<span class="text-xs text-muted">un momento</span> <span class="text-xs text-fg-2">un momento</span>
</div> </div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2"> <div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div> <div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
+4
View File
@@ -10,6 +10,7 @@
import TopBar from '$lib/components/layout/TopBar.svelte'; import TopBar from '$lib/components/layout/TopBar.svelte';
import MobileHeader from '$lib/components/layout/MobileHeader.svelte'; import MobileHeader from '$lib/components/layout/MobileHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte'; import Modal from '$lib/components/ui/overlay/Modal.svelte';
import DialogHost from '$lib/components/ui/overlay/DialogHost.svelte';
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte'; import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
@@ -87,6 +88,7 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS generado a partir de números, sin entrada de usuario -->
{@html `<style>${initialThemeCss}</style>`} {@html `<style>${initialThemeCss}</style>`}
</svelte:head> </svelte:head>
@@ -134,3 +136,5 @@
</Modal> </Modal>
</div> </div>
{/if} {/if}
<DialogHost />
+6 -8
View File
@@ -134,9 +134,7 @@
</svelte:head> </svelte:head>
<Page> <Page>
<div <div class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 sm:px-9 sm:py-8.5">
class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 sm:px-9 sm:py-8.5"
>
<div <div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between" class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
> >
@@ -173,7 +171,7 @@
{#each statsList as stat (stat.label)} {#each statsList as stat (stat.label)}
<div> <div>
<div class="text-display-2 text-fg">{stat.value}</div> <div class="text-display-2 text-fg">{stat.value}</div>
<div class="mt-1 text-xs text-fg-3">{stat.label}</div> <div class="mt-1 text-xs text-fg-2">{stat.label}</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -245,7 +243,7 @@
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button> <Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6"> <div class="flex flex-col gap-2 p-5.5 sm:p-6">
{#each spotlightRows as { track, seconds }, i (track.id)} {#each spotlightRows as { track, seconds }, i (track.id)}
<ListRow <ListRow
onclick={() => playSpotlightTrack(i)} onclick={() => playSpotlightTrack(i)}
@@ -265,7 +263,7 @@
{/snippet} {/snippet}
</ListRow> </ListRow>
{:else} {:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p> <p class="p-2 text-sm text-fg-2">Esta playlist todavía no tiene canciones.</p>
{/each} {/each}
</div> </div>
</div> </div>
@@ -318,12 +316,12 @@
{#if isCurrent} {#if isCurrent}
<EqBars size={13} paused={!player.playing} /> <EqBars size={13} paused={!player.playing} />
{:else} {:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span> <span class="text-xs text-fg-2 tabular-nums">{i + 1}</span>
{/if} {/if}
</span> </span>
{/snippet} {/snippet}
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtPlays(track.listensCount)} {fmtPlays(track.listensCount)}
</span> </span>
{/snippet} {/snippet}
+32 -18
View File
@@ -9,15 +9,13 @@
--color-surface: var(--mf-surface); --color-surface: var(--mf-surface);
--color-surface-2: var(--mf-surface-2); --color-surface-2: var(--mf-surface-2);
--color-surface-hover: var(--mf-surface-hover); --color-surface-hover: var(--mf-surface-hover);
--color-hover: var(--mf-surface-2); --color-hover: var(--mf-row-hover);
--color-line: var(--mf-border); --color-line: var(--mf-border);
--color-line-strong: var(--mf-border-strong); --color-line-strong: var(--mf-border-strong);
--color-hairline: var(--mf-hairline); --color-hairline: var(--mf-hairline);
--color-track: var(--mf-track); --color-track: var(--mf-track);
--color-fg: var(--mf-text); --color-fg: var(--mf-text);
--color-fg-2: var(--mf-text-2); --color-fg-2: var(--mf-text-2);
--color-fg-3: var(--mf-text-3);
--color-muted: var(--mf-text-4);
--color-accent: var(--mf-accent); --color-accent: var(--mf-accent);
--color-accent-soft: var(--mf-accent-title); --color-accent-soft: var(--mf-accent-title);
--color-accent-tint: var(--mf-accent-soft-bg); --color-accent-tint: var(--mf-accent-soft-bg);
@@ -29,17 +27,17 @@
--color-on-art: var(--mf-on-art); --color-on-art: var(--mf-on-art);
--color-on-art-2: var(--mf-on-art-2); --color-on-art-2: var(--mf-on-art-2);
--color-danger: oklch(63.7% 0.15 25.331); --color-danger: oklch(63.7% 0.15 25.331);
--color-danger-fg: oklch(88.5% 0.062 18.334); --color-danger-fg: var(--mf-danger-fg);
--color-btn: color-mix( --color-btn: var(--mf-btn-bg);
in srgb,
var(--mf-elevated) 45%,
color-mix(in srgb, var(--mf-hero-tint) 8%, var(--mf-bg))
);
--color-btn-hover: var(--mf-btn-bg-hover); --color-btn-hover: var(--mf-btn-bg-hover);
--color-cta-strong-hover: var(--mf-cta-strong-hover);
--color-row-hover: var(--mf-row-hover);
--radius-tag: 0.25rem; --radius-tag: 0.25rem;
--radius-thumb: 0.5rem; --radius-thumb: 0.5rem;
--radius-control: 0.625rem; --radius-control: 0.625rem;
--radius-btn-sm: 0.6875rem;
--radius-btn: 0.75rem;
--radius-art: 0.8125rem; --radius-art: 0.8125rem;
--radius-art-lg: 1.125rem; --radius-art-lg: 1.125rem;
--radius-panel: 1.125rem; --radius-panel: 1.125rem;
@@ -66,6 +64,11 @@
--container-search: 30rem; --container-search: 30rem;
--container-cta: 20rem; --container-cta: 20rem;
--text-control-sm: 0.78125rem;
--ease-snappy: var(--mf-ease);
--default-transition-duration: var(--mf-motion-fast);
--tracking-display: -0.02em; --tracking-display: -0.02em;
--tracking-eyebrow: 0.1em; --tracking-eyebrow: 0.1em;
--leading-body: 1.65; --leading-body: 1.65;
@@ -122,7 +125,8 @@
} }
@utility btn-transition { @utility btn-transition {
transition-property: color, background-color, border-color, outline-color, opacity, box-shadow, filter; transition-property:
color, background-color, border-color, outline-color, opacity, box-shadow, filter;
transition-timing-function: var(--default-transition-timing-function); transition-timing-function: var(--default-transition-timing-function);
transition-duration: var(--default-transition-duration); transition-duration: var(--default-transition-duration);
} }
@@ -184,7 +188,7 @@
@utility app-main { @utility app-main {
--mf-dock-h: 0px; --mf-dock-h: 0px;
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h)); padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h));
transition: padding 0.3s; transition: padding var(--mf-motion-slow) var(--mf-ease);
&[data-has-track] { &[data-has-track] {
--mf-dock-h: var(--mf-player-h); --mf-dock-h: var(--mf-player-h);
@@ -197,6 +201,12 @@
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue)) oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue))
); );
} }
@utility surface-active {
background-image: var(--mf-g-active);
}
@utility surface-active-hover {
background-image: var(--mf-g-hover);
}
@utility hero-surface { @utility hero-surface {
background-image: var(--mf-hero-bg); background-image: var(--mf-hero-bg);
} }
@@ -211,7 +221,7 @@
); );
} }
@utility field-control { @utility field-control {
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none; @apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-fg-2 focus:bg-surface-2 focus:outline-none;
} }
@layer base { @layer base {
@@ -258,7 +268,7 @@ button:disabled {
@keyframes pop-in { @keyframes pop-in {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.96) translateY(0.5rem); transform: scale(0.98) translateY(0.25rem);
} }
to { to {
opacity: 1; opacity: 1;
@@ -307,16 +317,16 @@ button:disabled {
} }
.animate-enter { .animate-enter {
animation: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; animation: fade-up var(--mf-motion-slow) var(--mf-ease) both;
animation-delay: calc(min(var(--i, 0), 10) * 45ms); animation-delay: calc(min(var(--i, 0), 10) * 40ms);
} }
.animate-pop { .animate-pop {
animation: pop-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; animation: pop-in var(--mf-motion-base) var(--mf-ease) both;
} }
.animate-fade { .animate-fade {
animation: fade-in 0.4s ease both; animation: fade-in var(--mf-motion-base) var(--mf-ease) both;
} }
.glass-panel { .glass-panel {
@@ -324,7 +334,11 @@ button:disabled {
background-image: var(--mf-modal-glow); background-image: var(--mf-modal-glow);
backdrop-filter: blur(22px); backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
transition: background 0.6s; transition: background var(--mf-motion-slow) var(--mf-ease);
}
.glass-panel.glass-panel-danger {
background-image: var(--mf-modal-glow-danger);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {