// 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 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); 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`; 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() { 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(); }, }; }