PLAYER {player.status}{player.track ? ` · ${player.track.title}` : ""}
{#if stats.mainFadeGain != null && stats.mainFadeGain < 0.999}· fade {stats.mainFadeGain.toFixed(2)}{/if}
@@ -195,4 +211,27 @@
height: 100%;
background: #7fd0ff;
}
+ /* The overlay is passthrough glass (pointer-events: none on the aside)
+ — only its buttons take clicks. */
+ button {
+ pointer-events: auto;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 4px;
+ color: #d7e0ea;
+ font: inherit;
+ font-size: 10px;
+ padding: 1px 7px;
+ margin-left: 4px;
+ cursor: pointer;
+ }
+ button:hover {
+ border-color: #7fd0ff;
+ color: #fff;
+ }
+ button.mini {
+ font-size: 9px;
+ padding: 0 5px;
+ opacity: 0.7;
+ }
diff --git a/src/lib/scrollbar.js b/src/lib/scrollbar.js
index 68d5d13..80b9784 100644
--- a/src/lib/scrollbar.js
+++ b/src/lib/scrollbar.js
@@ -26,6 +26,21 @@
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
+// 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
@@ -40,6 +55,16 @@ export function floatScroll(node) {
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;
@@ -112,7 +137,21 @@ export function floatScroll(node) {
maxScroll = scrollH - viewH;
thumb.style.display = "block";
thumb.style.height = `${thumbH}px`;
- place();
+ 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);
@@ -130,6 +169,12 @@ export function floatScroll(node) {
}
function onScroll() {
+ // 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();
}
diff --git a/src/styles/global.css b/src/styles/global.css
index b05e370..4d44a23 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -130,6 +130,21 @@
opacity: 1;
pointer-events: auto;
}
+/* The thumb's slide as a scroll-driven animation (scrollbar.js sets it up
+ where animation-timeline is supported). Endpoints are written by measure():
+ --ft-y0 = topInset - padTop, --ft-y1 = --ft-y0 + (trackH - thumbH). The
+ default animation range spans the scroller's whole scroll range, so linear
+ progress IS scrollTop / maxScroll — the exact ratio the JS fallback
+ computes — and the compositor tracks the scroll with zero main-thread
+ work (and none of the fallback's one-frame lag under threaded scrolling). */
+@keyframes float-thumb-ride {
+ from {
+ transform: translateY(var(--ft-y0, 0px));
+ }
+ to {
+ transform: translateY(var(--ft-y1, 0px));
+ }
+}
* {
margin: 0;
diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte
index 6bafb5f..0ab9f1b 100644
--- a/src/views/CatalogArtist.svelte
+++ b/src/views/CatalogArtist.svelte
@@ -16,9 +16,6 @@
getCatalogHeroPreview,
getCachedCatalogHeroArt,
getCatalogHeroArt,
- clearCatalogHeroArtCache,
- invalidateCatalogArtist,
- openCatalogArtist,
artBroken,
markArtBroken,
coverUpgrades,
@@ -116,6 +113,151 @@
return () => clearTimeout(t);
});
+ // THE HERO PIPELINE (Kyle, 2026-07-24 — the approved configuration,
+ // now unconditional):
+ // - Crossfade twins: the main-thread blur filter rides on the hero
+ // layers are retired for static-filtered twin layers cross-faded
+ // by a composited opacity scroll-driven animation (see the twin
+ // CSS in the @supports block; the @supports-not fallback keeps
+ // the --p filter rides on the originals and simply never shows
+ // the twins).
+ // - No bloom strip: the blurred hero copy behind the glass bar is
+ // retired; the seam tail alone carries the band.
+ // - The seam tail (Kyle's design, round 3): a TAIL rising from
+ // the HERO'S OWN TOP EDGE — a mirrored strip of the artwork
+ // itself, colour-continuous with the hero by construction — that
+ // RIDES WITH the hero (same squeeze, same gating), so the edge
+ // carries its own soft lead-in and a hard line never exists, at
+ // rest or mid-scroll. Nothing is anchored to the bar and nothing
+ // extends below the hero's edge. Zero per-frame statics.
+
+ // GPU-hero twin: a live canvas has no static twin, so on every 'ready'
+ // the finished frame is copied once into a plain canvas that the
+ // pre-filtered twin displays under a STATIC CSS filter. drawImage must
+ // happen in the same task as renderer.render() (a WebGL drawing buffer
+ // does not survive the compositor pass), which gpuHeroReady is — it is
+ // called synchronously after render.
+ let gpuTwinBox = $state(null);
+ // Clamped-edge blur bake: pad the source with edge-replicated pixels,
+ // blur, crop back — a blurred copy that is pixel-aligned with its
+ // source (same box, same aspect) and has NO transparent fringe at any
+ // edge. This is the underlying fix for the twin-edge bleed: a CSS
+ // filter blur samples transparency outside the element and fades the
+ // border; baking with clamp-to-edge sampling never does. Runs once
+ // per artwork (load/rotation), never per frame.
+ function bakeBlur(src, w, h, radius) {
+ const pad = Math.ceil(radius * 3);
+ const padded = document.createElement("canvas");
+ padded.width = w + pad * 2;
+ padded.height = h + pad * 2;
+ const g = padded.getContext("2d");
+ // Centre, four edge strips stretched from 1px slices, four corners.
+ g.drawImage(src, 0, 0, w, h, pad, pad, w, h);
+ g.drawImage(src, 0, 0, 1, h, 0, pad, pad, h); // left
+ g.drawImage(src, w - 1, 0, 1, h, w + pad, pad, pad, h); // right
+ g.drawImage(src, 0, 0, w, 1, pad, 0, w, pad); // top
+ g.drawImage(src, 0, h - 1, w, 1, pad, h + pad, w, pad); // bottom
+ g.drawImage(src, 0, 0, 1, 1, 0, 0, pad, pad); // corners
+ g.drawImage(src, w - 1, 0, 1, 1, w + pad, 0, pad, pad);
+ g.drawImage(src, 0, h - 1, 1, 1, 0, h + pad, pad, pad);
+ g.drawImage(src, w - 1, h - 1, 1, 1, w + pad, h + pad, pad, pad);
+ const out = document.createElement("canvas");
+ out.width = w;
+ out.height = h;
+ const og = out.getContext("2d");
+ og.filter = `blur(${radius}px)`;
+ og.drawImage(padded, -pad, -pad);
+ return out;
+ }
+
+ // Action for the img-path banner twin: a