dark glass, the whole app: frost on every pane, and the periscope pattern for every glass bar the frost sits under - backdrop-filter black-flattens over see-through ground (measured 3-vs-40), so bars over frost blur a scroll-synced clone behind a static band cut instead; the bar moved out of the scroller (pane shell, inner .content keeping the closest-contract, the old negative-margin flow replayed by a spacer after the artist page's sticky physics named the scrollport padding sacred); painted pages declare data-painted and keep the real backdrop bar, which is how the artist hero runs under the glass again; np wash rides the scroll away and dissolves to frost like the album banner; perf measured to vsync - 142px blur wraps (27.9ms frames down to 6.9), idle scroll-gated reclones, clip-path band, content-visibility on library rows, async cover decode; mini frosts over the mini:glass channel and the rest-frame flags ride window config because the env-var lane dies on some webview2 runtimes

This commit is contained in:
KeelrLeek committed 2026-08-26 14:55:25 -05:00
1 parent 225c9139aa
commit 6ba1864498
16 files changed
+821 -117

No files matched your search

+253 -5
View File
@@ -327,6 +327,136 @@
return () => (active = false);
});
// ── the head periscope (dark glass only) — the sidebar's pattern ────
// Under glass the head's backdrop-filter black-flattens the moment the
// wash scrolls out from under it (frost backdrop = opaque black slab;
// see Sidebar.svelte for the measurements). So under glass: the head
// drops backdrop-filter, the scroller's originals are masked out of the
// head band, and a cloneNode copy under a NORMAL blur paints them back
// as fog — wash included, so the head keeps its record tint at rest and
// the colors still seep and shift as you scroll. Canvas children clone
// blank (the waveform leaves no fog) — accepted.
let npGlassy = $state(document.documentElement.classList.contains("glass"));
let npScrollEl = $state(null);
let npPeriEl = $state(null);
$effect(() => {
const mo = new MutationObserver(() => {
npGlassy = document.documentElement.classList.contains("glass");
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
});
$effect(() => {
if (!npGlassy || !npScrollEl || !npPeriEl) return;
const view = npScrollEl;
const peri = npPeriEl;
let pending = 0;
let lastScroll = 0;
let lastNodes = 500;
const sync = () => {
lastScroll = performance.now();
const c = peri.firstElementChild?.firstElementChild;
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
};
const reclone = () => {
// The blur lives on an OVERSCAN wrapper extending 48px past the
// clip on every side: a gaussian fades toward transparent at its
// element's bounds (~2× the radius), and with the bounds out
// there the falloff is clipped away instead of vignetting the
// band edges (Kyle's catch; the wash img does the same job with
// scale(1.08)). The clone is pinned back inside by margins at
// its measured width, so layout and alignment are unchanged.
// height 142 = overscan + band + overscan: the filter rasters
// this box, never the full cloned scroller.
const wrap = document.createElement("div");
wrap.style.cssText =
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
"overflow:hidden;filter:blur(17px) saturate(1.3);will-change:transform;" +
"pointer-events:none;";
const c = view.cloneNode(true);
c.style.position = "relative";
c.style.margin = "48px 0 0 48px";
c.style.width = view.clientWidth + "px";
c.style.height = "auto";
c.style.overflow = "visible";
c.querySelectorAll("canvas").forEach((n) => n.remove());
lastNodes = c.querySelectorAll("*").length;
// The overscan clears the FILTER's falloff, but the blur still
// mixes with emptiness wherever the CONTENT ends at the clip
// line — and the wash is the one full-bleed layer, so its edges
// vignetted the band (Kyle's second catch). Stretch the cloned
// wash into the overscan margins so the band edge samples wash
// against wash. Rows keep their own padding; nothing else
// reaches the edge.
const bleed = c.querySelector(".scroll-bleed");
if (bleed) {
bleed.style.top = "-48px";
bleed.style.left = "-48px";
bleed.style.right = "-48px";
}
wrap.appendChild(c);
const fresh = !peri.firstElementChild;
if (fresh) {
wrap.style.opacity = "0";
wrap.style.transition = "opacity 160ms ease";
}
peri.replaceChildren(wrap);
if (fresh) requestAnimationFrame(() => (wrap.style.opacity = "1"));
sync();
};
// Trailing debounce; no `class` in the filter — hover flips were
// re-cloning per mouse move, and the fog doesn't read hover states.
// Delay scales with pane size and the clone runs at idle (see the
// GlassBar note: big pages were janking frames with reclone tasks).
const queueReclone = () => {
clearTimeout(pending);
const delay = Math.min(600, 120 + lastNodes / 40);
pending = setTimeout(() => {
// Never clone mid-scroll, never while hidden (see GlassBar).
if (document.hidden) return;
if (performance.now() - lastScroll < 250) return queueReclone();
if ("requestIdleCallback" in window) {
requestIdleCallback(() => reclone(), { timeout: 800 });
} else {
reclone();
}
}, delay);
};
const mo = new MutationObserver((records) => {
for (const r of records) {
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
peri.replaceChildren();
break;
}
}
queueReclone();
});
mo.observe(view, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src"],
});
const ro = new ResizeObserver(queueReclone);
ro.observe(view);
const onVis = () => {
if (!document.hidden) queueReclone();
};
document.addEventListener("visibilitychange", onVis);
view.addEventListener("scroll", sync, { passive: true });
reclone();
return () => {
mo.disconnect();
ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
view.removeEventListener("scroll", sync);
clearTimeout(pending);
peri.replaceChildren();
};
});
const bufferPct = $derived(
player.streamTotal > 0
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
@@ -1037,10 +1167,10 @@
{/snippet}
<aside class="nowplaying" class:collapsed={!ui.nowPlaying} class:dragging>
<!-- The record washes the whole panel, expanded or collapsed, and the glass
bars below sit on it exactly as the app's glass bar sits on a page hero.
Blurred hard, because it is a backdrop for text and icons — not a
second showing of the artwork. -->
<!-- The record washing the RAIL (the expanded view carries its own
.scroll-bleed inside the scroller, so the wash rides the scroll away
like the album page's hero banner). Blurred hard, because it is a
backdrop for text and icons — not a second showing of the artwork. -->
{#if artUrl}
<div class="bleed" aria-hidden="true">
<img src={artUrl} alt="" draggable="false" />
@@ -1104,6 +1234,9 @@
waste of the only permanently visible line it has. -->
<div class="headzone">
<header class="np-head glass" data-scrollhead>
{#if npGlassy}
<div class="peri" bind:this={npPeriEl} aria-hidden="true"></div>
{/if}
<button class="np-icon" title="Collapse panel" aria-label="Collapse Now Playing" onclick={toggleNowPlaying}>
<Icon name="chevron-right" size={16} />
</button>
@@ -1153,7 +1286,16 @@
</header>
</div>
<div class="np-scroll" use:floatScroll>
<div class="np-scroll" use:floatScroll bind:this={npScrollEl}>
<!-- The record's wash, anchored to the CONTENT: it rides the scroll
away like the album page's hero banner (Kyle, 2026-08-26). The
pane-level .bleed above now backgrounds the RAIL alone. -->
{#if artUrl}
<div class="scroll-bleed" aria-hidden="true">
<img src={artUrl} alt="" draggable="false" />
<span class="bleed-fade"></span>
</div>
{/if}
{#if panelContentVisible}
{#if currentItem}
<!-- glowing cover art -->
@@ -1578,6 +1720,12 @@
antialiased once and the wash runs full-bleed to the very edge —
no inset, no mask, no visible cost (the 1% dim is nothing). */
opacity: 0.99;
transition: opacity 0.28s ease;
}
/* Expanded, the pane-level wash yields to the scroller's own (rail keeps
this one). 0 ← 0.99, never up — the flatten note above stands. */
.nowplaying:not(.collapsed) .bleed {
opacity: 0;
}
.bleed img {
width: 100%;
@@ -1597,6 +1745,105 @@
);
}
/* The expanded view's wash, anchored to the scroller's CONTENT so it
rides the scroll away — the album page's hero-banner grammar. Height
tracks the pane width (the hero is width-driven), the fade span above
lands on solid --bg-player before the box ends, so solid mode is
seamless against the pane ground. Same flatten discipline as .bleed. */
.scroll-bleed {
position: absolute;
top: 0;
left: 0;
right: 0;
aspect-ratio: 3 / 5;
z-index: -1; /* np-scroll is a stacking context (z-index: 1) */
overflow: hidden;
pointer-events: none;
opacity: 0.99;
}
.scroll-bleed img {
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(17px) saturate(1.25);
opacity: 0.85;
transform: scale(1.08);
}
/* Dark glass: the pane joins the frost. The base thins like the sidebar
(nets ~0.71 over the .app canvas tint), and the bleed's fade thins
ONLY in its tail — it lands on solid --bg-player, which would keep
the lower panel opaque. The upper stops stay IDENTICAL to solid mode:
thinning them brightened the wash behind the glass header and the
cover read straight through it (Kyle, 2026-08-26 — "don't change the
glass topbar", the fix belongs to the wash, not the bar). Tail 0.85
is the mini Wash vocabulary (its fades end near 0.92). Stops stay
inside the flattened .bleed group, so the corner-arc story above is
untouched. */
:global(:root.glass) .nowplaying {
background: rgba(0, 0, 0, 0.35);
}
:global(:root.glass) .bleed-fade {
background: linear-gradient(
rgba(24, 24, 24, 0.15) 0,
rgba(24, 24, 24, 0.75) 38%,
rgba(24, 24, 24, 0.85) 78%
);
}
/* The wash ends the way the album page's hero banner does (Kyle,
2026-08-26): art and fade dissolve together below the hero zone and
the lower pane is pure frost, the same 0.35 ground the sidebar and
playbar stand on. Solid mode needs none of this: its fade already
lands on opaque --bg-player. Masking a flattened group is one alpha
op on one surface, so the corner-arc story above is untouched.
(.bleed = the rail's wash, pane-relative %; .scroll-bleed = the
expanded wash, self-relative — it also must reach nothing at its box
end, where the glass fade tail would otherwise seam against frost.) */
:global(:root.glass) .bleed {
mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
-webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
}
:global(:root.glass) .scroll-bleed {
mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
-webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
}
/* ── the head periscope, dark glass only (see the script note) ──────
The head drops its backdrop-filter (over frost it black-flattens
into a slab — Sidebar.svelte holds the measurements), the scroller's
originals are masked out of the 46px band (direct-child selector:
the clone inside .peri also wears .np-scroll and must not inherit
it), and the clone paints the band as fog — wash included, so the
record tint survives at rest and shifts as you scroll. */
:global(:root.glass) .np-head {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* clip-path, not mask: rectangle clips ride the compositor's fast
path (see App.svelte's band-cut note). */
:global(:root.glass) .full-view > .np-scroll {
clip-path: inset(46px 0 0 0);
}
.peri {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
pointer-events: none;
}
/* The clone's filter and geometry are inline on its overscan wrapper —
built in the reclone() above; the band mask can't reach the clone
(direct-child selector), so no cancel rule is needed here. */
/* The head's real content rides above the fog; the hairline is already
absolute and only needs the lift. */
.np-head > :global(:not(.peri):not(.hairline)) {
position: relative;
z-index: 1;
}
.np-head .hairline {
z-index: 1;
}
/* Both views stack in the same box and cross-fade, on the left sidebar's
own label clock — the collapse itself is the width animation above. */
.rail-view,
@@ -1630,6 +1877,7 @@
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* The transfer, as a hairline along the glass bar's bottom edge — the same
object in the rail and in the expanded header, so a download reads the
same whichever state the panel is in. */