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:
1 parent
225c9139aa
commit
6ba1864498
16 files changed
+821
-117
No files matched your search
+213
-36
@@ -32,6 +32,161 @@
|
||||
/** `hidden` hides the bar without unmounting it — see the note above. */
|
||||
let { children, hidden = false } = $props();
|
||||
|
||||
// ── the periscope, dark glass only — third instance of the pattern,
|
||||
// now the sidebar's EXACT shape (Sidebar.svelte holds the measured
|
||||
// story): the bar floats OUTSIDE the scroller (App.svelte's pane
|
||||
// shell), the band mask sits statically on the scroller, and this
|
||||
// clone paints the band as fog. The clone drops the scroller's own
|
||||
// background (the frost must not double) and cancels its band mask;
|
||||
// the album banner — the one full-bleed layer — stretches sideways
|
||||
// into the overscan margins (content-edge falloff, the np wash story).
|
||||
let glassy = $state(document.documentElement.classList.contains("glass"));
|
||||
let barEl = $state(null);
|
||||
let periEl = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const mo = new MutationObserver(() => {
|
||||
glassy = document.documentElement.classList.contains("glass");
|
||||
});
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => mo.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!glassy || !barEl || !periEl) return;
|
||||
const view = barEl.parentElement?.querySelector(":scope > .content");
|
||||
const peri = periEl;
|
||||
if (!view) return;
|
||||
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 = () => {
|
||||
// Painted pages (data-painted on the page root — the artist page)
|
||||
// ground the bar themselves: real backdrop-filter, no cut, no
|
||||
// fog. The shell class drives the CSS exemptions in App.svelte
|
||||
// and below; re-evaluated on every page swap via the observer.
|
||||
const painted = !!view.querySelector(":scope > [data-painted]");
|
||||
barEl.parentElement?.classList.toggle("painted-page", painted);
|
||||
// The band cut is a stylesheet rule; the exemption is this
|
||||
// inline write (outranks it flatly — see App.svelte's note).
|
||||
view.style.clipPath = painted ? "none" : "";
|
||||
if (painted) {
|
||||
peri.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
// height 142 = 48 overscan + 46 band + 48 overscan: the filter
|
||||
// rasterizes THIS box, not the full cloned page — blurring
|
||||
// thousands of px to show 46 was the single biggest frame cost.
|
||||
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.style.background = "none";
|
||||
c.style.maskImage = "none";
|
||||
c.style.webkitMaskImage = "none";
|
||||
// The artist page declares a named scroll-timeline INLINE on the
|
||||
// scroller; cloned along, it re-declares the name on this never-
|
||||
// scrolling copy and every cloned ride freezes at an empty pose —
|
||||
// the band fogged VOID over the hero. Shed it; the animation-none
|
||||
// rule below drops all cloned rides to their base styles.
|
||||
c.style.removeProperty("scroll-timeline");
|
||||
// Canvases clone BLANK — dead weight in the fog; and the clone's
|
||||
// node count feeds the next debounce.
|
||||
c.querySelectorAll("canvas").forEach((n) => n.remove());
|
||||
lastNodes = c.querySelectorAll("*").length;
|
||||
const banner = c.querySelector(".banner");
|
||||
if (banner) {
|
||||
banner.style.left = "-48px";
|
||||
banner.style.right = "-48px";
|
||||
}
|
||||
wrap.appendChild(c);
|
||||
// First fog after an empty band fades in; routine reclones swap
|
||||
// invisibly in place.
|
||||
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, and no `class` in the filter: hover highlights
|
||||
// flip classes constantly and were re-cloning the whole pane per
|
||||
// flip — the fog doesn't care about hover states (the band's
|
||||
// originals are masked anyway). Content identity arrives via
|
||||
// childList; art via src.
|
||||
// Debounce scales with page size and the clone itself runs at idle:
|
||||
// the library's 7k-node page cost a 126ms main-thread task per
|
||||
// reclone (measured 2026-08-26) — streamed-in covers were janking
|
||||
// scroll by re-cloning mid-frame. Fog after a page swap arrives a
|
||||
// beat late and FADES in (see reclone) instead of popping.
|
||||
const queueReclone = () => {
|
||||
clearTimeout(pending);
|
||||
const delay = Math.min(600, 120 + lastNodes / 40);
|
||||
pending = setTimeout(() => {
|
||||
// Never clone mid-scroll: the library's 117ms clone task was
|
||||
// the last source of scroll jank. The fog's translate stays
|
||||
// correct regardless; only content freshness waits. A hidden
|
||||
// window (mini mode) does no fog work at all — the
|
||||
// visibilitychange listener below re-queues on return.
|
||||
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) => {
|
||||
// A direct-child swap = the PAGE changed: drop the stale fog
|
||||
// now (frost in the band beats last page's ghost) and let the
|
||||
// fresh clone fade in when it lands.
|
||||
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 crumbs = $derived(nav.stack.length ? trail() : []);
|
||||
const back = $derived(canGoBack());
|
||||
const forward = $derived(canGoForward());
|
||||
@@ -80,7 +235,10 @@
|
||||
|
||||
<!-- data-scrollhead keeps the overlay scrollbar's thumb below this bar rather
|
||||
than running under it (see src/lib/scrollbar.js). -->
|
||||
<div class="glassbar" class:hidden data-scrollhead>
|
||||
<div class="glassbar" class:hidden data-scrollhead bind:this={barEl}>
|
||||
{#if glassy}
|
||||
<div class="peri" bind:this={periEl} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class="hist">
|
||||
<button class="arrow" disabled={!back} title="Back" aria-label="Back" onclick={navBack}>
|
||||
<Icon name="chevron-left" size={17} />
|
||||
@@ -125,42 +283,25 @@
|
||||
|
||||
<style>
|
||||
.glassbar {
|
||||
/* --bar-h is INHERITED from the scroll pane (App.svelte), not declared
|
||||
here: pages need the same number to decide whether to clear the bar
|
||||
or run their artwork under it, and two copies would drift. Safe
|
||||
because this component is only ever mounted inside that pane — see
|
||||
the note at the top of the file.
|
||||
Sticky rather than absolute, so the bar pins to the top of the pane
|
||||
and stays there as the page scrolls, and `top` pins it flush against
|
||||
the very top of the pane over that padding.
|
||||
/* --bar-h and --pane-pad are INHERITED from the pane shell
|
||||
(App.svelte), not declared here: pages need the same numbers to
|
||||
decide whether to clear the bar or run their artwork under it, and
|
||||
two copies would drift.
|
||||
|
||||
The negative margins reproduce an absolute bleed while keeping the
|
||||
bar in normal flow, and they do TWO jobs:
|
||||
|
||||
margin-top -pane-pad : bleed up over the pane's top padding
|
||||
(and -28 out to its left/right edges)
|
||||
margin-bottom -bar-h : consume no flow height of its own, and
|
||||
cancel the pane's top padding for what follows
|
||||
|
||||
so the net flow contribution is -pane-pad, and the PAGE's box after
|
||||
it starts flush with the top of the pane — which is what lets a hero
|
||||
run underneath the bar rather than beginning below it. Each page
|
||||
under the bar restores its own content inset with
|
||||
`padding-top: var(--pane-pad)`; see the note by BAR_VIEWS in
|
||||
App.svelte.
|
||||
|
||||
This used to come from margin collapsing instead: the bar was each
|
||||
page's first child, and its negative top margin collapsed through the
|
||||
page root and pulled it up. Hoisting the bar out ended the collapse
|
||||
and dropped every artist-page hero layer 24px (the `.hero-bg` stack
|
||||
is absolutely positioned against `.artist-page`), so the geometry is
|
||||
stated here rather than emerging from where the bar happens to sit.
|
||||
|
||||
All of these are tied to the pane's own padding — change one and the
|
||||
seam opens. */
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--pane-pad));
|
||||
margin: calc(-1 * var(--pane-pad)) -28px calc(-1 * var(--bar-h));
|
||||
Absolute over the scroller, not sticky inside it (2026-08-26): the
|
||||
bar used to sit IN the scroll pane, with negative margins faking
|
||||
this exact overlay while cancelling the pane's top padding. The
|
||||
dark-glass band mask must hide the scroller's top band WITHOUT
|
||||
touching the bar, which demands the bar OUTSIDE the scroller —
|
||||
the bar-over-scroller shape the sidebar's gbar and the np head
|
||||
always had. The flush-page geometry moved with it: the scroller's
|
||||
own top padding is 0 on bar views (App.svelte), and each page
|
||||
under the bar restores its inset with padding-top: var(--pane-pad)
|
||||
exactly as before. */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* Above the artist page's pinned identity (30) and hero (20): the bar
|
||||
keeps the top of the pane to itself and the parked wordmark slides
|
||||
under it. */
|
||||
@@ -184,6 +325,42 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dark glass: over the frosted pane the backdrop sample black-flattens
|
||||
into a slab (the measured story in Sidebar.svelte), so the bar drops
|
||||
its backdrop and the .peri clone fog paints the band instead — the
|
||||
app's third periscope, now the sidebar's EXACT shape: static band
|
||||
mask on the scroller (App.svelte), bar floating outside it. */
|
||||
:global(:root.glass) .glassbar {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
/* Painted pages ground the glass themselves — the original recipe is
|
||||
physically safe there and IS the designed hero-under-glass look. */
|
||||
:global(:root.glass .pane-shell.painted-page) .glassbar {
|
||||
backdrop-filter: blur(12px) saturate(1.7);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.7);
|
||||
}
|
||||
.peri {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* No animations inside the fog: cloned scroll-driven rides can never
|
||||
advance (their timeline source is the real scroller, not an ancestor
|
||||
here) and would hold empty from-poses. Base styles are the sensible
|
||||
static pose for a 46px blur. */
|
||||
.peri :global(.content) :global(*) {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
/* The bar's chrome rides above the fog. */
|
||||
.glassbar > :global(:not(.peri)) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* `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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -1359,6 +1359,11 @@
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
border-radius: var(--radius);
|
||||
/* Dark glass (the rule right below): this pane frosts at the same
|
||||
0.35-over-canvas net as the sidebar and the search pill. Nothing
|
||||
scrolls beneath this bar and it holds no backdrop-filter, so the
|
||||
frost is the whole story — no periscope needed here. The moment
|
||||
either changes, steal the pattern from Sidebar.svelte. */
|
||||
|
||||
/* One clock for the whole gesture. Chosen on
|
||||
research/playbar-redesign/expand-collapse.html: 350ms on
|
||||
@@ -1397,6 +1402,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
:global(:root.glass) .player {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.player > .now-playing,
|
||||
.player > .extras {
|
||||
position: absolute;
|
||||
|
||||
@@ -18,6 +18,121 @@
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
// ── the periscope (dark glass only) ─────────────────────────────────
|
||||
// The bar's backdrop-filter black-flattens over frost (see the CSS note),
|
||||
// so under glass the bar blurs a CLONE of the list instead: the original
|
||||
// rows are masked out of the bar band, and a cloneNode copy — filtered
|
||||
// with a normal blur, which composites fine over frost — paints them
|
||||
// back as fog, scroll-synced. Approved from the side-by-side test page
|
||||
// (research/glass-periscope) 2026-08-26.
|
||||
let glassy = $state(document.documentElement.classList.contains("glass"));
|
||||
let viewEl = $state(null);
|
||||
let periEl = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const mo = new MutationObserver(() => {
|
||||
glassy = document.documentElement.classList.contains("glass");
|
||||
});
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => mo.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!glassy || !viewEl || !periEl) return;
|
||||
const view = viewEl;
|
||||
const peri = periEl;
|
||||
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 = () => {
|
||||
// Blur on an OVERSCAN wrapper, 48px past the clip on every side:
|
||||
// a gaussian fades toward transparent at its element's bounds,
|
||||
// and with the bounds out there the falloff is clipped away
|
||||
// instead of vignetting the band edges (found on the np head —
|
||||
// same physics here). The clone keeps the scoping class (every
|
||||
// scoped rule, collapsed mode included, styles it identically)
|
||||
// and is pinned inside by margins at its measured width.
|
||||
// height 142 = overscan + band + overscan: the filter rasters
|
||||
// this box, never the full cloned list.
|
||||
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;
|
||||
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 list 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();
|
||||
};
|
||||
});
|
||||
|
||||
// Through goToView, not a bare `ui.view = …`: the glass bar's history and
|
||||
// trail are fed by recorded visits, and a destination that skips the
|
||||
// recorder is one the back arrow can never return to.
|
||||
@@ -160,6 +275,9 @@
|
||||
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
|
||||
<!-- One element, outside the list, so it never fades with the content. -->
|
||||
<div class="gbar">
|
||||
{#if glassy}
|
||||
<div class="peri" bind:this={periEl} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button
|
||||
class="status"
|
||||
data-state={app.connection}
|
||||
@@ -207,7 +325,7 @@
|
||||
any difference between the copies reads as doubling. Here nothing fades
|
||||
but text, and no glyph can move — every x comes from padding, which
|
||||
does not depend on the panel's width. -->
|
||||
<div class="view">
|
||||
<div class="view" bind:this={viewEl}>
|
||||
{#each items as item (item.id)}
|
||||
<button
|
||||
class="nav-item"
|
||||
@@ -299,6 +417,47 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── dark glass: frost + the periscope bar ──────────────────────────
|
||||
The pane frosts (0.35 over the .app canvas tint). The bar CANNOT use
|
||||
backdrop-filter here: over see-through frost the compositor flattens
|
||||
the sampled backdrop against opaque black and the bar renders as a
|
||||
slab (measured 2026-08-26: band 3 vs pane 40). Instead, under glass:
|
||||
the original rows are masked out of the bar band, and the .peri box
|
||||
inside the bar shows a scroll-synced CLONE of the list under a
|
||||
normal filter blur — normal filters composite correctly over frost
|
||||
(the np wash proves it daily). Numbers approved on the test page
|
||||
(research/glass-periscope): frost 0.35, tint 0.28, blur 17, sat 1.3.
|
||||
Solid mode: every rule below is inert, original recipe throughout. */
|
||||
:global(:root.glass) .sidebar {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
:global(:root.glass) .gbar {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
/* Direct child only — the periscope clone is inside .peri and must
|
||||
never inherit this band cut (it would blank the fog). clip-path,
|
||||
not mask: rectangle clips ride the compositor's fast path (see
|
||||
App.svelte's band-cut note). */
|
||||
:global(:root.glass) .sidebar > .view {
|
||||
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. */
|
||||
.gbar .status {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
/* ── the glass bar ── */
|
||||
.gbar {
|
||||
position: absolute;
|
||||
@@ -316,6 +475,8 @@
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.gicon {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
|
||||
@@ -678,6 +678,14 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Dark glass: the pill goes translucent so the acrylic reads through it
|
||||
too — its alpha COMPOSITES over the .app canvas tint (0.55), so 0.35
|
||||
here nets ~0.71 black where the pill sits: darker than the canvas
|
||||
around it, still glass. Solid mode above stays untouched. */
|
||||
:global(:root.glass) .bar-search {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* The ONE change to the field: the focus ring was `--text-muted`, which
|
||||
read as a dead grey border rather than as the field being live. It is the
|
||||
accent now, and a ↵ hint appears beside the caret to say what Enter does.
|
||||
|
||||
Reference in new issue
Block a user