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. -->