forked from Keelrleek/SoulStream
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,211 @@
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user