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
+820 -116

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "vite build",
"tauri": "tauri",
"test": "node --test tests/preview/"
"test": "node --test \"tests/preview/*.test.mjs\""
},
"license": "ISC",
"devDependencies": {
+29 -48
View File
@@ -27,39 +27,6 @@ mod ytdlp;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Two WebView2 presentation fixes, both delivered by env var because
// tauri.conf's additionalBrowserArgs never reaches the browser process
// (verified via its command line). Merge, don't clobber — dev launches
// carry the CDP port here. Both found 2026-08-26 via the dark-glass work:
//
// 1. NO delegated compositing: with it, DWM composes the webview's layers
// at rest and blends semi-transparent darks in the wrong colorspace —
// the app rested washed-out LIGHT and looked correct only while an
// animation forced Chromium to compose in-process.
// 2. Force the sRGB color profile: on an HDR display the MAXIMIZED
// window's rest path takes a different SDR-white tone mapping than
// the animated path — covers blew out near-white at rest and snapped
// correct on hover. One profile, one mapping, both paths agree.
#[cfg(windows)]
{
const KEY: &str = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS";
let mut args = std::env::var(KEY).unwrap_or_default();
let mut ensure = |flag: &str, key: &str| {
if !args.contains(key) {
if !args.is_empty() {
args.push(' ');
}
args.push_str(flag);
}
};
ensure(
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,DelegatedCompositing",
"DelegatedCompositing",
);
ensure("--force-color-profile=srgb", "force-color-profile");
std::env::set_var(KEY, args);
}
tauri::Builder::default()
.manage(state::AppState::default())
.setup(|app| {
@@ -256,23 +223,37 @@ pub fn run() {
vocal_sep::vocal_sep_download,
])
.run({
#[allow(unused_mut)]
let mut context = tauri::generate_context!();
// Debug builds expose CDP on 9222 for instrumentation (see HANDOFF.md).
// wry always sets AdditionalBrowserArguments programmatically, so the
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var is ignored; the flag has
// to go through the window config. Explicit args replace wry's defaults,
// so those are restated here.
#[cfg(debug_assertions)]
{
for window in &mut context.config_mut().app.windows {
window.additional_browser_args = Some(
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection \
// Browser args ride the WINDOW CONFIG, nothing else: wry always sets
// AdditionalBrowserArguments programmatically, and at least WebView2
// 151.x then drops the WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var
// entirely — flags "delivered" that way vanish silently on some
// runtimes (they held on the home machine, gone on the work one).
// Verify in the browser process command line (Get-CimInstance
// Win32_Process), never by assumption. Explicit args replace wry's
// defaults, so those are restated. Two WebView2 presentation fixes
// live here (2026-08-26, the dark-glass work):
//
// 1. NO delegated compositing: with it, DWM composes the webview's
// layers at rest and blends semi-transparent darks in the wrong
// colorspace — the app rested washed-out LIGHT and looked correct
// only while an animation forced Chromium to compose in-process.
// 2. Force the sRGB color profile: on an HDR display the MAXIMIZED
// window's rest path takes a different SDR-white tone mapping
// than the animated path — covers blew out near-white at rest
// and snapped correct on hover. One profile, one mapping, both
// paths agree.
//
// Debug builds add CDP on 9222 for instrumentation (see HANDOFF.md).
let base = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,DelegatedCompositing \
--autoplay-policy=no-user-gesture-required \
--remote-debugging-port=9222"
.into(),
);
}
--force-color-profile=srgb";
#[cfg(debug_assertions)]
let args = format!("{base} --remote-debugging-port=9222");
#[cfg(not(debug_assertions))]
let args = base.to_string();
for window in &mut context.config_mut().app.windows {
window.additional_browser_args = Some(args.clone());
}
context
})
+1 -1
View File
@@ -35,7 +35,7 @@
"minimizable": false,
"alwaysOnTop": true,
"shadow": true,
"backgroundColor": "#181818"
"transparent": true
}
],
"security": {
+75 -19
View File
@@ -137,14 +137,18 @@
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. -->
<!-- The pane is a SHELL with the bar floating absolute over an inner
scroller — the same bar-over-scroller relationship the sidebar's
gbar and the np head have, which is what lets the dark-glass band
mask sit statically on the scroller (a bar INSIDE the scroller
would be masked with it; the per-frame page-mask this replaces
never tracked cleanly). The inner div KEEPS the .content class:
every page resolves its scroll pane via closest(".content"). -->
<main class="pane-shell" class:barless={!BAR_VIEWS.has(ui.view)} inert={previewUi.open || undefined}>
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
<div class="content" use:floatScroll>
<CurrentView />
</div>
</main>
<NowPlaying />
<PlayerBar />
@@ -224,7 +228,17 @@
acrylic. The panes above it all paint their own opaque surfaces and
must stay untouched. */
:global(:root.glass) .app {
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.55);
}
/* Dark glass, the content pane (TEST, album page first — 2026-08-26):
the pane joins the frost at the shared 0.35. The top-highlight
gradient is a solid-mode grounding for hero seams; under glass the
hero tails are thinned instead (see CatalogAlbum), so the frost is
flat here. GlassBar drops its backdrop under glass for the same
measured reason as every other bar (slab over frost). */
:global(:root.glass) .content {
background: rgba(0, 0, 0, 0.35);
}
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
@@ -250,8 +264,30 @@
--nowplaying-width: var(--nowplaying-rail);
}
.content {
/* The shell: grid cell, corner clip, and the two named lengths — on the
SHELL so both the bar (its child now) and every page (descendants of
the scroller) inherit them. Purely structural; all paint and scroll
behavior stays on the inner .content below. */
.pane-shell {
grid-area: content;
position: relative;
border-radius: var(--radius);
overflow: hidden;
/* The pane's top padding, named because three other rules are tied to
it: the glass bar's height pairs with it 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;
}
.content {
position: absolute;
inset: 0;
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
@@ -261,22 +297,42 @@
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;
/* The ORIGINAL padding, all views: the artist page's pinned/sticky
physics are tuned against a scrollport with this exact top
padding — zeroing it shifted every sticky offset 24px and the
page collapsed into itself (2026-08-26). The bar's old job of
cancelling this padding for what follows (so hero pages start
flush at the pane top) is reproduced by the spacer below. */
padding: var(--pane-pad) 28px;
}
/* The absolute bar contributes no flow, so this zero-height spacer
replays its old net-flow trick: on bar views, pull everything after
it up by exactly --pane-pad — the page's box starts flush at the
pane top, and each hero page restores its own inset, precisely the
pre-restructure contract. Barless views keep the classic inset. */
:global(.pane-shell:not(.barless) > .content)::before {
content: "";
display: block;
height: 0;
margin-bottom: calc(-1 * var(--pane-pad));
}
/* Dark glass: the band cut, the sidebar's exact move — static, in the
scroller's own box coordinates, with the bar safely OUTSIDE as a
sibling. clip-path, NOT mask-image: the cut is a hard edge, and a
rectangle clip stays on the compositor's fast path where an alpha
mask forced the 7k-node library through ~14ms raster frames
(measured 2026-08-26). Skipped on barless views. */
:global(:root.glass) .pane-shell:not(.barless) > .content {
clip-path: inset(var(--bar-h) 0 0 0);
}
/* Painted pages (data-painted roots) keep their content running under
the bar for the real backdrop to sample — GlassBar's periscope effect
lifts this clip INLINE when it toggles .painted-page (an inline write
outranks this rule without a specificity war). */
</style>
+213 -36
View File
@@ -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
+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. */
+9
View File
@@ -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;
+162 -1
View File
@@ -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;
+8
View File
@@ -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.
+14 -2
View File
@@ -2,9 +2,21 @@
// root (global.css thins the base surfaces), the OS half is the acrylic
// backdrop behind the transparent window (window_glass command). Both are
// idempotent, so boot and the Settings toggle share this one entry point.
//
// This file runs in the MAIN window only. The mini frosts itself from the
// `mini:glass` event this emits (one setting, two windows): it toggles its
// own root class and invokes window_glass, which acts on the CALLING window.
// The mini must never import this — its applier would re-emit into its own
// listener.
import { emit } from "@tauri-apps/api/event";
import { windowGlass } from "./api.js";
let current = false;
export const glassOn = () => current;
export function applyGlass(on) {
document.documentElement.classList.toggle("glass", !!on);
void windowGlass(!!on).catch(() => {});
current = !!on;
document.documentElement.classList.toggle("glass", current);
void windowGlass(current).catch(() => {});
void emit("mini:glass", { on: current });
}
+6
View File
@@ -11,11 +11,14 @@
// throttled ~5/s
// mini:queue { rows: [{ title, dur }], index, next: { title, artUrl } }
// on queue shape change
// mini:glass { on } on dark-glass flips
// (applyGlass) + hello
// mini:cmd { op, value } mini → here
// mini:hello {} mini → here, answered
// with a full snapshot
import { emit } from "@tauri-apps/api/event";
import * as api from "./api.js";
import { glassOn } from "./glass.js";
import {
player,
theme,
@@ -154,6 +157,9 @@ export function initMiniBridge() {
void emitTrack();
void emitQueue();
emitTick();
// Frost state rides its own channel: hello answers with the current
// value, applyGlass pushes flips live (see glass.js).
void emit("mini:glass", { on: glassOn() });
});
api.onEvent("mini:cmd", (p) => {
const { op, value } = p ?? {};
+13
View File
@@ -69,6 +69,19 @@
background: var(--bg-player);
user-select: none;
}
/* Dark glass, mini edition: unlike the main window (only the canvas
BETWEEN panes thins), the mini is all pane — so the pane itself thins
to the same black tint the main's .app uses (one dial, two files —
keep the alphas in step), over the OS acrylic (window_glass).
global.css already stops html/body painting under .glass; every
shape's own --bg-player fill is overridden in one place here. The
Wash art bleed stays as-is above it. */
:global(:root.glass) .miniroot {
background: transparent;
}
:global(:root.glass) .miniroot :global(.shape) {
background: rgba(0, 0, 0, 0.55);
}
:global(.m-disc) {
border-radius: 50%;
background: var(--accent);
+10
View File
@@ -103,6 +103,16 @@ export function initMini() {
mini.index = p.index ?? -1;
mini.next = p.next ?? null;
});
// Dark glass, the mini's half: the brain streams the one setting
// (glass.js emits on flips, the bridge answers hello); this window
// toggles its own root class and asks the OS for its own acrylic —
// window_glass acts on the calling window. Don't import glass.js here:
// its applier emits mini:glass, which this listener would loop on.
onEvent("mini:glass", (p) => {
const on = !!p?.on;
document.documentElement.classList.toggle("glass", on);
void invoke("window_glass", { on }).catch(() => {});
});
// Ask the brain for a full snapshot; retry briefly in case the mini won
// the race against the bridge's listener registration.
void emit("mini:hello", {});
+10
View File
@@ -1164,6 +1164,16 @@
var(--bg-base) 100%
);
}
/* Dark glass: the banner DISSOLVES at its foot instead of landing on a
repainted tail — a translucent tail color can never match the frost
beneath and drew a hard line at the box edge (Kyle, 2026-08-26). The
mask fades the whole layer (img + scrim together) to nothing, so it
ends on frost at frost's own value — the np wash's move. Solid mode
keeps the gradient's opaque landing against --bg-base. */
:global(:root.glass) .banner {
mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
}
/* `stretch`, not `flex-end`: the copy column is sized BY the artwork, so
its first line starts at the cover's top edge and its last row ends at
+6
View File
@@ -4584,8 +4584,14 @@
// (defe225) when the new location lands.
</script>
<!-- data-painted: this page paints its ground floor-to-ceiling (hero image
+ the .sheet's opaque tail), so under dark glass the GlassBar keeps its
REAL backdrop-filter here — no band cut, no periscope fog. The frost
never shows through a painted page, and the fog was replacing the
designed hero-under-glass continuity with a blurred imitation. -->
<div
class="artist-page"
data-painted
data-load-stage={pageLoadStage}
data-scroll={heroScrolled ? "1" : "0"}
data-park={extrasParked ? "1" : "0"}
+10 -3
View File
@@ -1503,7 +1503,7 @@
{#snippet cover(a, size)}
{#if a.art_path}
{#if coverUrls[a.id]}
<img class="cover" src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
<img class="cover" src={coverUrls[a.id]} alt="" loading="lazy" decoding="async" draggable="false" />
{:else}
<div class="cover gen-pending"></div>
{/if}
@@ -1564,7 +1564,7 @@
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
{/if}
{#if r.cover}
<img class="cover" src={r.cover} alt="" loading="lazy" draggable="false" />
<img class="cover" src={r.cover} alt="" loading="lazy" decoding="async" draggable="false" />
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
@@ -2190,7 +2190,7 @@
>
<span class="lb-cell lb-thumb">
{#if a.art_path && coverUrls[a.id]}
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
<img src={coverUrls[a.id]} alt="" loading="lazy" decoding="async" draggable="false" />
{:else if !a.art_path}
{@const g = genFor(a)}
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
@@ -2961,6 +2961,13 @@
border-radius: 7px;
font-size: 12.5px;
cursor: pointer;
/* 800+ rows: offscreen ones skip render entirely. The intrinsic
hint matches the fixed 36px height exactly, so scroll geometry
(and every offset-based jump) is unchanged — this took the
library's mount task from ~112ms and its cold-scroll raster
spikes down to screen-sized work (measured 2026-08-26). */
content-visibility: auto;
contain-intrinsic-size: auto 36px;
}
.lib-page[data-cols="all"] .lb-tr {
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 40px 56px 96px 58px 66px 74px;