Scroll content inside the shell so the player shares the ambient backdrop
The player dock used to sit fixed over the scrolling page, so it needed an opaque background. The shell is now a fixed-height frame where only the content column scrolls, letting the dock stay transparent like the sidebar and queue. Scroll reset/restore on navigation moves to restoreScroll(), and the stable scrollbar gutter moves from <html> to the content scroller.
This commit is contained in:
@@ -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,28 +103,30 @@
|
||||
{@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">
|
||||
<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}>
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
<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">
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Queue />
|
||||
</div>
|
||||
|
||||
<Queue />
|
||||
<PlayerDock user={data.user} />
|
||||
</div>
|
||||
|
||||
<PlayerDock user={data.user} />
|
||||
|
||||
<Modal
|
||||
open={createPlaylistModal.open}
|
||||
onClose={() => createPlaylistModal.close()}
|
||||
|
||||
@@ -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