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 @@ -
+
+ {#if glassy} + + {/if}