navigation: one glass bar for the app's life, not one per page

The bar was rendered by each view, so `<CurrentView />` swapping on every
navigation destroyed it and built a new one. Measured: the `.glassbar`
element is not the same node on either side of an artist -> album move, and
the old one is disconnected.

That broke the crumb entrance animation, which is what this was noticed
from. The `seen` set exists so only a genuinely ARRIVING crumb slides in,
and it is component state -- so it was empty on every mount and every crumb
looked new. Instrumenting Element.animate across one navigation:

  AC/DC -> Highway to Hell   should animate: the album
                             actually animated: all three crumbs
  Back to AC/DC              should animate: nothing
                             actually animated: both crumbs

So the whole trail re-flew in from the right on every page change, and Back
animated crumbs that had never left -- the exact opposite of the
direction-of-travel cue the animation was written for.

The bar is app chrome, not part of a page, so App.svelte now mounts it once
as the scroll pane's first child and pages no longer carry it. Views that
do not show it yet hide it with `display: none` rather than an {#if}: the
element leaves the layout and measures zero -- scrollbar.js reads this
bar's height to start the thumb below it, and correctly gets 0 -- while the
component stays alive and keeps its crumb memory across the round trip.

Geometry is unchanged, which the negative-margin bleed made easy to get
wrong: the bar's net flow height is zero either way, so moving it from
first-child-of-the-page to previous-sibling-of-the-page leaves the page's
top exactly where it was. Verified against the live pane -- bar top/left/
width flush with the pane and 46 high, page top at pane + 24, z-index 40
and elementFromPoint at the bar's centre still returning the bar rather
than the hero underneath.

After: arriving at an album animates only the album's own crumb, Back
animates nothing, and a trip out to Library and back animates nothing. The
backdrop-filter layer is no longer recomposited on every page change, and a
bar element can now transition between pages at all, which was impossible
when each side was a different node.

Two consequences worth stating. The resolver pulse takes the GLOBAL accent
everywhere now; on the album page it used to inherit that page's extracted
palette, because the bar sat inside `.album-page`. The artist page always
gave it the global accent, so the two now agree rather than disagreeing.
And putting the bar on the remaining pages is now widening BAR_VIEWS plus
deciding how it sits against each page's existing header -- the mounting
question is settled.

85 JS tests pass.
This commit is contained in:
KeelrLeek committed 2026-07-28 17:31:01 -05:00
1 parent 98d11a2228
commit 8017c0d877
4 files changed
+42 -10

No files matched your search

+13
View File
@@ -7,6 +7,7 @@
import LoginModal from "./components/LoginModal.svelte";
import PreviewOverlay from "./components/PreviewOverlay.svelte";
import DebugOverlay from "./components/DebugOverlay.svelte";
import GlassBar from "./components/GlassBar.svelte";
import { previewUi } from "./lib/preview/previewSession.svelte.js";
import Home from "./views/Home.svelte";
import Search from "./views/Search.svelte";
@@ -39,6 +40,13 @@
};
const CurrentView = $derived(views[ui.view] ?? Home);
/* The glass bar is app chrome, not part of a page, so it is mounted ONCE
here and lives above whichever view is current — see the note at the top
of GlassBar.svelte for what a per-view instance cost. Views that do not
show it yet hide it; the component is never unmounted, so its crumb
memory survives every navigation, including out to Home and back. */
const BAR_VIEWS = new Set(["catalogArtist", "catalogAlbum"]);
initEvents();
autoConnect();
refreshLibrary();
@@ -62,6 +70,11 @@
The titlebar stays live for window chrome. -->
<Sidebar />
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
<!-- First child of the scroll pane: the bar's sticky pin and its
negative bleed margins are measured against THIS element's
padding, and its net flow height is zero, so the page below still
starts exactly where it did when each view carried its own. -->
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
<CurrentView />
</main>
<NowPlaying />
+24 -2
View File
@@ -2,6 +2,15 @@
// The header every catalog page navigates by — a frosted strip pinned to
// the top of the scroll pane, floating over whatever artwork is behind it.
//
// MOUNTED ONCE, by App.svelte, as the first child of the scroll pane —
// never by a page. A per-view `<GlassBar />` was a different component
// instance on either side of every navigation, which cost three things:
// the entrance animation below could not tell an arriving crumb from one
// that was already there (see `seen`), the backdrop-filter layer was
// recomposited on every page change, and nothing in the bar could ever
// transition between pages. Pages it should not appear on hide it rather
// than unmount it, so its memory survives the round trip.
//
// It began as the artist page's `.results-head`, in parity with the Now
// Playing panel's `.np-head`. Geometry, tint, blur radius and the hairline
// edge are all load-bearing; see the notes on the rules below.
@@ -20,7 +29,8 @@
import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js";
import Icon from "./Icon.svelte";
let { children } = $props();
/** `hidden` hides the bar without unmounting it — see the note above. */
let { children, hidden = false } = $props();
const crumbs = $derived(nav.stack.length ? trail() : []);
const back = $derived(canGoBack());
@@ -38,6 +48,10 @@
const DUR = 300;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
// Survives navigation only because this component does: a per-page bar
// remounted with an EMPTY set, so every crumb looked new and the whole
// trail re-flew in on every page change — including on Back, where nothing
// had arrived at all.
let seen = new Set();
function slideIn(node, key) {
// Only genuinely new crumbs animate. Without this every crumb re-runs
@@ -66,7 +80,7 @@
<!-- data-scrollhead keeps the overlay scrollbar's thumb below this bar rather
than running under it (see src/lib/scrollbar.js). -->
<div class="glassbar" data-scrollhead>
<div class="glassbar" class:hidden data-scrollhead>
<div class="hist">
<button class="arrow" disabled={!back} title="Back" aria-label="Back" onclick={navBack}>
<Icon name="chevron-left" size={17} />
@@ -146,6 +160,14 @@
overflow: hidden;
}
/* `display: none` rather than an {#if}: the element leaves the layout and
measures zero (scrollbar.js reads this bar's height to start the thumb
below it, and gets 0 here — correct for a page with no bar), while the
COMPONENT stays alive and keeps its crumb memory. */
.glassbar.hidden {
display: none;
}
.hist {
display: flex;
align-items: center;
-3
View File
@@ -29,7 +29,6 @@
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte";
import GlassBar from "../components/GlassBar.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
const album = $derived(page?.album ?? null);
@@ -509,8 +508,6 @@
</script>
<div class="album-page" style={accentStyle} bind:this={pageEl}>
<GlassBar />
{#if catalog.albumLoading}
<p class="hint">Loading album…</p>
{:else if !page}
+5 -5
View File
@@ -44,7 +44,6 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
import GlassBar from "../components/GlassBar.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -4459,10 +4458,11 @@
</div>
</div>
<!-- The shared frosted header (src/components/GlassBar.svelte); it
floats over the seam tail's mirror at the top. Its -24px margin is
what the pin geometry below is measured against. -->
<GlassBar />
<!-- The shared frosted header (src/components/GlassBar.svelte) floats
over the seam tail's mirror at the top. It is mounted by App.svelte
as the scroll pane's first child, not here — but its -24px bleed is
still what the pin geometry below is measured against, and its net
flow height is zero either way, so this spacer's top is unmoved. -->
<!-- Pure spacer now: it still reserves the room the artwork needs and
still sets where the content begins (both from its aspect-ratio,