diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9fb519f..9b2f5ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -243,10 +243,20 @@ pub fn run() { // than the animated path — covers blew out near-white at rest // and snapped correct on hover. One profile, one mapping, both // paths agree. + // 3. Force the wheel scroll animator ON: this WebView2 shipped + // wheel detents as INSTANT 120px jumps (measured: a dispatched + // detent moved scrollTop in exactly one frame) while the + // present pipeline ran a flawless 144Hz — scroll position + // updating at detent cadence reads as "choppy but the FPS + // says 144". The system animation setting is on and + // prefers-reduced-motion is off, so the snap is WebView2's + // embedded default, not the Chromium system heuristic; the + // switch pins enable_scroll_animator=true in WebPreferences. // // 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 \ + --enable-smooth-scrolling \ --force-color-profile=srgb"; #[cfg(debug_assertions)] let args = format!("{base} --remote-debugging-port=9222"); diff --git a/src/App.svelte b/src/App.svelte index 85d2248..dc9fcdb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -37,7 +37,7 @@ } from "./lib/state.svelte.js"; import { floatScroll } from "./lib/scrollbar.js"; import { initMiniBridge } from "./lib/miniBridge.svelte.js"; - import { applyGlass } from "./lib/glass.js"; + import { applyGlass, armGlassResizeGuard } from "./lib/glass.js"; import { getSettings } from "./lib/api.js"; const views = { @@ -108,6 +108,12 @@ void getSettings() .then((s) => s?.glass_mode && applyGlass(true)) .catch(() => {}); + // Windows drops the acrylic blur for the length of a live resize and the + // window stays transparent through it, so the desktop punches through + // sharp. The guard grounds the canvas for the gesture — see the note on + // armGlassResizeGuard. Armed unconditionally: the CSS gates on .glass, and + // this way the mode can be toggled mid-drag without leaving stale state. + armGlassResizeGuard(); autoConnect(); refreshLibrary(); refreshPlaylists(); @@ -231,6 +237,26 @@ background: rgba(0, 0, 0, 0.55); } + /* While the window is being resized, the canvas GROUNDS itself. + Windows drops the acrylic blur for the whole of a live resize — that is + a documented limitation of the legacy accent-policy backdrop the OS half + uses (see window_glass in src-tauri/src/commands.rs), not something the + app can switch off. The window stays transparent through it, so what the + eye gets is not "less blur" but the raw desktop, sharp: wallpaper, + icons, other windows. Measured 2026-08-27 by toggling window_glass on + its own — with the acrylic off you can read desktop icon labels straight + through the titlebar strip and the sidebar. + The blur cannot be kept, so the transparency stops being used for the + length of the gesture. One opaque canvas is enough: every pane above it + is translucent over THIS, so grounding here grounds all of them. + --bg-sidebar is exactly what solid mode paints, so the drag looks like + the app with glass simply switched off, which is what it is. + .resizing is written by armGlassResizeGuard (src/lib/glass.js) and is + gated on .glass here, so solid mode never sees it. */ + :global(:root.glass.resizing) .app { + background: var(--bg-sidebar); + } + /* 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 diff --git a/src/components/ArtistExtras.svelte b/src/components/ArtistExtras.svelte index 8f8ab79..f7c8560 100644 --- a/src/components/ArtistExtras.svelte +++ b/src/components/ArtistExtras.svelte @@ -818,4 +818,27 @@ transform: none; } } + + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css; solid mode untouched. These sections sit low on + the artist page, below the hero's dissolve, where the ground is plain + pane frost. */ + :global(:root.glass) .ax-card:hover, + :global(:root.glass) .ax-own:hover { + background: var(--glass-hover); + } + :global(:root.glass) .ax-art, + :global(:root.glass) .ax-own { + background: var(--glass-surface); + } + :global(:root.glass) .ax-own { + border-color: var(--glass-hairline); + } + /* The activity timeline's bars are a chart, not chrome: the resting bar is + the "no releases" tone and the accent marks the hovered year, so the + two have to stay clearly apart. A white veil at the surface value keeps + that contrast against the frost the way #242424 does against #121212. */ + :global(:root.glass) .tl-bar { + background: var(--glass-press); + } diff --git a/src/components/GlassBar.svelte b/src/components/GlassBar.svelte index 5f27639..e554cb9 100644 --- a/src/components/GlassBar.svelte +++ b/src/components/GlassBar.svelte @@ -27,6 +27,7 @@ resolver, } from "../lib/state.svelte.js"; import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js"; + import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js"; import Icon from "./Icon.svelte"; /** `hidden` hides the bar without unmounting it — see the note above. */ @@ -58,27 +59,63 @@ const peri = periEl; if (!view) return; let pending = 0; - let lastScroll = 0; - let lastNodes = 500; + let recloneGen = 0; + let lastRecloneMs = 4; + // Observer mode. On a painted page the full subtree+src watch fed a + // guaranteed no-op pipeline: the artist hero swaps its src every 20s + // and covers stream in, and every batch armed the debounce, burned an + // idle callback and re-ran a reclone that returned immediately. So + // painted pages are watched with DIRECT CHILDREN ONLY — enough to see + // the next page swap. CONTRACT this introduces: data-painted is a + // mount-time-static attribute on a direct child of .content; a page + // toggling it in place without remounting will not be noticed (none + // does). + let narrowObs = false; + const FULL_OBS = { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["src"], + }; const sync = () => { - lastScroll = performance.now(); + markAppScroll(); 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. + // 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. + // Runs SYNCHRONOUSLY from the observer's page-swap branch — the old + // debounced-only determination left a 120-800ms window after + // navigating onto the artist page where the band cut and the dropped + // backdrop still applied over the fresh hero — and again from every + // reclone, which stays the authority for the resize/visibility/idle + // paths. + const applyPainted = () => { 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) { + // Re-observe only on an actual mode CHANGE — disconnect drops any + // queued records, so never cycle it per call. + if (painted !== narrowObs) { + narrowObs = painted; + mo.disconnect(); + mo.observe(view, painted ? { childList: true } : FULL_OBS); + } + return painted; + }; + const reclone = () => { + if (applyPainted()) { peri.replaceChildren(); return; } + // BUILD, detached, measured — the duration feeds the next + // debounce delay (it scales with subtree size exactly like the + // old full-tree node census, which existed only to guess it). + const t0 = performance.now(); + const gen = ++recloneGen; const wrap = document.createElement("div"); // height 142 = 48 overscan + 46 band + 48 overscan: the filter // rasterizes THIS box, not the full cloned page — blurring @@ -102,26 +139,35 @@ // 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. + // Canvases clone BLANK — dead weight in the fog. 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(); + lastRecloneMs = performance.now() - t0; + // ATTACH in its own frame: replaceChildren forces selector + // matching + style + layout of the entire clone — the expensive + // half of the task — and must never share a task with clone + // construction. Re-checked at attach: a stale build (page + // swapped, newer reclone, effect torn down — the gen bump) is + // dropped; resumed scrolling discards the wrap and requeues. + requestAnimationFrame(() => { + if (gen !== recloneGen) return; + if (!appScrollIdleFor(1000)) return queueReclone(); + // 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 @@ -135,17 +181,29 @@ // beat late and FADES in (see reclone) instead of popping. const queueReclone = () => { clearTimeout(pending); - const delay = Math.min(600, 120 + lastNodes / 40); + const delay = Math.min(600, 120 + lastRecloneMs * 3); 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. + // APP-WIDE recency (scrollbar.js note), a full second of it: + // 250ms sat inside natural inter-flick pauses, so the clone + // landed exactly between wheel bursts. Re-checked inside the + // idle callback with NO forcing timeout, and the callback + // yields when the granted idle slice is too thin for the + // build. A 17px-blurred band going stale for the extra beat + // is invisible; a clone task landing mid-scroll is the + // measured freeze-then-teleport stall. if (document.hidden) return; - if (performance.now() - lastScroll < 250) return queueReclone(); + if (!appScrollIdleFor(1000)) return queueReclone(); if ("requestIdleCallback" in window) { - requestIdleCallback(() => reclone(), { timeout: 800 }); + requestIdleCallback((deadline) => { + if (!appScrollIdleFor(1000)) return queueReclone(); + if (deadline && deadline.timeRemaining() < 15) return queueReclone(); + reclone(); + }); } else { reclone(); } @@ -153,22 +211,23 @@ }; 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. + // now (frost in the band beats last page's ghost), resolve + // painted-ness in the same breath (shell class, band exemption, + // observer mode — no debounce-wide mis-clip window), and only + // then decide whether there is fog to build. for (const r of records) { if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) { + // Invalidate any built-but-unattached clone: attaching + // the OLD page's fog after this clear would ghost it. + recloneGen++; peri.replaceChildren(); + if (applyPainted()) return; break; } } queueReclone(); }); - mo.observe(view, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["src"], - }); + mo.observe(view, FULL_OBS); const ro = new ResizeObserver(queueReclone); ro.observe(view); const onVis = () => { @@ -178,6 +237,7 @@ view.addEventListener("scroll", sync, { passive: true }); reclone(); return () => { + recloneGen++; mo.disconnect(); ro.disconnect(); document.removeEventListener("visibilitychange", onVis); diff --git a/src/components/LyricsPane.svelte b/src/components/LyricsPane.svelte index 42e8eb5..675cc35 100644 --- a/src/components/LyricsPane.svelte +++ b/src/components/LyricsPane.svelte @@ -932,4 +932,41 @@ transition: none; } } + + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css. This pane rides inside Now Playing, so its ground + is the same 0.7075 frost every other pane surface stands on — with one + wrinkle worth stating: while the album wash is still painting at the top + of the panel these surfaces sit on artwork instead, and a white veil + reads correctly on both. Solid mode is untouched. */ + :global(:root.glass) .line:hover, + :global(:root.glass) .cand:hover { + background: var(--glass-hover); + } + :global(:root.glass) .tool:hover, + :global(:root.glass) .pbtn.ghost:hover { + background: var(--glass-press); + } + :global(:root.glass) .nudge, + :global(:root.glass) .state-glyph, + :global(:root.glass) .pbtn.ghost, + :global(:root.glass) .mono, + :global(:root.glass) .c-tag.inst { + background: var(--glass-surface); + } + :global(:root.glass) .paste textarea { + background: var(--glass-well); + border-color: var(--glass-hairline); + } + /* The loading shimmer: same two-tone sweep, in white. */ + :global(:root.glass) .shimmer span { + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0.06) 35%, + rgba(255, 255, 255, 0.13) 50%, + rgba(255, 255, 255, 0.06) 65% + ); + background-size: 240% 100%; + } + /* Menus stay solid on purpose — see Playlists.svelte. */ diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 4161726..8c18e60 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -33,7 +33,7 @@ import LyricsPane from "./LyricsPane.svelte"; import PlaylistMenu from "./PlaylistMenu.svelte"; import * as api from "../lib/api.js"; - import { floatScroll } from "../lib/scrollbar.js"; + import { floatScroll, markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js"; import { splitDescription } from "../lib/editions.js"; import { accentFromImage } from "../lib/color.js"; import { @@ -353,10 +353,10 @@ const view = npScrollEl; const peri = npPeriEl; let pending = 0; - let lastScroll = 0; - let lastNodes = 500; + let recloneGen = 0; + let lastRecloneMs = 4; const sync = () => { - lastScroll = performance.now(); + markAppScroll(); const c = peri.firstElementChild?.firstElementChild; if (c) c.style.transform = `translateY(${-view.scrollTop}px)`; }; @@ -370,6 +370,11 @@ // its measured width, so layout and alignment are unchanged. // height 142 = overscan + band + overscan: the filter rasters // this box, never the full cloned scroller. + // BUILD, detached, measured — the duration feeds the next + // debounce delay (see GlassBar; the old node census existed + // only to guess it). + const t0 = performance.now(); + const gen = ++recloneGen; const wrap = document.createElement("div"); wrap.style.cssText = "position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" + @@ -382,7 +387,6 @@ 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 @@ -397,14 +401,22 @@ 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(); + lastRecloneMs = performance.now() - t0; + // ATTACH in its own frame (see GlassBar): the style+layout + // flush of the whole clone never shares a task with its + // construction; stale builds and resumed scrolling requeue. + requestAnimationFrame(() => { + if (gen !== recloneGen) return; + if (!appScrollIdleFor(1000)) return queueReclone(); + 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. @@ -412,13 +424,24 @@ // GlassBar note: big pages were janking frames with reclone tasks). const queueReclone = () => { clearTimeout(pending); - const delay = Math.min(600, 120 + lastNodes / 40); + const delay = Math.min(600, 120 + lastRecloneMs * 3); pending = setTimeout(() => { // Never clone mid-scroll, never while hidden (see GlassBar). + // APP-WIDE scroll recency (scrollbar.js note), a full second + // of it, re-checked inside the idle callback with no forcing + // timeout, and the callback yields on a thin idle slice: + // this pane's playback churn (queue rows, lyric lines, art) + // used to queue a full-pane clone gated only on the NP + // scroller's own last-scroll time, so it landed exactly when + // the user paused scrolling the CONTENT pane. if (document.hidden) return; - if (performance.now() - lastScroll < 250) return queueReclone(); + if (!appScrollIdleFor(1000)) return queueReclone(); if ("requestIdleCallback" in window) { - requestIdleCallback(() => reclone(), { timeout: 800 }); + requestIdleCallback((deadline) => { + if (!appScrollIdleFor(1000)) return queueReclone(); + if (deadline && deadline.timeRemaining() < 15) return queueReclone(); + reclone(); + }); } else { reclone(); } @@ -427,6 +450,8 @@ const mo = new MutationObserver((records) => { for (const r of records) { if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) { + // Invalidate any built-but-unattached clone (GlassBar note). + recloneGen++; peri.replaceChildren(); break; } @@ -448,6 +473,7 @@ view.addEventListener("scroll", sync, { passive: true }); reclone(); return () => { + recloneGen++; mo.disconnect(); ro.disconnect(); document.removeEventListener("visibilitychange", onVis); @@ -1808,6 +1834,76 @@ -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 96%); } + /* ── the chrome standing ON the frost (2026-08-27) ────────────────── + Below the wash's dissolve the panel is bare frost, and everything on + it was still painting opaque --bg-highlight: the quality chips, every + queue and release row, the icon buttons, the art wells. See the ladder + in global.css for why these elevate with white rather than more black. + Solid mode keeps every original recipe. */ + :global(:root.glass) .chip { + background: var(--glass-surface); + box-shadow: var(--glass-ring); + } + :global(:root.glass) .np-icon:hover, + :global(:root.glass) .row:hover, + :global(:root.glass) .np-head :global(.plm-trigger:hover) { + background: var(--glass-hover); + } + /* Artwork wells: what is visible while a cover loads, or in place of one + that never arrives. */ + :global(:root.glass) .cover, + :global(:root.glass) .un-fallback, + :global(:root.glass) .achip-ph, + :global(:root.glass) .av2, + :global(:root.glass) .rel-th, + :global(:root.glass) .mini .art { + background: var(--glass-surface); + } + /* The segmented control's thumb was `color-mix(--accent 28%, + --bg-highlight)` — accent-tinted but fully OPAQUE, so the one piece of + chrome that marks which tab you are on read as a solid brown slab on + the frost. This is the artist page's own tab recipe (.disco-tab.on), + copied deliberately: the two controls do the same job and should not + have two vocabularies. Nets 0.34 + 0.66x0.5 = 0.67 alpha. */ + :global(:root.glass) .seg-thumb { + background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); + } + /* The COLLAPSED rail's header wears the same .glass recipe as .np-head and + had the same flatten, but none of the periscope machinery — and it needs + none: the rail is a fixed column (header, edge, cover, dots), nothing + scrolls beneath this bar, so the filter had nothing to blur in the first + place. Dropping it removes a flattened slab and a backdrop pass. */ + :global(:root.glass) .rail-bar { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + /* Hairlines. --border is opaque #2a2a2a, which on frost reads as a drawn + grey line rather than an edge. These five all pair it with a translucent + fill they already had, so only the border was out of step. */ + :global(:root.glass) .upnext, + :global(:root.glass) .achip, + :global(:root.glass) .fan, + :global(:root.glass) .showall, + :global(:root.glass) .seg { + border-color: var(--glass-hairline); + } + /* The queue-count badge punches a 2px ring of opaque --bg-player around + itself to separate it from the icon underneath. On frost that ring is a + solid dot of #181818; the same separation comes from the frost itself. */ + :global(:root.glass) .rail-badge { + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35); + } + + /* NOTE, deliberately NOT changed: .segwrap keeps its backdrop-filter. + It is a bar that has to hide the rows scrolling under it, and over + frost a backdrop sample black-flattens (the measurement lives in + Sidebar.svelte). The shipped answer for a bar in that position is a + periscope clone, not a thinner fill — three of those exist already and + a fourth is a structural change, not a paint one. Left as a known + slab; revisit with the periscope pattern if it starts to grate. */ + /* ── 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 diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index 8f00515..28b2d07 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -1405,6 +1405,18 @@ :global(:root.glass) .player { background: rgba(0, 0, 0, 0.35); } + /* The cover well behind the bar's artwork: opaque --bg-highlight, so a + track with no art (or one still loading) punched a grey square through + the frost. Ladder value — see global.css. */ + :global(:root.glass) .art.placeholder, + :global(:root.glass) .art.cover { + background: var(--glass-surface); + } + /* The expand/collapse chevron at the bar's edge. */ + :global(:root.glass) .gicon:hover { + background: var(--glass-hover); + } + /* The overflow menu stays solid on purpose — see Playlists.svelte. */ .player > .now-playing, .player > .extras { diff --git a/src/components/SaveMenu.svelte b/src/components/SaveMenu.svelte index b2b6d74..7d4ae68 100644 --- a/src/components/SaveMenu.svelte +++ b/src/components/SaveMenu.svelte @@ -163,4 +163,15 @@ color: var(--text-primary); box-shadow: 0 12px 26px -10px rgba(0, 0, 0, 0.75); } + + /* ── dark glass ──────────────────────────────────────────────────────── + Only the TRIGGER. .save-icon is a 34px icon button that lives on the + page — album actions, the Now Playing head — so it stands on pane frost + and its opaque --bg-highlight hover read as a slab there. The popover + and its toast below it stay solid on purpose: a menu has to be readable + over whatever it lands on (the Settings copy for this mode says so). */ + :global(:root.glass) .save-icon:hover, + :global(:root.glass) .save-icon.on { + background: var(--glass-hover); + } diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index 76c8086..6ff0c0b 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -16,6 +16,7 @@ playlistCoverFor, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; + import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js"; import Icon from "./Icon.svelte"; // ── the periscope (dark glass only) ───────────────────────────────── @@ -42,10 +43,10 @@ const view = viewEl; const peri = periEl; let pending = 0; - let lastScroll = 0; - let lastNodes = 500; + let recloneGen = 0; + let lastRecloneMs = 4; const sync = () => { - lastScroll = performance.now(); + markAppScroll(); const c = peri.firstElementChild?.firstElementChild; if (c) c.style.transform = `translateY(${-view.scrollTop}px)`; }; @@ -59,6 +60,11 @@ // 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. + // BUILD, detached, measured — the duration feeds the next + // debounce delay (see GlassBar; the old node census existed + // only to guess it). + const t0 = performance.now(); + const gen = ++recloneGen; const wrap = document.createElement("div"); wrap.style.cssText = "position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" + @@ -71,16 +77,23 @@ 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(); + lastRecloneMs = performance.now() - t0; + // ATTACH in its own frame (see GlassBar): the style+layout + // flush of the whole clone never shares a task with its + // construction; stale builds and resumed scrolling requeue. + requestAnimationFrame(() => { + if (gen !== recloneGen) return; + if (!appScrollIdleFor(1000)) return queueReclone(); + 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. @@ -88,13 +101,22 @@ // GlassBar note: big pages were janking frames with reclone tasks). const queueReclone = () => { clearTimeout(pending); - const delay = Math.min(600, 120 + lastNodes / 40); + const delay = Math.min(600, 120 + lastRecloneMs * 3); pending = setTimeout(() => { // Never clone mid-scroll, never while hidden (see GlassBar). + // APP-WIDE scroll recency (scrollbar.js note), a full second + // of it, re-checked inside the idle callback with no forcing + // timeout, and the callback yields on a thin idle slice: + // this pane's own churn must not clone while the CONTENT + // pane scrolls. if (document.hidden) return; - if (performance.now() - lastScroll < 250) return queueReclone(); + if (!appScrollIdleFor(1000)) return queueReclone(); if ("requestIdleCallback" in window) { - requestIdleCallback(() => reclone(), { timeout: 800 }); + requestIdleCallback((deadline) => { + if (!appScrollIdleFor(1000)) return queueReclone(); + if (deadline && deadline.timeRemaining() < 15) return queueReclone(); + reclone(); + }); } else { reclone(); } @@ -103,6 +125,8 @@ const mo = new MutationObserver((records) => { for (const r of records) { if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) { + // Invalidate any built-but-unattached clone (GlassBar note). + recloneGen++; peri.replaceChildren(); break; } @@ -124,6 +148,7 @@ view.addEventListener("scroll", sync, { passive: true }); reclone(); return () => { + recloneGen++; mo.disconnect(); ro.disconnect(); document.removeEventListener("visibilitychange", onVis); @@ -442,6 +467,38 @@ :global(:root.glass) .sidebar > .view { clip-path: inset(46px 0 0 0); } + /* The rows ON the frost. --bg-highlight is opaque #242424, so under glass + every nav row and every pinned/recent row painted a slab on the pane — + the one Kyle photographed first. The white veil makes the same +18 step + against the frost that #242424 makes against #121212 (see the ladder in + global.css). Hover and active deliberately share one value: this sidebar + has always distinguished active by TEXT COLOUR (the note above the base + rule), and that is left exactly as it is. */ + :global(:root.glass) .nav-item:hover, + :global(:root.glass) .rec:hover, + :global(:root.glass) .nav-item.active, + :global(:root.glass) .rec.on { + background: var(--glass-hover); + } + :global(:root.glass) .gicon:hover { + background: var(--glass-hover); + } + /* The artwork well behind a recent with no cover yet. */ + :global(:root.glass) .art-placeholder { + background: var(--glass-surface); + } + /* --border is opaque #2a2a2a: a 1px divider on the frost read as a solid + scratch. The hairline token is the same weight against glass. */ + :global(:root.glass) .rule { + background: var(--glass-hairline); + } + /* The count badge's backdrop is the frosted pane, so its blur flattens the + sample to black exactly like the bar's did — the fill already carries + the look on its own. */ + :global(:root.glass) .bdg { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } .peri { position: absolute; inset: 0; diff --git a/src/components/TitleBar.svelte b/src/components/TitleBar.svelte index 43b4728..a2b2139 100644 --- a/src/components/TitleBar.svelte +++ b/src/components/TitleBar.svelte @@ -1016,4 +1016,25 @@ background: #c42b1c; color: #fff; } + + /* ── dark glass ──────────────────────────────────────────────────────── + The window buttons are the one surface in this file that stands on the + CANVAS rather than on a pane, and the math is different there: the + canvas is .app's 0.55, not a pane's 0.7075, so the ground behind them + is lighter and a hover needs a stronger veil to make the same step. + 0.12 is the value GlassBar's own .arrow:hover already uses for chrome + in this position. --glass-hover (0.08) is tuned for the 0.7075 pane and + would read as almost nothing up here. */ + :global(:root.glass) .win-btn:hover, + :global(:root.glass) .dl-ring:hover { + background: rgba(255, 255, 255, 0.12); + } + /* The ⏎ keycap inside the search pill; the pill is already glass. */ + :global(:root.glass) .kb { + background: var(--glass-hover); + } + /* The suggestions dropdown and everything inside it stay OPAQUE on + purpose — it is a menu, and a menu has to stay readable over whatever + page it lands on. Same call as Playlists/Playlist, and the Settings + copy for this mode says so out loud. */ diff --git a/src/lib/glass.js b/src/lib/glass.js index 1236aeb..4eefd9e 100644 --- a/src/lib/glass.js +++ b/src/lib/glass.js @@ -20,3 +20,44 @@ export function applyGlass(on) { void windowGlass(current).catch(() => {}); void emit("mini:glass", { on: current }); } + +/** Ground the canvas while the window is being resized. + * + * WHY THIS EXISTS. The OS half of glass mode is the LEGACY accent-policy + * acrylic — `SetWindowCompositionAttribute` with ACCENT_ENABLE_ACRYLICBLURBEHIND + * (see `window_glass` in src-tauri/src/commands.rs, and the comment there for + * why the modern DWM system backdrop was rejected). Windows drops that blur + * for the duration of a live resize; it has done so since Windows 10 2004 and + * it is not something the app can switch off. The moment it drops, the window + * is still TRANSPARENT — so the desktop behind it stops being a soft wash and + * punches through razor-sharp, wallpaper and icons and all. Measured + * 2026-08-27 by toggling `window_glass` alone: with the acrylic off you can + * read desktop icon labels through the titlebar and the sidebar. + * + * So the fix is not to keep the blur — Windows will not let us. It is to stop + * relying on it for the length of the gesture: the canvas goes opaque, and + * with nothing see-through there is nothing for the missing blur to ruin. The + * window reads as an ordinary solid dark window while you drag, and comes back + * to glass when you let go. + * + * `resize` fires continuously through a drag and once for a maximize, a + * restore or a DPI change, so all of those are covered by the same guard. The + * class goes on unconditionally and the CSS gates it on `.glass`, which keeps + * this correct if the mode is toggled mid-gesture. + */ +const RESIZE_SETTLE_MS = 180; +export function armGlassResizeGuard() { + if (typeof window === "undefined") return () => {}; + let timer = 0; + const onResize = () => { + document.documentElement.classList.add("resizing"); + clearTimeout(timer); + timer = setTimeout(() => document.documentElement.classList.remove("resizing"), RESIZE_SETTLE_MS); + }; + window.addEventListener("resize", onResize, { passive: true }); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", onResize); + document.documentElement.classList.remove("resizing"); + }; +} diff --git a/src/lib/heroGpu.js b/src/lib/heroGpu.js index 4fd5a2b..6c933ae 100644 --- a/src/lib/heroGpu.js +++ b/src/lib/heroGpu.js @@ -351,8 +351,12 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ function copyCanvas(from, to) { if (!from.width || !from.height) return; - to.width = from.width; - to.height = from.height; + // Guarded like copyBloom below: assigning width/height reallocates + // the backing store even when the value is unchanged — two ~10-20MB + // reallocs per rotation for nothing. The full-surface drawImage on + // an alpha:false context overwrites every pixel, so reuse is clean. + if (to.width !== from.width) to.width = from.width; + if (to.height !== from.height) to.height = from.height; const context = to.getContext("2d", { alpha: false }); context.imageSmoothingEnabled = true; context.imageSmoothingQuality = "high"; @@ -451,7 +455,19 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ }); gl.bindTexture(gl.TEXTURE_2D, finished.tx); gl.generateMipmap(gl.TEXTURE_2D); - gl.finish(); + // flush, NEVER finish — the same reasoning resize() records + // below: gl.finish() blocks the main thread until the GPU has + // drained, and it stalls hardest exactly when the GPU is busy. + // Here that meant the whole 4K EASU/RCAS/grade chain drained + // inside the rotation task — a main-thread block in the measured + // 100ms+ class on a 4-core iGPU, every ~20s hero rotation, worst + // in glass mode when the compositor shares the GPU. GL command + // ordering makes the temporaries' deletion in `finally` safe + // without draining (deletion of referenced resources is + // deferred), and nothing reads pixels back before the later + // drawImage copies impose their own sync. renderMs now reports + // enqueue time, not drain time — fine for the debug attribute. + gl.flush(); disposeTarget(finished, true); return { tx: finished.tx, width: HERO_MASTER_WIDTH, height: HERO_MASTER_HEIGHT }; } catch (error) { diff --git a/src/lib/scrollbar.js b/src/lib/scrollbar.js index 80b9784..631a6a9 100644 --- a/src/lib/scrollbar.js +++ b/src/lib/scrollbar.js @@ -26,6 +26,22 @@ const PAD = 8; // gap between the thumb's travel and the top header / bottom edge const HOVER_ZONE = 22; // px in from the right edge that reveals the thumb on hover +// One app-wide scroll-recency clock, living here because this module already +// sees every custom-scrolled pane (and, unlike glass.js, is safe for the mini +// to import — a separate window is a separate module graph). It exists for +// the periscopes' "never clone mid-scroll" gates: each pane's gate used to +// consult only its OWN scroller's last-scroll time, so DOM churn in one pane +// (np queue/lyric updates during playback, sidebar recents) queued a +// full-pane clone that landed the moment the user paused scrolling a +// DIFFERENT pane — measured 2026-08-27 as 200-750ms input-blocking tasks at +// artist-scroll pauses on a 4-core laptop. Marked by every periscope sync +// and by onScroll below; checked by every queueReclone, and RE-checked +// inside the idle callback, because an idle gap between wheel bursts is not +// "done scrolling". +let lastAppScroll = 0; +export const markAppScroll = () => (lastAppScroll = performance.now()); +export const appScrollIdleFor = (ms) => performance.now() - lastAppScroll >= ms; + // Scroll-driven-animation support, gated EXACTLY like CatalogArtist's // scrollDriven const so the two can never disagree about which engine owns // per-scroll motion. Where supported, the thumb's slide is a compositor @@ -169,6 +185,7 @@ export function floatScroll(node) { } function onScroll() { + markAppScroll(); // SDA path: the compositor owns the slide; touching scrollTop here // would force the style+layout pass this fix exists to remove. if (SDA) { diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index d3d83d9..e754bd9 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -966,11 +966,18 @@ const catalogHeroArtCache = new Map(); const catalogHeroGpuCache = new Map(); const CATALOG_HERO_PREVIEW_CACHE_MAX = 16; const CATALOG_HERO_ART_CACHE_MAX = 10; -const CATALOG_HERO_GPU_CACHE_MAX = 2; +// Sized for the ROTATION WORKING SET, not just the current+next pair: at 2 +// entries the 20s carousel over 3+ banners missed on every tick, and the +// 24MiB entry cap excluded every raw 4K payload (3840×2160×4 ≈ 31.6MiB — +// the baked-4K provider fallback), so those re-paid the backend decode and +// a fresh multi-MB raw-RGBA IPC transfer on every single revisit. Worst +// case here is 64MiB of pinned ArrayBuffers while an artist page rotates — +// revisit in the RAM pass if that bites on 8GB machines. +const CATALOG_HERO_GPU_CACHE_MAX = 4; const CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS = 12 * MIB; const CATALOG_HERO_ART_CACHE_MAX_CHARS = 32 * MIB; -const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 32 * MIB; -const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 24 * MIB; +const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 64 * MIB; +const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 32 * MIB; let catalogHeroArtEpoch = 0; let heroWarmMode = null; let heroWarmModeRequest = null; @@ -7252,12 +7259,36 @@ function writeSession() { } catch { // Quota, or a profile with storage disabled. The session is a // convenience; nothing downstream may depend on it having been written. + // Still stamp the attempt: with the stamp only on success, a failing + // setItem left `stale` permanently true and this whole function — + // captureScroll's forced layout included — re-ran on every 3s tick. + sessionWrittenAt = Date.now(); } } /** Cheap "has anything worth saving changed" probe, run on a timer rather than * from a reactive effect: the alternative is subscribing to a store that ticks * five times a second, and the only field that does is deliberately excluded. */ +let sessionWritePending = false; +/** The tick's writes go through idle scheduling: writeSession's + * captureScroll reads scrollTop — a forced style+layout while scroll-driven + * animations hold style dirty (the exact read the artist page's scroll + * machinery avoids) — so it must not land mid-scroll on a 3s clock. + * Direct writeSession callers (unload-style flushes) are unaffected. */ +function scheduleSessionWrite() { + if (sessionWritePending) return; + sessionWritePending = true; + const write = () => { + sessionWritePending = false; + writeSession(); + }; + if (typeof requestIdleCallback === "function") { + requestIdleCallback(write, { timeout: 2000 }); + } else { + setTimeout(write, 250); + } +} + function sessionTick() { if (sessionRestoring) return; const signature = [ @@ -7265,10 +7296,14 @@ function sessionTick() { player.status, player.queueIndex, player.queue.length, player.shuffle, player.repeat, player.volume, player.muted, ].join("|"); - const stale = player.status !== "idle" && Date.now() - sessionWrittenAt > SESSION_MAX_STALE_MS; + // Periodic position refresh only while PLAYING: a paused track's position + // does not move, and pausing itself flips the signature, so the paused + // position is captured once and the 12s forced rewrite (scroll read + + // full-queue stringify + sync localStorage) stops. + const stale = player.status === "playing" && Date.now() - sessionWrittenAt > SESSION_MAX_STALE_MS; if (signature === sessionSignature && !stale) return; sessionSignature = signature; - writeSession(); + scheduleSessionWrite(); } /** Re-source a track that was restored without its audio — an app restart, diff --git a/src/styles/global.css b/src/styles/global.css index 68ccf4c..673c2a9 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -107,6 +107,57 @@ background: transparent; } +/* ── the dark-glass SURFACE LADDER (2026-08-27) ───────────────────────────── + Everything above thins the window CANVAS. This block is about what stands + ON the panes: cards, rows, chips, hover and active states — which were all + still opaque and read as slabs floating on the frost (Kyle, 2026-08-27). + + These are NEW names. The four solid tokens above are never redefined here, + and the warning directly above is the reason: --bg-elevated/-highlight/ + -press/--border are consumed inside color-mix() and gradient stops (the + library toolbar's `color-mix(… var(--bg-elevated) 82%, transparent)`, the + album row's `linear-gradient(transparent, var(--bg-highlight) 14px)` text + fade), where an alpha in the token multiplies through and destroys the call + site — 0.74 × 0.03 = 0.022, an invisible chip. They also paint the modal + and import-window chrome, which must stay opaque. Panes opt in one at a + time; so do surfaces. + + WHY WHITE. A pane is 0.35 black over the .app canvas's 0.55 — a ground of + 0.7075 black over the acrylic. Adding BLACK to something standing on that + goes the wrong way: rgba(0,0,0,0.35) on a card inside .content nets + 0.35 + 0.7075×0.65 = 0.81, darker than its own pane and inside the ~0.85 + band already rejected once on the artist page (CatalogArtist.svelte, the + sheet-tail note). Panes add black; surfaces inside panes add white. + + The alphas are picked for STEP PARITY with solid mode, whose ladder is + measured from --bg-base #121212 (value 18): elevated #181818 is +6, + highlight #242424 is +18, press #2a2a2a is +24. Under glass the ground is + a transmission, not a value — 0.2925 × acrylic — so a white veil at alpha + `a` lifts it by a × (255 − ground). Solving for those same steps across a + dark-to-bright acrylic (ground 12 → 59) gives 0.03 / 0.08 / 0.11, which is + also, independently, the white-overlay vocabulary the newest glass chrome + already ships (.btn-ghost:hover and .ax-ghost:hover are rgba(255,255,255, + 0.08); the bar hairlines are 0.05; Library's inset ring is 0.06). + + --glass-well is the exception that proves the rule: a sunken INPUT still + wants to read as a hole, so it adds a little black. Not much — a field + inside a pane already stands on 0.7075, so the titlebar pill's 0.35 (which + is correct there, on the 0.55 canvas) would net 0.81 here. 0.18 nets 0.76, + a five-point sink, and the definition comes from --glass-hairline instead. + + Usage is always additive and always paint-only: + :global(:root.glass) .thing { background: var(--glass-surface); } + :global(:root.glass) .thing:hover { background: var(--glass-hover); } + Solid mode never sees any of it. */ +:root.glass { + --glass-surface: rgba(255, 255, 255, 0.03); + --glass-hover: rgba(255, 255, 255, 0.08); + --glass-press: rgba(255, 255, 255, 0.11); + --glass-hairline: rgba(255, 255, 255, 0.1); + --glass-well: rgba(0, 0, 0, 0.18); + --glass-ring: inset 0 0 0 1px rgba(255, 255, 255, 0.06); +} + /* Floating overlay scrollbar (see src/lib/scrollbar.js). The native bar is hidden so it never reserves width or reflows content; a thin thumb floats in the container's right padding, fading in on scroll/hover, out when idle. */ diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte index f0105d0..940bb3f 100644 --- a/src/views/CatalogAlbum.svelte +++ b/src/views/CatalogAlbum.svelte @@ -1930,4 +1930,54 @@ font-size: 26px; } } + + /* ── dark glass ──────────────────────────────────────────────────────── + Most of this page already speaks the white-veil vocabulary (.iconbtn, + .pill.ghost, .gpill). The tracklist did not: hovering a row dropped an + opaque #242424 band across the pane — the third of Kyle's screenshots. + Ladder in global.css; solid mode untouched. */ + :global(:root.glass) .trow:hover, + :global(:root.glass) .trow:focus-within, + :global(:root.glass) .trow:has(:global(.plm-trigger[aria-expanded="true"])) { + background: var(--glass-hover); + } + + /* The row-button shim is the subtle half of that change. In solid mode + .rowbtns fades to the SAME --bg-highlight as the hover, so the cluster + reads as "the buttons take over" while quietly occluding the last ~19px + of the listeners bar behind them (measured: the bar's fill ends 15px + inside .tcount, the buttons start 34px inside it). Nothing translucent + can occlude, so under glass the bar gets out of the way instead: the + shim stops painting and .tcount fades out just before the buttons + begin. Offsets are from .tcount's RIGHT edge so they hold at any pane + width — the button cluster is a fixed 96px and .tcount's right edge is + 70px from the row's, leaving the 34px start point. */ + :global(:root.glass) .rowbtns { + background: none; + } + :global(:root.glass) .trow:hover .tcount, + :global(:root.glass) .trow:focus-within .tcount { + mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent calc(100% - 34px)); + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent calc(100% - 34px)); + } + + :global(:root.glass) .pill:disabled { + background: var(--glass-surface); + } + /* The artwork well, when a record has no cover at all. */ + :global(:root.glass) .cover.placeholder { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11)); + } + :global(:root.glass) .ed-art.placeholder { + background: var(--glass-surface); + } + /* Loading skeletons, and the two hairline-bordered tags. --border is + opaque #2a2a2a and reads as a drawn line on frost. */ + :global(:root.glass) .sk { + background: rgba(255, 255, 255, 0.06); + } + :global(:root.glass) .tag-unreleased, + :global(:root.glass) .stat-note { + border-color: var(--glass-hairline); + } diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 7ea35c9..cfc083c 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -37,6 +37,7 @@ setFollowed, announceFollow, } from "../lib/state.svelte.js"; + import { appScrollIdleFor } from "../lib/scrollbar.js"; import * as api from "../lib/api.js"; import { accentFromImage } from "../lib/color.js"; import { @@ -252,7 +253,23 @@ const im = new Image(); im.onload = () => { if (!alive) return; - const baked = bakeBlur(im, im.naturalWidth, im.naturalHeight, 8); + // Display resolution, not naturalWidth: same reasoning as + // twinScale — a 4K source baked at full res is a 100ms-class + // blur for pixels CSS then throws away. Pre-scale into a + // scratch canvas (bakeBlur's edge-strip slices read source + // coordinates, so it must receive an already-scaled source) + // and scale the radius to keep the displayed softness. + const scale = Math.min( + 1, + ((heroWidth || window.innerWidth) * devicePixelRatio) / im.naturalWidth, + ); + const sw = Math.max(1, Math.round(im.naturalWidth * scale)); + const sh = Math.max(1, Math.round(im.naturalHeight * scale)); + const scaled = document.createElement("canvas"); + scaled.width = sw; + scaled.height = sh; + scaled.getContext("2d").drawImage(im, 0, 0, sw, sh); + const baked = bakeBlur(scaled, sw, sh, 8 * scale); canvas.width = baked.width; canvas.height = baked.height; canvas.getContext("2d").drawImage(baked, 0, 0); @@ -271,61 +288,89 @@ // The seam tail's mirror needs the same snapshot on the GPU path (a // live canvas has no url for the tail's ). let tailMirrorBox = $state(null); + /** The twins' backing resolution: the hero box's device pixels, never the + * source's. Both twins are CSS-stretched to the box, so source pixels + * beyond box×dpr are invisible — and baking the 4K source at full res + * made snapshotGpuTwins a measured 100-170ms main-thread stall on a + * 4-core iGPU (every rotation, every artist entry). The baked blur + * radius scales by the same factor, so the displayed softness is + * unchanged. */ + const twinScale = (src) => + Math.min(1, ((heroWidth || window.innerWidth) * devicePixelRatio) / src.width); + let twinBakeGeneration = 0; function snapshotGpuTwins() { if (!gpuTwinBox && !tailMirrorBox) return; const gpu = document.querySelector(".artist-page .gpu-hero"); const bannerSrc = gpu?.querySelector("canvas.banner.current"); if (!bannerSrc || !bannerSrc.width) return; - const dup = (src, cls) => { - if (!src || !src.width) return null; - const d = document.createElement("canvas"); - d.width = src.width; - d.height = src.height; - d.getContext("2d").drawImage(src, 0, 0); - d.className = cls; - return d; - }; - if (gpuTwinBox) { - // The twin's banner carries its 8px blur BAKED with clamped - // edges (see bakeBlur) — aligned, fringe-free. Rotation - // dissolve: same pattern as the tail below — the old baked - // frame stays underneath while the new one fades in on the - // banner's own curve (an instant replaceChildren here made the - // mono overlay SNAP to the next photo while the real hero was - // still mid-dissolve — Kyle caught it). Cap at two frames so - // rapid re-readies can never stack. - const bakedBanner = bakeBlur(bannerSrc, bannerSrc.width, bannerSrc.height, 8); - bakedBanner.className = "gt-banner"; - const oldFrames = [...gpuTwinBox.children]; - if (oldFrames.length) bakedBanner.classList.add("incoming"); - gpuTwinBox.append(bakedBanner); - while (gpuTwinBox.children.length > 2) { - gpuTwinBox.firstChild.remove(); + // SAME-TASK BOUNDARY: only this one scaled copy reads the WebGL + // drawing buffer (it does not survive the compositor pass — the + // reason this function is called synchronously from gpuHeroReady). + // Everything below works from the copy and is free to run later. + const scale = twinScale(bannerSrc); + const snap = document.createElement("canvas"); + snap.width = Math.max(1, Math.round(bannerSrc.width * scale)); + snap.height = Math.max(1, Math.round(bannerSrc.height * scale)); + snap.getContext("2d").drawImage(bannerSrc, 0, 0, snap.width, snap.height); + const swapIn = (box, frame) => { + const old = [...box.children]; + if (old.length) frame.classList.add("incoming"); + box.append(frame); + // Cap at two frames so rapid re-readies can never stack. + while (box.children.length > 2) { + box.firstChild.remove(); } - if (oldFrames.length) { + if (old.length) { setTimeout(() => { - for (const el of oldFrames) el.remove(); + for (const el of old) el.remove(); }, 1450); } - } + }; // Tail mirror: the banner frame only, styled like the img variant. // Rotation dissolve: the old frame stays underneath while the new // one fades in on the banner's own curve, then the old is dropped. - // Cap at two frames so rapid re-readies can never stack. if (tailMirrorBox) { - const t = dup(bannerSrc, "tail-mirror-frame"); - if (t) { - const old = [...tailMirrorBox.children]; - if (old.length) t.classList.add("incoming"); - tailMirrorBox.append(t); - while (tailMirrorBox.children.length > 2) { - tailMirrorBox.firstChild.remove(); - } - if (old.length) { - setTimeout(() => { - for (const el of old) el.remove(); - }, 1450); + const t = document.createElement("canvas"); + t.width = snap.width; + t.height = snap.height; + t.getContext("2d").drawImage(snap, 0, 0); + t.className = "tail-mirror-frame"; + swapIn(tailMirrorBox, t); + } + // The twin's banner carries its 8px blur BAKED with clamped edges + // (see bakeBlur) — aligned, fringe-free. Rotation dissolve: the old + // baked frame stays underneath while the new one fades in on the + // banner's own curve (an instant replaceChildren here made the mono + // overlay SNAP to the next photo while the real hero was still + // mid-dissolve — Kyle caught it). The bake runs off the ready task: + // it needs only `snap`, and the outgoing baked frame covering the + // box for one more beat of a 1400ms dissolve is invisible, while the + // blur landing inside the ready task was the biggest slice of the + // stall. The generation guard drops a pending bake when a newer + // ready (or unmount) has superseded it. + if (gpuTwinBox) { + const generation = ++twinBakeGeneration; + const bake = () => { + if (generation !== twinBakeGeneration || !gpuTwinBox) return; + // Same discipline as the periscope reclones: never bake + // within 250ms of app-wide scroll, re-check INSIDE the idle + // callback, and no forcing timeout — the outgoing baked + // frame covering one more beat of the 1400ms dissolve is + // invisible, while a bake forced mid-scroll is a dropped + // frame. The generation guard bounds requeues: the next + // ready supersedes a still-waiting bake. + if (!appScrollIdleFor(250)) { + setTimeout(bake, 250); + return; } + const bakedBanner = bakeBlur(snap, snap.width, snap.height, 8 * scale); + bakedBanner.className = "gt-banner"; + swapIn(gpuTwinBox, bakedBanner); + }; + if ("requestIdleCallback" in window) { + requestIdleCallback(bake); + } else { + setTimeout(bake, 50); } } } @@ -348,7 +393,14 @@ const source = document.querySelector(".artist-page .gpu-hero canvas.banner.current"); const baked = gpuTwinBox?.lastElementChild; if (!source || !source.width || !baked) return; - if (baked.width === source.width && baked.height === source.height) return; + // Compare against the SCALED target, not the source: the twins bake + // at display resolution (see twinScale), so source dims would always + // mismatch and re-bake on every resize-effect tick. + const scale = twinScale(source); + if ( + baked.width === Math.max(1, Math.round(source.width * scale)) + && baked.height === Math.max(1, Math.round(source.height * scale)) + ) return; snapshotGpuTwins(); } @@ -497,7 +549,17 @@ const custom = page?.banner_custom; const visible = documentVisible; if (!visible || count < 2 || custom) return; - const timer = setInterval(advanceHero, HERO_ROTATE_MS); + const timer = setInterval(() => { + // A rotation is pure decoration, but starting its pipeline mid- + // gesture lands the cost mid-gesture: a cache-miss source is a + // multi-MB raw-RGBA IPC arrival + full-res texture upload (a + // measured ~150ms main-thread task on a 4-core iGPU) whose + // promise resolves at an arbitrary moment relative to input. + // Skip the tick while the app scrolled recently — the current + // photo simply dwells one cycle longer. + if (!appScrollIdleFor(600)) return; + advanceHero(); + }, HERO_ROTATE_MS); return () => clearInterval(timer); }); @@ -4586,9 +4648,12 @@ + REAL backdrop-filter here — no band cut, no periscope fog. What the + contract actually needs is the BAR BAND: the gfade mask keeps the photo + full under the bar at every pose, so the bar always samples paint + (below the fade the glass page is deliberately bare pane frost now), + and the fog was replacing the designed hero-under-glass continuity + with a blurred imitation. -->
diff --git a/src/views/CatalogDiscography.svelte b/src/views/CatalogDiscography.svelte index 2363e2b..529d449 100644 --- a/src/views/CatalogDiscography.svelte +++ b/src/views/CatalogDiscography.svelte @@ -1607,4 +1607,58 @@ } } + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css; solid mode untouched. + + The pinned ambience shade lands on opaque --bg-base, which keeps the top + 440px of the page solid over the frost. Same answer as the album and + playlist banners: dissolve the whole clipped layer — img and shade + together — so it ends on frost at frost's own value. Repainting the + landing colour thinner cannot work; a translucent tail never matches + frost and draws a line at the box edge (Kyle, 2026-08-26). */ + :global(:root.glass) .pane-banner .clip { + mask-image: linear-gradient(to bottom, #000 55%, transparent 98%); + -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 98%); + } + + /* The header card's tint, and ONLY when the artist has no picture — then + .hero-tint is the first element child ({#if artist.picture}) and that + 0.9 black gradient is painting onto the pane instead of shading a photo. + Same test and same inversion as Home and CatalogSearch. */ + :global(:root.glass) .shero .hero-tint:first-child { + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0.055) 28%, + rgba(255, 255, 255, 0.02) 72%, + rgba(255, 255, 255, 0.045) + ); + } + + :global(:root.glass) .rec .trow:hover, + :global(:root.glass) .sortbtn:hover, + :global(:root.glass) .year-rail button:hover { + background: var(--glass-hover); + } + /* Unlike the album page, .rec .rowbtns has no fade shim to match — the + cluster is absolutely positioned and simply appears — so the hover + change needs no companion here. */ + + /* Its backdrop reaches the see-through pane, where the sample flattens to + black (Sidebar.svelte holds the measurement). The white fill already + carries the look. */ + :global(:root.glass) .rec .pill.ghost { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + :global(:root.glass) .skel i { + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.04), + rgba(255, 255, 255, 0.1), + rgba(255, 255, 255, 0.04) + ); + background-size: 200% 100%; + } + /* Menus stay solid on purpose — see Playlists.svelte. */ diff --git a/src/views/CatalogSearch.svelte b/src/views/CatalogSearch.svelte index 0d33d7f..345f186 100644 --- a/src/views/CatalogSearch.svelte +++ b/src/views/CatalogSearch.svelte @@ -2330,4 +2330,38 @@ display: none; } } + + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css. This page is mostly artwork already, so what was + left opaque is the wells behind art that has not arrived — and the + top-result hero on the days it has no picture to sit on. + + The :first-child test is the same one Home.svelte uses: .hero-bg only + renders when there IS artwork ({#if}), so a .hero-tint that is the + first element child is a 0.9 black gradient painting straight onto the + pane rather than a legibility scrim over a photo. Same diagonal, same + three stops, inverted to white. With artwork it is untouched. */ + :global(:root.glass) .hero-tint:first-child { + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0.055) 28%, + rgba(255, 255, 255, 0.02) 72%, + rgba(255, 255, 255, 0.045) + ); + } + + :global(:root.glass) .cover.placeholder, + :global(:root.glass) .card .placeholder, + :global(:root.glass) .mini.placeholder, + :global(:root.glass) .mini-art.placeholder { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11)); + } + /* The cover's own backing colour, seen through art that has transparency + and while it decodes. */ + :global(:root.glass) .cover { + background: var(--glass-surface); + } + :global(:root.glass) .own { + background: var(--glass-press); + } diff --git a/src/views/Downloads.svelte b/src/views/Downloads.svelte index 8bc3f98..8f9176d 100644 --- a/src/views/Downloads.svelte +++ b/src/views/Downloads.svelte @@ -325,4 +325,19 @@ .row :global(.plm-trigger:hover) { opacity: 1; } + + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css. An active transfer row is a card on the pane; a + finished one is bare until hovered, and keeps that shape. */ + :global(:root.glass) .row { + background: var(--glass-surface); + box-shadow: var(--glass-ring); + } + :global(:root.glass) .row.done-row { + background: none; + box-shadow: none; + } + :global(:root.glass) .row.done-row:hover { + background: var(--glass-hover); + } diff --git a/src/views/Home.svelte b/src/views/Home.svelte index 5c9efe5..4c91bb3 100644 --- a/src/views/Home.svelte +++ b/src/views/Home.svelte @@ -1303,4 +1303,45 @@ font-weight: 700; } .cta:hover { background: var(--accent-hover); } + + /* ── dark glass ──────────────────────────────────────────────────────── + Every surface below is opaque in solid mode and stays exactly that way; + these are additive :root.glass rules only. Values come from the ladder + in global.css — surfaces on a pane elevate with WHITE, because the pane + is already 0.7075 black over the acrylic and more black would sink them + under it. */ + + /* Kyle's screenshot: hovering a card dropped an opaque #181818 rectangle + over the pane frost and the ambience wash. The base .release paints + nothing at all, so this hover IS the card's only paint. */ + :global(:root.glass) .release:hover, + :global(:root.glass) .release:focus-visible, + :global(:root.glass) .ax-card:hover { + background: var(--glass-hover); + } + + /* Artwork wells — what shows while the cover is still loading, or forever + when there is none. A ring instead of a fill keeps the well readable as + a frame rather than a hole. */ + :global(:root.glass) .ax-art, + :global(:root.glass) .cover.placeholder { + background: var(--glass-surface); + box-shadow: var(--glass-ring); + } + + /* The greeting banner, and ONLY when it has no artwork behind it. With a + lead release the tint sits on an opaque .feat-bg image and is doing + legibility work — untouched. With none, .feat-tint is the first child + (the {#if} renders no element) and that 0.9 black gradient is painting + straight onto the pane: a black slab on the frost. Same diagonal, same + three stops, inverted to white so the banner reads as a lifted glass + panel instead of a hole. */ + :global(:root.glass) .feat-tint:first-child { + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0.055) 28%, + rgba(255, 255, 255, 0.02) 72%, + rgba(255, 255, 255, 0.045) + ); + } diff --git a/src/views/Library.svelte b/src/views/Library.svelte index 69f3883..0f0ff5f 100644 --- a/src/views/Library.svelte +++ b/src/views/Library.svelte @@ -3558,4 +3558,98 @@ } .lib-tool.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #000; } .lib-tool.primary .lib-import-pct { background: rgba(0, 0, 0, 0.22); color: #000; } + + /* ── dark glass ──────────────────────────────────────────────────────── + The ladder lives in global.css. Solid mode sees none of this. + + The toolbar was the loudest surface on the page and it was loud for two + compounding reasons, both measured on 2026-08-27 with a known-colour + ground substituted behind the window (research/scroll-diag/glassshot.mjs): + its own fill nets 0.82 + 0.7075x0.18 = 0.947 — a slab by alpha alone — + AND its backdrop-filter samples the see-through pane, which the + compositor flattens against black (the measurement lives in + Sidebar.svelte). Chroma of the shipped bar, blue-minus-red over a + #6b3fa8 ground: +2, i.e. dead. Fill alone got it to +12; dropping the + filter as well got it to +16, against +30 for the bare pane beside it. + Both halves were needed, so both are here. */ + :global(:root.glass) .lib-toolbar { + background: rgba(255, 255, 255, 0.06); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + /* Same story, smaller: the field and the tool buttons sit in that bar. */ + :global(:root.glass) .lib-search, + :global(:root.glass) .lib-tool { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + :global(:root.glass) .lib-search { + background: var(--glass-well); + border-color: var(--glass-hairline); + } + /* .lib-tool.primary — the Import button — keeps its accent fill: a filled + accent button is meant to read as solid. :not(.primary) is what makes + that true. `:root.glass .lib-tool` carries Svelte's scope hash and + scores (0,4,0), which outranks `.lib-tool.primary` at (0,3,0), so + without the exclusion this rule quietly repaints the accent button as a + 3%-white ghost. Verified by reading the computed style back. */ + :global(:root.glass) .lib-tool:not(.primary) { + background: var(--glass-surface); + border-color: var(--glass-hairline); + } + + /* Rows and cards on the pane. */ + :global(:root.glass) .lb-tr:not(.lb-th):hover, + :global(:root.glass) .release:hover, + :global(:root.glass) .release:focus-visible, + :global(:root.glass) .lib-dir:hover, + :global(:root.glass) .sortbtn:hover { + background: var(--glass-hover); + } + /* Selected keeps its accent ring doing the work — only the fill under the + ring changes, so the two states stay as far apart as they were. */ + :global(:root.glass) .release.active, + :global(:root.glass) .lb-tr.active { + background: var(--glass-hover); + } + + /* Artwork wells and the shelf avatars. */ + :global(:root.glass) .cover.placeholder, + :global(:root.glass) .cover.gen-pending, + :global(:root.glass) .lib-shelf-dot, + :global(:root.glass) .lib-pick-none { + background: var(--glass-surface); + } + + /* The album expander and the notch that points at its card. These two MUST + carry the same value: the notch is a rotated square half-buried under the + panel's top edge, so any difference between them draws a triangle. */ + :global(:root.glass) .xp-panel, + :global(:root.glass) .notch { + background: var(--glass-surface); + } + /* The rail's shade is a legibility scrim over the blurred cover behind the + CTA column. Its solid-mode tail is rgba(24,24,24,0.92), which on a now + translucent panel turns the rail's lower half into a near-opaque block + inside an otherwise glass card — the stacked-tint failure the artist + page already paid for once. Same shape, same colour, thinned so the + panel keeps ONE value top to bottom; the glow it is shading rests at + 0.32 opacity, so this is still more than enough to read over. */ + :global(:root.glass) .rail-shade { + background: linear-gradient( + 180deg, + transparent 0%, + rgba(24, 24, 24, 0.3) 55%, + rgba(24, 24, 24, 0.55) 100% + ); + } + :global(:root.glass) .pill.ghost, + :global(:root.glass) .pill:disabled, + :global(:root.glass) .rail-cta :global(.plm-trigger) { + background: var(--glass-surface); + } + :global(:root.glass) .pill.ghost:hover, + :global(:root.glass) .rail-cta :global(.plm-trigger:hover) { + background: var(--glass-hover); + } diff --git a/src/views/Playlist.svelte b/src/views/Playlist.svelte index f36136a..a924b87 100644 --- a/src/views/Playlist.svelte +++ b/src/views/Playlist.svelte @@ -1904,4 +1904,54 @@ animation-duration: 1ms; } } + + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css. Solid mode is untouched throughout. + + The banner is the album page's problem, verbatim: its ::after scrim + lands on opaque --bg-base, which keeps the whole hero zone solid over + the frost, and a thinner landing COLOUR cannot fix it — a translucent + tail can never match frost and draws a hard line at the box edge + (CatalogAlbum.svelte carries Kyle's note from 2026-08-26). So the layer + dissolves instead: img and scrim fade out together and end on frost at + frost's own value. Same stops as the album banner, for the same reason. */ + :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%); + } + + /* Rows. .dragged keeps its lift by staying one rung ABOVE hover, which is + the relationship it has in solid mode (--bg-press over --bg-highlight). */ + :global(:root.glass) .trow:hover, + :global(:root.glass) .mp-row:hover { + background: var(--glass-hover); + } + :global(:root.glass) .trow.dragged { + background: var(--glass-press); + } + + /* Title and description editors, and the track filter: their backdrops + reach the see-through pane, where a backdrop sample black-flattens + (measured — Sidebar.svelte holds the numbers). The fills already carry + the look on their own. */ + :global(:root.glass) .h1-input, + :global(:root.glass) .desc-input, + :global(:root.glass) .search { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + :global(:root.glass) .bulkline { + background: var(--glass-surface); + border-color: var(--glass-hairline); + } + :global(:root.glass) .bulkline .bulkx:hover { + background: var(--glass-hover); + } + :global(:root.glass) .pill:disabled, + :global(:root.glass) .thumb-fallback { + background: var(--glass-surface); + } + + /* Menus stay solid on purpose — see the note in Playlists.svelte. */ diff --git a/src/views/Playlists.svelte b/src/views/Playlists.svelte index 81605c8..11f9108 100644 --- a/src/views/Playlists.svelte +++ b/src/views/Playlists.svelte @@ -892,4 +892,43 @@ color: var(--text-muted); max-width: 380px; } + + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css. The shelf is nothing but cards, and every one was + an opaque #181818 tile on the frost. + + Menus are deliberately NOT in here. A popover has to stay readable over + whatever it lands on, and the Settings copy for this mode now says so + out loud: cards, rows and highlights thin out; menus and dialogs stay + solid. The same rule holds for .menu in Playlist.svelte. */ + :global(:root.glass) .card { + background: var(--glass-surface); + box-shadow: var(--glass-ring); + } + :global(:root.glass) .card:hover { + background: var(--glass-hover); + } + /* Same backdrop-over-frost flatten as the library's tools. + :not(.primary) is load-bearing, not tidiness: `:root.glass .tool` plus + Svelte's scope hash scores (0,4,0) against `.tool.primary`'s (0,3,0), so + without it this rule outranks the accent fill and "New playlist" turns + into a ghost button. Caught by measuring the live computed style — + rgba(255,255,255,0.03) where rgb(188,107,46) belonged. */ + :global(:root.glass) .tool:not(.primary) { + background: var(--glass-surface); + border-color: var(--glass-hairline); + } + :global(:root.glass) .tool { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + :global(:root.glass) .empty-icon { + background: var(--glass-surface); + box-shadow: var(--glass-ring); + } + /* --accent-soft is already translucent and composites correctly; only the + --bg-press half of this gradient needed thinning. */ + :global(:root.glass) .art.fallback { + background: linear-gradient(135deg, var(--accent-soft), rgba(255, 255, 255, 0.11) 72%); + } diff --git a/src/views/Search.svelte b/src/views/Search.svelte index 4471e0b..db3a8c4 100644 --- a/src/views/Search.svelte +++ b/src/views/Search.svelte @@ -382,4 +382,27 @@ color: var(--accent-hover); cursor: default; } + + /* ── dark glass ──────────────────────────────────────────────────────── + Ladder in global.css. The query field is the page's whole first + impression and was a 420px opaque grey lozenge on the frost. */ + :global(:root.glass) input { + background: var(--glass-well); + box-shadow: var(--glass-ring); + } + /* The focus ring is a 2px --text-primary halo in both modes and reads + fine against the well; left alone deliberately. */ + + :global(:root.glass) .group-head:hover, + :global(:root.glass) .file:hover { + background: var(--glass-hover); + } + :global(:root.glass) .badge, + :global(:root.glass) .dl-all.queued { + background: var(--glass-surface); + box-shadow: var(--glass-ring); + } + :global(:root.glass) .art-mini { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11)); + } diff --git a/src/views/Settings.svelte b/src/views/Settings.svelte index 8b70468..69d8129 100644 --- a/src/views/Settings.svelte +++ b/src/views/Settings.svelte @@ -294,7 +294,7 @@
Dark glass
-
Lets whatever is behind the window glow through the app's dark surfaces with a live blur (Windows acrylic). Panels, menus and cards stay solid so nothing gets hard to read.
+
Lets whatever is behind the window glow through the app's dark surfaces with a live blur (Windows acrylic). Cards, rows and highlights thin out with it; menus and dialogs stay solid so nothing gets hard to read.