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
@@ -6,7 +6,6 @@
import type { SessionUser } from '$lib/types';
import ListRow from '../ui/media/ListRow.svelte';
import IconButton from '../ui/primitives/IconButton.svelte';
import Logo from '../ui/primitives/Logo.svelte';
import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist {
@@ -40,14 +39,14 @@
<a
href={link.href}
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'
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="size-icon-md 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>
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
{/if}
</a>
{/each}
@@ -56,7 +55,7 @@
{#if user}
<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">
<span class="text-eyebrow text-muted">Tus playlists</span>
<span class="text-eyebrow text-fg-2">Tus playlists</span>
{#if playlists.length > 0}
<IconButton
label="Crear playlist"
@@ -64,7 +63,7 @@
size="xs"
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>
{/if}
</div>
@@ -87,7 +86,7 @@
<button
type="button"
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} />
Crear tu primera lista
@@ -69,21 +69,21 @@
<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="size-4 shrink-0 text-fg-3" strokeWidth={1.8} />
<Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
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}
<button
type="button"
onclick={clearQuery}
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" />
</button>
@@ -32,7 +32,7 @@
<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-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)}
</span>
<Slider
@@ -42,13 +42,13 @@
oninput={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)}
</span>
</div>
</div>
<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
value={player.volume}
max={100}
@@ -1,7 +1,6 @@
<script lang="ts">
import PlayerBar from './PlayerBar.svelte';
import TabsBar from '../layout/TabsBar.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types';
@@ -17,11 +16,6 @@
<div
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}
<PlayerBar />
{/if}
@@ -7,7 +7,7 @@
import { fmtTime } from '$lib/utils/format';
import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { expoOut } from 'svelte/easing';
const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
@@ -17,7 +17,7 @@
{#if queuePanel.open}
<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"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
>
@@ -29,8 +29,8 @@
</div>
{#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p>
<div class="mb-6 rounded-control bg-accent-tint p-2.5">
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 rounded-control surface-active p-2.5">
<MediaIdentity
trackId={player.current.id}
title={player.current.title}
@@ -42,12 +42,12 @@
{/if}
<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}
<button
type="button"
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
</button>
@@ -68,13 +68,12 @@
>
{#snippet trailing()}
{#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}
{/snippet}
</ListRow>
{: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}
</div>
</aside>
@@ -49,7 +49,7 @@
<ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-3"
class="mt-0.5 text-xs text-fg-2"
/>
</div>
{#if !compact}
@@ -13,7 +13,7 @@
const sideClass = $derived(
compact
? '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>
@@ -24,7 +24,7 @@
onclick={() => player.toggleShuffle()}
aria-pressed={player.shuffle}
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} />
</button>
@@ -56,7 +56,7 @@
onclick={() => player.toggleRepeat()}
aria-pressed={player.repeat}
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} />
</button>
@@ -80,7 +80,7 @@
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-muted">{helperText}</p>
<p class="text-sm text-fg-2">{helperText}</p>
{:else}
<span></span>
{/if}
@@ -68,6 +68,6 @@
class="h-full w-full object-cover"
/>
{: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}
</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="min-w-0">
<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'
? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'}
@@ -30,21 +30,21 @@
{title}
</h2>
{#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}
{/snippet}
{#snippet autoActions()}
{#if count !== undefined}
<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')}
</span>
{/if}
{#if href}
<a
{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}
</a>
@@ -118,7 +118,7 @@
/>
{:else if fallback === 'music'}
<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
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>
<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"
aria-label="Contenido explícito">E</span
>
@@ -50,7 +50,7 @@
const classes = $derived(
`flex items-center gap-3 text-left transition-colors ${
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>
@@ -38,7 +38,7 @@
{trackIds}
size="fill"
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 onPlay}
@@ -50,12 +50,12 @@
size="sm"
onclick={onPlay}
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}
</div>
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#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}
</a>
@@ -63,6 +63,6 @@
>
{@render badge?.()}
</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>
@@ -26,12 +26,12 @@
};
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'
};
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>
@@ -91,7 +91,7 @@
<div class="overflow-x-auto {klass}">
<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}"
>
{#if index}
@@ -106,13 +106,14 @@
<span></span>
{/if}
</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)}
{@const active = player.current.id === track.id}
<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
? 'bg-accent-tint'
? 'surface-active'
: 'hover:bg-hover'}"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
@@ -122,7 +123,7 @@
type="button"
onclick={() => onPlay(i)}
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}
<EqBars paused={!player.playing} />
@@ -138,6 +139,7 @@
tabindex="0"
use:pressable={() => onPlay(i)}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
@@ -156,7 +158,7 @@
</MediaIdentity>
</div>
{#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'}
{Number(track.listensCount ?? 0)}
{:else if column === 'added' && track.addedAt}
@@ -166,7 +168,7 @@
{/if}
</span>
{/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))}
</span>
{#if rowAction}
@@ -1,5 +1,5 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Dialog from './Dialog.svelte';
import Button from '../primitives/Button.svelte';
interface Props {
@@ -14,13 +14,11 @@
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script>
<Modal {open} {onClose} maxWidth="max-w-sm">
<h2 class="text-display-3 text-fg">{title}</h2>
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Dialog {open} {onClose} {title} {description}>
{#snippet actions()}
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button>
</form>
</div>
</Modal>
{/snippet}
</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"
>
<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
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">
{#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}
{#each playlists as playlist (playlist.id)}
<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>
<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}
>
{@render children()}
@@ -2,6 +2,9 @@
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition';
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
interface Props {
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) {
if (!open) return;
if (event.key === 'Escape') {
@@ -70,13 +82,15 @@
{#if open}
<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"
onclick={onBackdropClick}
>
<div
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"
aria-modal="true"
aria-label={title}
@@ -86,7 +100,7 @@
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-2.5 text-eyebrow text-muted">
<p class="mb-2.5 text-eyebrow text-fg-2">
{eyebrow}
</p>
{/if}
@@ -24,7 +24,7 @@
<a
href={target.url}
{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">
<ArrowLeft class="size-icon-sm" />
@@ -29,20 +29,20 @@
}: Props = $props();
const sizes = {
sm: 'px-4 py-2.5 text-xs',
md: 'px-5 py-3 text-sm',
lg: 'px-6 py-3.5 text-sm'
sm: 'rounded-control px-4 py-2 text-xs',
md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
lg: 'rounded-btn px-6 py-3.5 text-sm'
};
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',
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'
};
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>
@@ -15,9 +15,7 @@
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-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'
selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
}`
);
</script>
@@ -25,7 +23,7 @@
{#snippet content()}
{@render children()}
{#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}
{/snippet}
@@ -20,7 +20,7 @@
{/if}
{@render children()}
{#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()}
</div>
{/if}
@@ -41,7 +41,7 @@
};
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',
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">
{#if loading}
<LoaderCircle class="size-5 animate-spin text-muted" />
<LoaderCircle class="size-5 animate-spin text-fg-2" />
{/if}
</div>
@@ -36,7 +36,7 @@
<div class="relative w-full">
{#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" />
</span>
{/if}
@@ -16,7 +16,7 @@
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<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%))"
></div>
{#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 ===
option.value
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
: 'text-fg-2 hover:text-fg-2'}"
>
{option.label}
</button>
+15 -6
View File
@@ -2,6 +2,7 @@ import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte';
export interface PlayerTrack {
id: string | number;
@@ -119,7 +120,6 @@ class PlayerState {
progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
loading = $state(false);
error = $state('');
recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
shuffle = $state(false);
@@ -173,7 +173,7 @@ class PlayerState {
this.#stopProgressLoop();
this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.';
this.#fail();
});
this.#audio = audio;
this.#setupMediaSession(audio);
@@ -268,7 +268,6 @@ class PlayerState {
if (track) this.#syncMediaSessionMetadata(track);
const token = ++this.#loadToken;
this.loading = true;
this.error = '';
this.progress = 0;
try {
const src = await this.#resolveLocalSrc(String(id));
@@ -283,13 +282,23 @@ class PlayerState {
if (token !== this.#loadToken) return;
this.loading = false;
this.playing = false;
this.error =
exception instanceof Error && exception.message
if (exception instanceof DOMException && exception.name === 'AbortError') return;
this.#fail(
exception instanceof Error && !(exception instanceof DOMException)
? 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> {
const res = await fetch(`/api/tracks/${id}/stream`);
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';
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
+2 -2
View File
@@ -1,5 +1,5 @@
export const ACCENT_LIGHTNESS = 74;
export const ACCENT_CHROMA = 0.145;
export const ACCENT_HUE = 145;
export const ACCENT_CHROMA = 0.06;
export const ACCENT_HUE = 30;
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-text: #d9d8dc;
--mf-text-2: #8a8a8e;
--mf-text-3: #7d7d81;
--mf-text-4: #646468;
--mf-text-2: #b1b0b5;
--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-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-strong: rgba(255, 255, 255, 0.09);
@@ -24,7 +24,14 @@
--mf-on-art: #fefdfb;
--mf-on-art-2: #cac8c3;
--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");
@@ -71,15 +78,15 @@
}
:root[data-theme='light'] {
--mf-text: #17171a;
--mf-text-2: #4a4a4e;
--mf-text-3: #6b6b6f;
--mf-text-4: #8c8c90;
--mf-text: #17161c;
--mf-text-2: #5d5c66;
--mf-surface: rgba(0, 0, 0, 0.035);
--mf-surface-2: rgba(0, 0, 0, 0.06);
--mf-surface-hover: #e8eceb;
--mf-btn-bg-hover: var(--mf-surface-hover);
--mf-surface-hover: rgba(26, 24, 38, 0.06);
--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-hover: rgba(0, 0, 0, 0.32);
@@ -89,8 +96,10 @@
--mf-track: rgba(0, 0, 0, 0.12);
--mf-scrim: #ffffff;
--mf-ink: #ffffff;
--mf-cta-strong-bg: #17171a;
--mf-ink: #f7f6f9;
--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-c: 0.085;
+45 -44
View File
@@ -2,70 +2,71 @@ function r(value: number, digits = 4): string {
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 {
[cssVar: string]: string;
}
export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens {
const h = ((hue % 360) + 360) % 360;
const h2 = (h + 40) % 360;
const H = r(h, 1);
const H2 = r(h2, 1);
const light = mode === 'light';
const accentL = light ? 0.8 : 0.8;
const accentC = light ? 0.4 : 0.2;
const titleL = light ? 0.5 : 0.8;
const titleC = light ? 0.1 : 0.05;
const mutedL = light ? 0.48 : 0.79;
const mutedC = light ? 0.06 : 0.035;
const accentL = light ? 0.5 : 0.72;
const accentC = light ? 0.07 : 0.06;
const titleL = light ? 0.36 : 0.86;
const titleC = light ? 0.06 : 0.03;
const mutedL = light ? 0.42 : 0.8;
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 {
'--mf-accent': `oklch(${r(accentL)} ${r(accentC)} ${H})`,
'--mf-accent': accent,
'--mf-accent-title': `oklch(${r(titleL)} ${r(titleC)} ${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-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-hover': `color-mix(in srgb, oklch(${r(accentL)} ${r(accentC)} ${H}) 24%, var(--mf-bg))`,
'--mf-bg': light ? `oklch(0.97 0.014 ${H})` : `oklch(0.075 0.02 ${H})`,
'--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.025 ${H})`,
'--mf-panel-bg': light ? `oklch(0.97 0.014 ${H} / 0.7)` : `oklch(0.1 0.022 ${H} / 0.28)`,
'--mf-bar-bg': light ? `oklch(0.97 0.014 ${H} / 0.9)` : `oklch(0.115 0.025 ${H} / 0.9)`,
'--mf-hairline': light ? `oklch(0.3 0.035 ${H} / 0.1)` : `oklch(0.62 0.05 ${H} / 0.08)`,
'--mf-accent-btn-bg': `color-mix(in srgb, ${accent} 18%, var(--mf-bg))`,
'--mf-accent-btn-bg-hover': `color-mix(in srgb, ${accent} 26%, var(--mf-bg))`,
'--mf-g-active': gActive,
'--mf-g-hover': gHover,
'--mf-bg': light ? `oklch(0.975 0.012 ${H})` : `oklch(0.068 0.008 ${H})`,
'--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.012 ${H})`,
'--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
? 'none'
: `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%)`,
'--mf-hero-tint': light ? tcLight(1, 55, h) : tc(1, 42, h),
? `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(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 ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`,
'--mf-hero-bg': light
? `linear-gradient(${tcLight(0.1, 55, h)}, ${tcLight(0.1, 55, h)})`
: `linear-gradient(${tc(0.1, 38, h)}, ${tc(0.1, 38, h)})`,
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.96, 0.02, 0.4)} 60%)`
: `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
? `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, ${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.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)`
: `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
? `linear-gradient(${tcLight(0.1, 55, h)}, ${tcLight(0.1, 55, h)})`
: `linear-gradient(${tc(0.1, 38, h)}, ${tc(0.1, 38, h)})`,
'--mf-logo-grad': light
? `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%)`,
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)`
: `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': `linear-gradient(${accent}, ${accent})`,
'--mf-cover-grad': light
? `linear-gradient(150deg, oklch(0.7 0.16 ${H}), oklch(0.54 0.11 ${H}))`
: `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))`
? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))`
: `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))`
};
}