Web player general changes #22
@@ -57,9 +57,13 @@ 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`.
|
||||
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
|
||||
|
||||
## App shell
|
||||
|
||||
The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="scrollbar-stable">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
||||
class="hidden min-h-0 shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
>
|
||||
<a href="/" class="mb-4">
|
||||
@@ -73,7 +73,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||
<ListRow
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
|
||||
class="pointer-events-auto flex items-center justify-around px-4 py-2 glass-bar lg:hidden {klass}"
|
||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||
>
|
||||
{#each links as link (link.href)}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="animate-enter pointer-events-auto flex items-center gap-icon-md glass-bar px-4 py-3 sm:hidden"
|
||||
class="animate-enter pointer-events-auto flex items-center gap-icon-md px-4 py-3 glass-bar sm:hidden"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo compact />
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
|
||||
class="animate-enter pointer-events-auto hidden items-center gap-5 px-4 py-3 glass-bar sm:flex"
|
||||
>
|
||||
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
|
||||
<TrackInfo />
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
|
||||
>
|
||||
<div class="flex shrink-0 flex-col">
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
inert={!queuePanel.open}
|
||||
>
|
||||
<aside
|
||||
class="flex shrink-0 flex-col border-l border-hairline px-4 pt-5"
|
||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
||||
class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
|
||||
style="width:var(--mf-queue-w)"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-px overflow-y-auto"
|
||||
class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto"
|
||||
ondragover={(event) => dragFrom !== null && event.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Compass from '@lucide/svelte/icons/compass';
|
||||
@@ -55,6 +55,26 @@ export function trackNavigation() {
|
||||
});
|
||||
}
|
||||
|
||||
const scrollPositions = new Map<number, number>();
|
||||
|
||||
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
|
||||
let current: number | null = null;
|
||||
|
||||
beforeNavigate(() => {
|
||||
const scroller = getScroller();
|
||||
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
|
||||
});
|
||||
|
||||
afterNavigate((nav) => {
|
||||
current = historyIndex();
|
||||
const scroller = getScroller();
|
||||
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
|
||||
const saved =
|
||||
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
|
||||
scroller.scrollTop = saved ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function previousPage(): PageRef | null {
|
||||
return visited.get(index - 1) ?? null;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
import { ACCENT_HUE } from '$lib/theme/color';
|
||||
import { buildThemeTokens, applyThemeTokens, tokensToCss } from '$lib/theme/tokens';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
import { trackNavigation } from '$lib/state/navigation.svelte';
|
||||
import { trackNavigation, restoreScroll } from '$lib/state/navigation.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
let scroller = $state<HTMLElement>();
|
||||
|
||||
trackNavigation();
|
||||
restoreScroll(() => scroller);
|
||||
|
||||
$effect(() => {
|
||||
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
|
||||
@@ -35,7 +38,6 @@
|
||||
});
|
||||
|
||||
const isAuthPage = $derived(page.url.pathname === '/auth');
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
|
||||
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
|
||||
|
||||
@@ -101,17 +103,18 @@
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative flex min-h-screen text-fg antialiased">
|
||||
<div class="relative flex h-dvh overflow-hidden text-fg antialiased">
|
||||
<div class="app-backdrop"></div>
|
||||
<div class="app-vignette"></div>
|
||||
|
||||
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} />
|
||||
|
||||
<div class="flex min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div bind:this={scroller} class="flex min-w-0 flex-1 app-scroll flex-col">
|
||||
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
|
||||
<TopBar user={data.user} accountUrl={data.accountUrl} />
|
||||
<main class="flex-1 app-main" data-has-track={hasTrack ? '' : undefined}>
|
||||
<main class="flex-1">
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
@@ -122,6 +125,7 @@
|
||||
</div>
|
||||
|
||||
<PlayerDock user={data.user} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={createPlaylistModal.open}
|
||||
|
||||
@@ -98,10 +98,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility text-display-1 {
|
||||
@apply font-display text-3xl font-medium tracking-tight sm:text-4xl;
|
||||
}
|
||||
@@ -131,7 +127,6 @@
|
||||
transition-duration: var(--default-transition-duration);
|
||||
}
|
||||
@utility glass-bar {
|
||||
background-color: var(--mf-bg);
|
||||
border-top: 1px solid var(--mf-hairline);
|
||||
}
|
||||
@utility grid-cards {
|
||||
@@ -224,14 +219,12 @@
|
||||
margin-bottom: --spacing(14);
|
||||
}
|
||||
}
|
||||
@utility app-main {
|
||||
--mf-dock-h: 0px;
|
||||
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h));
|
||||
transition: padding var(--mf-motion-slow) var(--mf-ease);
|
||||
|
||||
&[data-has-track] {
|
||||
--mf-dock-h: var(--mf-player-h);
|
||||
}
|
||||
@utility app-scroll {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mf-scrollbar) transparent;
|
||||
}
|
||||
@utility genre-tile {
|
||||
background: linear-gradient(
|
||||
|
||||
Reference in New Issue
Block a user