"Show all" beside the artist page's Discography head opens every release as a stack of album-page heroes with their tracklists — the shapes Kyle picked off the mock: the search page's top-result card as a compact header, the album hero at --cover-scale 0.45 per record, a per-record accent sampled from its own cover, the year rail and the tab summary. The tab row does not stick and there is no Playing badge. Records the artist page has not fetched are shown rather than hidden, and the entrance is the W1 stagger — 300ms in, 55ms per row, 28px of lift, eight rows. It does not re-file the releases. catalog.discoTabs is the artist page's OWN computed tab list, published as it derives it and republished as the evidence improves. It has to work this way: the filing depends on the background description harvest that runs while you look at the artist page and on the MusicBrainz type hints from the deferred identity pass, neither of which is derivable from the payload — so a second classifier would read neither and would quietly file some releases differently from the page that linked to it. One list, computed once, handed over. If the tabs in hand belong to somebody else, opening a discography lands on the artist page instead, which republishes them and puts "Show all" one click away; an empty discography would be a lie about the catalogue. The page's CSS was extracted from src/ rather than retyped — CatalogAlbum's record section, CatalogSearch's header card and PlayerBar's menu, pulled through the same rule-matcher the mockup generator uses — so it inherits the app's measurements instead of approximating them. Verified against the running app: artwork 108px, record hero 114.3, header 147.5, header top 78, bar offset 0, ambience banner 0, rail centring error 0 and 12.8px of clearance at every scroll depth and pane width. Pond files 12+16+2+2 = 32 families on both pages. It is the one page exempt from BAR_VIEWS' padding-top rule, deliberately: its blurred ambience layer has to begin at the very top of the pane, and a padding there would start it halfway through the glass bar. It takes the inset on its header's margin instead, which lands in the same place.
234 lines
11 KiB
Svelte
234 lines
11 KiB
Svelte
<script>
|
||
import Sidebar from "./components/Sidebar.svelte";
|
||
import TitleBar from "./components/TitleBar.svelte";
|
||
import PlayerBar from "./components/PlayerBar.svelte";
|
||
import NowPlaying from "./components/NowPlaying.svelte";
|
||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||
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";
|
||
import Library from "./views/Library.svelte";
|
||
import Downloads from "./views/Downloads.svelte";
|
||
import Settings from "./views/Settings.svelte";
|
||
import Playlist from "./views/Playlist.svelte";
|
||
import Playlists from "./views/Playlists.svelte";
|
||
import CatalogSearch from "./views/CatalogSearch.svelte";
|
||
import CatalogArtist from "./views/CatalogArtist.svelte";
|
||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||
import CatalogDiscography from "./views/CatalogDiscography.svelte";
|
||
import FollowToast from "./components/FollowToast.svelte";
|
||
import {
|
||
ui,
|
||
initEvents,
|
||
autoConnect,
|
||
refreshLibrary,
|
||
refreshPlaylists,
|
||
refreshRecents,
|
||
restoreSession,
|
||
} from "./lib/state.svelte.js";
|
||
import { floatScroll } from "./lib/scrollbar.js";
|
||
|
||
const views = {
|
||
home: Home,
|
||
search: Search,
|
||
library: Library,
|
||
downloads: Downloads,
|
||
settings: Settings,
|
||
playlist: Playlist,
|
||
playlists: Playlists,
|
||
catalog: CatalogSearch,
|
||
catalogArtist: CatalogArtist,
|
||
catalogAlbum: CatalogAlbum,
|
||
catalogDiscography: CatalogDiscography,
|
||
};
|
||
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.
|
||
|
||
A page added here MUST also take `padding-top: var(--pane-pad)`: the bar
|
||
cancels the pane's top padding so a page's artwork can run underneath it,
|
||
and the page restores that inset for its own content. Without it the page
|
||
renders 24px too high; with it but not listed here, 24px too low.
|
||
|
||
`catalogDiscography` is the one exception to the padding rule above, and
|
||
deliberately: it pins a blurred ambience layer behind the glass, and that
|
||
layer has to begin at the very top of the pane. A padding-top here would
|
||
start it a --pane-pad down — halfway through the bar. It takes the inset
|
||
on its header's margin instead, which comes to the same place. */
|
||
const BAR_VIEWS = new Set([
|
||
"catalogArtist",
|
||
"catalogAlbum",
|
||
"catalog",
|
||
"catalogDiscography",
|
||
]);
|
||
|
||
initEvents();
|
||
autoConnect();
|
||
refreshLibrary();
|
||
refreshPlaylists();
|
||
refreshRecents();
|
||
// Last, and after initEvents: the restore asks the audio thread what it is
|
||
// doing, and re-opens the page the last session was on. See the session
|
||
// section at the foot of state.svelte.js.
|
||
void restoreSession();
|
||
|
||
</script>
|
||
|
||
<div
|
||
class="app"
|
||
class:np-open={ui.nowPlaying}
|
||
class:sidebar-collapsed={ui.sidebarCollapsed}
|
||
class:player-expanded={ui.playerExpanded}
|
||
class:np-dragging={ui.nowPlayingDragging}
|
||
class:preview-open={previewUi.open}
|
||
style="--np-open-width: {ui.nowPlayingWidth}px"
|
||
>
|
||
<TitleBar />
|
||
<!-- While the preview modal is open the page is inert and the flanking
|
||
panels + player bar are pointer-inert under the scrim (user decision
|
||
2026-07-23: the bar stays VISIBLE, dimmed, like Spotify — un-rendering
|
||
it left a black hole in the grid's bottom row). There is still no UI
|
||
path to command main audio behind the focus coordinator's back: the
|
||
scrim intercepts pointer input and the dialog traps the keyboard.
|
||
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. It consumes no flow height of its own AND cancels the
|
||
pane's top padding, so the page after it begins flush with the top
|
||
of the pane and its artwork runs under the bar. -->
|
||
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
|
||
<CurrentView />
|
||
</main>
|
||
<NowPlaying />
|
||
<PlayerBar />
|
||
<FollowToast />
|
||
<PreviewOverlay />
|
||
<ArtworkViewer />
|
||
<LoginModal />
|
||
<DebugOverlay />
|
||
</div>
|
||
|
||
<style>
|
||
/* Frameless window: the titlebar row is part of the app background —
|
||
no separate chrome color, the panels float beneath it. */
|
||
/* While the preview modal is open: keyboard is contained by the dialog's
|
||
focus trap and aria-modal, the content pane is `inert`, and the flanking
|
||
panels + player bar stop taking pointer input here — the bar stays
|
||
visible (dimmed under the scrim) but cannot command main audio. */
|
||
.app.preview-open :global(.sidebar),
|
||
.app.preview-open :global(.nowplaying),
|
||
.app.preview-open :global(.player) {
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* NOTE (2026-08-01 perf audit): do NOT try to move the preview scrim's
|
||
backdrop blur onto these panels as `filter: blur(6px)`. It was tried:
|
||
filtered panels do not get cached render passes here (the content
|
||
pane's canvas/composited subtree defeats caching), so every presented
|
||
frame re-rastered five blurred panels — measured ~2× the cost of the
|
||
single backdrop-filter on the scrim. The cheap lever is the modal's
|
||
PRESENT RATE (see the eq animation note in PreviewOverlay.svelte),
|
||
not where the blur lives. */
|
||
|
||
.app {
|
||
display: grid;
|
||
grid-template-columns: var(--sidebar-width) 1fr var(--nowplaying-width);
|
||
grid-template-rows: 30px 1fr var(--player-height);
|
||
grid-template-areas:
|
||
"titlebar titlebar titlebar"
|
||
"sidebar content nowplaying"
|
||
"player player player";
|
||
height: 100%;
|
||
gap: 8px;
|
||
padding: 0 8px 8px;
|
||
background: var(--bg-sidebar);
|
||
overflow: hidden;
|
||
/* The player row grows UPWARD because the grid's total height is the
|
||
window: the bar's own track gets taller and `1fr` — the content —
|
||
gives up exactly that much. Nothing below the bar can move, so its
|
||
bottom edge is pinned without a rule saying so. */
|
||
transition:
|
||
grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||
grid-template-rows 350ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
/* Indirection so the dragged width can be an INLINE style without
|
||
outranking the collapse rule below: an inline --nowplaying-width
|
||
would beat `.app:not(.np-open)` and the panel could never collapse.
|
||
Both rules here are class-level, so the more specific one wins. */
|
||
--nowplaying-width: var(--np-open-width, 340px);
|
||
}
|
||
|
||
/* A drag has to track the pointer, and an eased track list cannot: the grip
|
||
moves the target every frame, each move restarts the 0.28s ease from
|
||
wherever it had crawled to, and the column ends up going almost nowhere —
|
||
measured 203px behind the pointer on a 220px drag, then racing to the
|
||
target after release. The panel already leaves the ease for a drag
|
||
(.nowplaying.dragging); if the column does not leave with it the panel
|
||
grows past its own track and the window clips it. */
|
||
.app.np-dragging {
|
||
transition: none;
|
||
}
|
||
|
||
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
|
||
grid-template-columns transition animates the reflow. */
|
||
.app.sidebar-collapsed {
|
||
--sidebar-width: 64px;
|
||
}
|
||
|
||
/* Expanded player: the same one-line gesture the sidebar makes, on the row
|
||
instead of the column. 90px was chosen on
|
||
research/playbar-redesign/expand-collapse.html — enough for the 34px
|
||
transport, a 10px gap and the 24px lane stacked and centred (68px of
|
||
content, 11px of air top and bottom), and no more. */
|
||
.app.player-expanded {
|
||
--player-height: 90px;
|
||
}
|
||
|
||
/* Now Playing collapses to its RAIL, not to nothing — the same gesture the
|
||
left nav makes. The column animates between the user's dragged width and
|
||
the rail on the shared 0.28s curve above; the panel keeps a presence at
|
||
every point, so there is no off-screen slide any more. */
|
||
.app:not(.np-open) {
|
||
--nowplaying-width: var(--nowplaying-rail);
|
||
}
|
||
|
||
.content {
|
||
grid-area: content;
|
||
background: linear-gradient(var(--bg-highlight) 0, var(--bg-base) 240px);
|
||
/* The highlight wash belongs to the top of the PAGE, not the viewport:
|
||
anchored to the pane (default `scroll`), the gradient stays put
|
||
while content scrolls, so the hero's scrim — which dissolves into
|
||
pure bg-base — ends on a lighter backdrop mid-gradient and paints a
|
||
hard seam at the hero's bottom edge. `local` scrolls the gradient
|
||
away with the content, keeping the seam colors identical at every
|
||
scroll position. */
|
||
background-attachment: local;
|
||
/* The pane's top padding, named because three other rules are tied to
|
||
it: the glass bar bleeds up over it, cancels it for what follows, and
|
||
each page under the bar restores it as its own padding-top. */
|
||
--pane-pad: 24px;
|
||
/* The glass bar's height, declared HERE rather than on the bar so pages
|
||
can clear it. A page with a hero (artist, album) wants its artwork to
|
||
run underneath the bar and only takes --pane-pad back; a page with no
|
||
artwork behind the bar (search) has to start below it, or its first
|
||
heading sits under the frost at rest. */
|
||
--bar-h: 46px;
|
||
border-radius: var(--radius);
|
||
overflow-y: auto;
|
||
overflow-anchor: none;
|
||
/* Main scroll pane: no elastic rubber-band at its edges. Inner regions
|
||
deliberately do NOT set this, so their wheel chains here. */
|
||
overscroll-behavior: none;
|
||
padding: var(--pane-pad) 28px;
|
||
}
|
||
</style>
|