diff --git a/package.json b/package.json
index d9a4378..41f3133 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 2cd443a..9fb519f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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.
+ // 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 \
+ --force-color-profile=srgb";
#[cfg(debug_assertions)]
- {
- for window in &mut context.config_mut().app.windows {
- window.additional_browser_args = Some(
- "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection \
- --autoplay-policy=no-user-gesture-required \
- --remote-debugging-port=9222"
- .into(),
- );
- }
+ 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
})
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 9c54459..93b8444 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -35,7 +35,7 @@
"minimizable": false,
"alwaysOnTop": true,
"shadow": true,
- "backgroundColor": "#181818"
+ "transparent": true
}
],
"security": {
diff --git a/src/App.svelte b/src/App.svelte
index 65cbb72..85d2248 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -137,14 +137,18 @@
scrim intercepts pointer input and the dialog traps the keyboard.
The titlebar stays live for window chrome. -->
-
-
+
+
-
+
+
+
@@ -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). */
diff --git a/src/components/GlassBar.svelte b/src/components/GlassBar.svelte
index 8397224..5f27639 100644
--- a/src/components/GlassBar.svelte
+++ b/src/components/GlassBar.svelte
@@ -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 @@
-