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:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+211
View File
@@ -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();
},
};
}