dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together

SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
This commit is contained in:
KeelrLeek committed 2026-08-27 17:24:20 -05:00
1 parent 7ab678fd22
commit 547cee6649
26 files changed
+1220 -144

No files matched your search

+41
View File
@@ -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");
};
}
+19 -3
View File
@@ -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) {
+17
View File
@@ -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) {
+40 -5
View File
@@ -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,