Align library styles with the design system

Use type recipes and Button danger variant, add danger tint, dropzone and cover-fill tokens, give SearchInput a size prop so the top bar keeps its larger search, show queue details inline on mobile, derive upload limits from constants and document the card hover style.
This commit is contained in:
2026-09-25 01:20:32 +02:00
parent 074ea7bf26
commit ebe9769d4e
12 changed files with 54 additions and 11 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ The shell in `src/routes/(app)/+layout.svelte` is a fixed `h-dvh` frame: Sidebar
## Motion ## 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. 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`. Media cards have no cover zoom: hovering one only fades in its background (`hover:bg-hover`) and reveals the play button.
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. 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.
@@ -77,6 +77,7 @@
<SearchInput <SearchInput
bind:value={query} bind:value={query}
placeholder="Filtrar por título o artista" placeholder="Filtrar por título o artista"
size="sm"
class="w-full sm:max-w-search sm:flex-1" class="w-full sm:max-w-search sm:flex-1"
/> />
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} /> <ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
@@ -6,6 +6,7 @@
summarizeQueue, summarizeQueue,
toQueuedUpload, toQueuedUpload,
UPLOAD_FORMATS, UPLOAD_FORMATS,
uploadLimitsLabel,
type QueuedUpload, type QueuedUpload,
type Visibility type Visibility
} from '$lib/data/uploadQueue'; } from '$lib/data/uploadQueue';
@@ -69,7 +70,7 @@
<FileDropzone <FileDropzone
accept={ACCEPT} accept={ACCEPT}
title="Arrastra tus archivos aquí" title="Arrastra tus archivos aquí"
hint="WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras" hint={uploadLimitsLabel()}
inputLabel="Elegir archivos de audio" inputLabel="Elegir archivos de audio"
onfiles={enqueue} onfiles={enqueue}
readDrop={filesFromDrop} readDrop={filesFromDrop}
@@ -88,7 +89,7 @@
<section class="min-w-0 rounded-panel bg-surface p-5 sm:p-6"> <section class="min-w-0 rounded-panel bg-surface p-5 sm:p-6">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 class="font-display text-xl font-semibold tracking-display text-fg">Cola de subida</h2> <h2 class="text-display-3 text-fg">Cola de subida</h2>
<p class="mt-1 text-xs text-fg-2"> <p class="mt-1 text-xs text-fg-2">
{queue.length > 0 ? queueMeta : 'Todavía no hay archivos'} {queue.length > 0 ? queueMeta : 'Todavía no hay archivos'}
</p> </p>
@@ -57,6 +57,17 @@
onTitle, onTitle,
class: klass = '' class: klass = ''
}: Props = $props(); }: Props = $props();
function details(item: QueuedUpload): string {
return [
genresLabel,
visibilityLabel,
formatSize(item.file.size),
item.duration === null ? '' : fmtTime(item.duration)
]
.filter((part) => part !== '' && part !== '-')
.join(' · ');
}
</script> </script>
<DataGrid <DataGrid
@@ -82,6 +93,7 @@
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none" class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
/> />
<p class="truncate text-xs text-fg-2">{item.file.name}</p> <p class="truncate text-xs text-fg-2">{item.file.name}</p>
<p class="truncate text-xs text-fg-2 sm:hidden">{details(item)}</p>
</div> </div>
</div> </div>
{:else if column.id === 'genres'} {:else if column.id === 'genres'}
@@ -59,11 +59,11 @@
</script> </script>
<aside class="flex flex-col gap-6 {klass}"> <aside class="flex flex-col gap-6 {klass}">
<p class="text-xs font-medium tracking-eyebrow text-fg-2 uppercase">Aplicar a esta subida</p> <p class="text-eyebrow text-fg-2">Aplicar a esta subida</p>
<div class="grid gap-8 sm:grid-cols-[auto_1fr]"> <div class="grid gap-8 sm:grid-cols-[auto_1fr]">
<Field label="Portada"> <Field label="Portada">
<div class="w-full max-w-44"> <div class="w-full max-w-cover-fill">
<ImageDropzone <ImageDropzone
name="cover" name="cover"
icon={ImageIcon} icon={ImageIcon}
@@ -75,6 +75,7 @@
icon={coverIcon} icon={coverIcon}
gradient gradient
size="fill" size="fill"
class="max-w-cover-fill lg:max-w-none"
/> />
{#if coverHint} {#if coverHint}
<p class="text-xs text-fg-2">{coverHint}</p> <p class="text-xs text-fg-2">{coverHint}</p>
@@ -70,14 +70,14 @@
ondragover={onOver} ondragover={onOver}
ondragleave={onLeave} ondragleave={onLeave}
ondrop={onDrop} ondrop={onDrop}
class="flex min-h-64 flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging class="flex min-h-dropzone flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging
? 'border-accent/60 bg-accent-tint' ? 'border-accent/60 bg-accent-tint'
: 'border-line-strong bg-surface'} {klass}" : 'border-line-strong bg-surface'} {klass}"
> >
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2"> <span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
<UploadCloud class="size-icon-xl" /> <UploadCloud class="size-icon-xl" />
</span> </span>
<h2 class="mt-5 font-display text-xl font-semibold tracking-display text-fg">{title}</h2> <h2 class="mt-5 text-display-3 text-fg">{title}</h2>
{#if hint} {#if hint}
<p class="mt-2 text-xs text-fg-2">{hint}</p> <p class="mt-2 text-xs text-fg-2">{hint}</p>
{/if} {/if}
@@ -10,7 +10,7 @@
const TONE = { const TONE = {
success: 'bg-accent-tint text-accent-soft', success: 'bg-accent-tint text-accent-soft',
neutral: 'bg-btn text-fg-2', neutral: 'bg-btn text-fg-2',
danger: 'bg-danger/15 text-danger-fg' danger: 'bg-danger-tint text-danger-fg'
}; };
</script> </script>
@@ -6,6 +6,7 @@
value?: string; value?: string;
placeholder?: string; placeholder?: string;
label?: string; label?: string;
size?: 'sm' | 'md';
oninput?: () => void; oninput?: () => void;
onclear?: () => void; onclear?: () => void;
class?: string; class?: string;
@@ -15,11 +16,17 @@
value = $bindable(''), value = $bindable(''),
placeholder = 'Buscar', placeholder = 'Buscar',
label = placeholder, label = placeholder,
size = 'md',
oninput, oninput,
onclear, onclear,
class: klass = '' class: klass = ''
}: Props = $props(); }: Props = $props();
const SIZE = {
sm: { text: 'text-xs', icon: 'size-icon-xs' },
md: { text: 'text-sm', icon: 'size-icon-sm' }
};
let input: HTMLInputElement | undefined = $state(); let input: HTMLInputElement | undefined = $state();
function clear() { function clear() {
@@ -32,7 +39,7 @@
<label <label
class="flex h-9.5 min-w-0 items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover {klass}" class="flex h-9.5 min-w-0 items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover {klass}"
> >
<Search class="size-icon-xs shrink-0 text-fg-2" /> <Search class="{SIZE[size].icon} shrink-0 text-fg-2" />
<input <input
type="text" type="text"
bind:this={input} bind:this={input}
@@ -40,7 +47,8 @@
{oninput} {oninput}
{placeholder} {placeholder}
aria-label={label} aria-label={label}
class="w-full min-w-0 bg-transparent text-xs text-fg placeholder:text-fg-2 focus:outline-none" class="w-full min-w-0 bg-transparent {SIZE[size]
.text} text-fg placeholder:text-fg-2 focus:outline-none"
/> />
{#if value} {#if value}
<button <button
@@ -4,6 +4,7 @@ import {
titleFromFileName, titleFromFileName,
toQueuedUpload, toQueuedUpload,
uploadError, uploadError,
uploadLimitsLabel,
UPLOAD_MAX_BYTES UPLOAD_MAX_BYTES
} from '$lib/data/uploadQueue'; } from '$lib/data/uploadQueue';
@@ -43,3 +44,11 @@ describe('summarizeQueue', () => {
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 }); expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 });
}); });
}); });
describe('uploadLimitsLabel', () => {
it('lists the formats and the size limit', () => {
expect(uploadLimitsLabel()).toBe(
'WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras'
);
});
});
+9 -1
View File
@@ -1,6 +1,14 @@
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024; export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3']; export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
const UPLOAD_MAX_MB = Math.round(UPLOAD_MAX_BYTES / (1024 * 1024));
export function uploadLimitsLabel(): string {
const names = UPLOAD_FORMATS.filter((ext) => ext !== 'aif').map((ext) => ext.toUpperCase());
const list = `${names.slice(0, -1).join(', ')} o ${names[names.length - 1]}`;
return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`;
}
export type Visibility = 'private' | 'public'; export type Visibility = 'private' | 'public';
export interface QueuedUpload { export interface QueuedUpload {
@@ -30,7 +38,7 @@ export function titleFromFileName(name: string): string {
export function uploadError(file: File): string | null { export function uploadError(file: File): string | null {
if (!UPLOAD_FORMATS.includes(fileExtension(file.name))) return 'Formato no compatible'; if (!UPLOAD_FORMATS.includes(fileExtension(file.name))) return 'Formato no compatible';
if (file.size > UPLOAD_MAX_BYTES) return 'Supera 500 MB'; if (file.size > UPLOAD_MAX_BYTES) return `Supera ${UPLOAD_MAX_MB} MB`;
if (file.size === 0) return 'Archivo vacío'; if (file.size === 0) return 'Archivo vacío';
return null; return null;
} }
+3
View File
@@ -28,6 +28,7 @@
--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: var(--mf-danger-fg); --color-danger-fg: var(--mf-danger-fg);
--color-danger-tint: color-mix(in srgb, var(--color-danger) 15%, transparent);
--color-btn: var(--mf-btn-bg); --color-btn: var(--mf-btn-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-cta-strong-hover: var(--mf-cta-strong-hover);
@@ -60,6 +61,8 @@
--spacing-icon-2xl: 1.75rem; --spacing-icon-2xl: 1.75rem;
--spacing-icon-3xl: 2.5rem; --spacing-icon-3xl: 2.5rem;
--spacing-section: 2.875rem; --spacing-section: 2.875rem;
--spacing-dropzone: 16rem;
--spacing-cover-fill: 11rem;
--container-prose-sm: 26rem; --container-prose-sm: 26rem;
--container-hero: 34rem; --container-hero: 34rem;