Files
SoulStream/src/lib/scrollbar.js
T
KeelrLeek 547cee6649 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.
2026-08-27 17:24:20 -05:00

274 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Floating overlay scrollbar, applied with `use:floatScroll` on any element
// that scrolls. It hides the native scrollbar entirely — so the bar never
// reserves layout width and can never reflow the content when it appears —
// and draws a thin thumb that floats over the container's right padding,
// fading in while scrolling and out again when idle. No track, no arrow
// buttons; just the one bar. Reusable across the whole app.
//
// When the scroller has a floating glass header pinned to its top (marked
// `[data-scrollhead]`, either a descendant or a direct sibling), the thumb's
// travel starts one PAD *below* that header, so it never rides up over the
// header and shows the same PAD gap top and bottom.
//
// ARCHITECTURE — read before changing how the thumb is positioned.
// The thumb lives inside a zero-height `position: sticky` rail, so the
// COMPOSITOR keeps it pinned in the scrollport; JS only slides it along the
// track. The obvious simpler design — an absolute thumb counter-scrolled per
// frame (top = scrollTop + trackPos) — jitters as soon as the scroller gets
// composited (threaded) scrolling, which the artist page's sticky
// backdrop-filter header forces: the compositor scrolls the content ahead of
// the main thread, the counter-scroll commits one frame late, and the thumb
// visibly oscillates by the full per-frame scroll delta. Pages that scroll on
// the main thread never show this, which is why the bug looked artist-page-
// only. With the sticky rail, a late frame only mis-places the thumb along the
// track by scrollDelta × (trackH / scrollH) — a few percent — imperceptible.
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
// scroll-driven animation (float-thumb-ride in global.css) and onScroll never
// reads scrollTop at all — the per-scroll place() read was forcing a full
// style+layout pass every frame on pages whose scroll-driven animations had
// already dirtied style (measured: 652 forced UpdateStyleAndLayout, ~248ms
// per 6.9s of continuous scrolling on the artist page). The thumb's mapping
// is exactly linear in scrollTop — y = (topInset - padTop) +
// (scrollTop / maxScroll) * (trackH - thumbH) — so the default animation
// range (the scroller's entire scroll range) reproduces it identically;
// measure() writes the two endpoint vars. The !SDA path is byte-identical to
// the old behavior.
const SDA = typeof CSS !== "undefined" && CSS.supports("animation-timeline", "scroll()");
export function floatScroll(node) {
node.classList.add("float-scroll");
node.style.scrollbarWidth = "none"; // inline: beats scoped component rules
if (getComputedStyle(node).position === "static") node.style.position = "relative";
// Zero-height sticky rail as the scroller's FIRST child (sticky can only
// hold an element back from scrolling up past `top: 0`, so it must sit at
// the top of the flow). The thumb hangs off it, absolutely positioned.
const rail = document.createElement("div");
rail.className = "float-rail";
const thumb = document.createElement("div");
thumb.className = "float-thumb";
rail.appendChild(thumb);
node.prepend(rail);
if (SDA) {
// scroll(nearest) from the thumb resolves to `node`: the only
// intermediate ancestor is the zero-height .float-rail, which is not
// a scroll container (no overflow set — the thumb visibly hangs out
// of its zero-height box, so it could never be otherwise). The named
// --artist-pane timeline lives on the same element on the artist
// page; nearest-lookup and the name agree there by construction.
thumb.style.animation = "float-thumb-ride auto linear both";
thumb.style.animationTimeline = "scroll(nearest block)";
}
let hideTimer = 0;
let dragging = false;
let measureRaf = 0;
// Cached layout, refreshed only by measure() (never during a scroll).
let active = false;
let topInset = PAD;
let trackH = 0;
let thumbH = 24;
let maxScroll = 0;
let padTop = 0;
let boxRight = 0; // viewport x of the scroller's right edge (for hover)
let boxTop = 0; // viewport y of the scroller's top edge
// The `show` state (scrolling) fades on its own after a beat; the separate
// `hover` state below is independent and controls its own visibility, so a
// deliberate grab only needs `dragging` to hold the thumb up.
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (!dragging) thumb.classList.remove("show");
}, 500);
}
function show() {
thumb.classList.add("show");
scheduleHide();
}
// Recompute the cached geometry. Runs on init and whenever the layout might
// have changed (resize, content grow/shrink, a view swapping a header in or
// out) — but deliberately NOT on scroll: the per-frame path below reads
// only scrollTop and forces no layout.
function measure() {
measureRaf = 0;
const viewH = node.clientHeight;
const scrollH = node.scrollHeight;
const box = node.getBoundingClientRect();
boxRight = box.right;
boxTop = box.top;
const cs = getComputedStyle(node);
padTop = parseFloat(cs.paddingTop) || 0;
// The rail is an in-flow (zero-height) child. In a flex/grid scroller
// it would still contribute one row-gap before the real first child —
// cancel it so adding the rail never shifts the content.
const rowGap = parseFloat(cs.rowGap) || 0;
rail.style.marginBottom = rowGap ? `-${rowGap}px` : "0";
// The rail spans the content box; the thumb should sit 3px inside the
// PADDING box's right edge (where it always lived), so pull it back out
// through the right padding.
const padRight = parseFloat(cs.paddingRight) || 0;
thumb.style.right = `${3 - padRight}px`;
// A `[data-scrollhead]` glass header pinned to the top of this scroller:
// a descendant (the artist page's Results bar inside the content pane)
// or a direct sibling (the Now Playing header sits beside its scroller).
const head =
node.querySelector("[data-scrollhead]") ||
node.parentElement?.querySelector(":scope > [data-scrollhead]");
const headH = head ? head.getBoundingClientRect().height : 0;
active = scrollH > viewH + 1;
if (!active) {
thumb.style.display = "none";
return;
}
topInset = headH + PAD;
trackH = Math.max(0, viewH - topInset - PAD);
thumbH = Math.max(24, Math.min(trackH, (viewH / scrollH) * trackH));
maxScroll = scrollH - viewH;
thumb.style.display = "block";
thumb.style.height = `${thumbH}px`;
if (SDA) {
// The ride's two endpoints (see float-thumb-ride in global.css).
// Progress over the default range is scrollTop / maxScroll — the
// exact `ratio` place() computes — so the animated transform is
// the same pixel place() would have written at every scrollTop.
// An inline transform would lose to the running animation anyway,
// so the place() call below is gated to the fallback path.
thumb.style.setProperty("--ft-y0", `${topInset - padTop}px`);
thumb.style.setProperty(
"--ft-y1",
`${topInset - padTop + Math.max(0, trackH - thumbH)}px`,
);
} else {
place();
}
}
function scheduleMeasure() {
if (!measureRaf) measureRaf = requestAnimationFrame(measure);
}
// Per-frame, cheap: read only scrollTop, write one composited transform.
// The engine pins the sticky rail at the scroller's CONTENT-box top (i.e.
// `padTop` below the visual top edge, verified empirically in WebView2) for
// the entire scroll, so the rail-relative offset is a constant correction.
function place() {
if (!active) return;
const ratio = maxScroll > 0 ? node.scrollTop / maxScroll : 0;
const y = topInset - padTop + ratio * (trackH - thumbH);
thumb.style.transform = `translateY(${y}px)`;
}
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) {
show();
return;
}
place();
show();
}
// The thumb appears while scrolling; separately, moving the pointer into the
// narrow strip along the scroller's right edge reveals it faintly (the
// `hover` state) so it can be found and grabbed without scrolling first.
node.addEventListener("scroll", onScroll, { passive: true });
let nearEdge = false;
function setHover(on) {
if (on === nearEdge) return;
nearEdge = on;
thumb.classList.toggle("hover", on);
}
function onPointerMove(e) {
setHover(
active &&
e.clientX >= boxRight - HOVER_ZONE &&
e.clientX <= boxRight + 2 &&
e.clientY >= boxTop + topInset - 4,
);
}
function onPointerLeave() {
setHover(false);
}
node.addEventListener("pointermove", onPointerMove, { passive: true });
node.addEventListener("pointerleave", onPointerLeave);
const ro = new ResizeObserver(scheduleMeasure);
ro.observe(node);
// Content growing/shrinking (queue changes, sections toggling) changes
// scrollHeight; childList catches that — and a view swapping a header in or
// out — without firing on every text tick.
const mo = new MutationObserver(scheduleMeasure);
mo.observe(node, { childList: true, subtree: true });
// Drag the thumb.
let dragY0 = 0;
let scroll0 = 0;
thumb.addEventListener("pointerdown", (e) => {
dragging = true;
dragY0 = e.clientY;
scroll0 = node.scrollTop;
thumb.classList.add("dragging");
thumb.setPointerCapture(e.pointerId);
e.preventDefault();
});
thumb.addEventListener("pointermove", (e) => {
if (!dragging) return;
const track = trackH - thumbH;
node.scrollTop = scroll0 + (track > 0 ? ((e.clientY - dragY0) / track) * maxScroll : 0);
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
thumb.classList.remove("dragging");
if (thumb.hasPointerCapture(e.pointerId)) thumb.releasePointerCapture(e.pointerId);
scheduleHide();
}
thumb.addEventListener("pointerup", endDrag);
thumb.addEventListener("pointercancel", endDrag);
measure();
return {
destroy() {
clearTimeout(hideTimer);
if (measureRaf) cancelAnimationFrame(measureRaf);
node.removeEventListener("scroll", onScroll);
node.removeEventListener("pointermove", onPointerMove);
node.removeEventListener("pointerleave", onPointerLeave);
// The thumb's own pointer listeners go away with rail.remove().
ro.disconnect();
mo.disconnect();
rail.remove();
},
};
}