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
that renders the
+ // clamp-baked blur of the given url. Display-only (no pixel readback),
+ // so remote artwork without CORS headers still works.
+ function bakedBlur(canvas, url) {
+ let alive = true;
+ const render = (u) => {
+ if (!u) return;
+ const im = new Image();
+ im.onload = () => {
+ if (!alive) return;
+ const baked = bakeBlur(im, im.naturalWidth, im.naturalHeight, 8);
+ canvas.width = baked.width;
+ canvas.height = baked.height;
+ canvas.getContext("2d").drawImage(baked, 0, 0);
+ };
+ im.src = u;
+ };
+ render(url);
+ return {
+ update: render,
+ destroy() {
+ alive = false;
+ },
+ };
+ }
+
+ // The seam tail's mirror needs the same snapshot on the GPU path (a
+ // live canvas has no url for the tail's ).
+ let tailMirrorBox = $state(null);
+ function snapshotGpuTwins() {
+ if (!gpuTwinBox && !tailMirrorBox) return;
+ const gpu = document.querySelector(".artist-page .gpu-hero");
+ const bannerSrc = gpu?.querySelector("canvas.banner.current");
+ if (!bannerSrc || !bannerSrc.width) return;
+ const dup = (src, cls) => {
+ if (!src || !src.width) return null;
+ const d = document.createElement("canvas");
+ d.width = src.width;
+ d.height = src.height;
+ d.getContext("2d").drawImage(src, 0, 0);
+ d.className = cls;
+ return d;
+ };
+ if (gpuTwinBox) {
+ // The twin's banner carries its 8px blur BAKED with clamped
+ // edges (see bakeBlur) — aligned, fringe-free. Rotation
+ // dissolve: same pattern as the tail below — the old baked
+ // frame stays underneath while the new one fades in on the
+ // banner's own curve (an instant replaceChildren here made the
+ // mono overlay SNAP to the next photo while the real hero was
+ // still mid-dissolve — Kyle caught it). Cap at two frames so
+ // rapid re-readies can never stack.
+ const bakedBanner = bakeBlur(bannerSrc, bannerSrc.width, bannerSrc.height, 8);
+ bakedBanner.className = "gt-banner";
+ const oldFrames = [...gpuTwinBox.children];
+ if (oldFrames.length) bakedBanner.classList.add("incoming");
+ gpuTwinBox.append(bakedBanner);
+ while (gpuTwinBox.children.length > 2) {
+ gpuTwinBox.firstChild.remove();
+ }
+ if (oldFrames.length) {
+ setTimeout(() => {
+ for (const el of oldFrames) el.remove();
+ }, 1450);
+ }
+ }
+ // Tail mirror: the banner frame only, styled like the img variant.
+ // Rotation dissolve: the old frame stays underneath while the new
+ // one fades in on the banner's own curve, then the old is dropped.
+ // Cap at two frames so rapid re-readies can never stack.
+ if (tailMirrorBox) {
+ const t = dup(bannerSrc, "tail-mirror-frame");
+ if (t) {
+ const old = [...tailMirrorBox.children];
+ if (old.length) t.classList.add("incoming");
+ tailMirrorBox.append(t);
+ while (tailMirrorBox.children.length > 2) {
+ tailMirrorBox.firstChild.remove();
+ }
+ if (old.length) {
+ setTimeout(() => {
+ for (const el of old) el.remove();
+ }, 1450);
+ }
+ }
+ }
+ }
+
const HERO_ROTATE_MS = 20_000;
const HERO_FADE_MS = 1_400;
// The preview→finished 4K swap gets its own, shorter dissolve: long enough
@@ -282,6 +424,9 @@
previousBanner = null;
}, 650);
}
+ // Same-task snapshot for the twin + the seam tail's mirror — see
+ // snapshotGpuTwins above.
+ snapshotGpuTwins();
document.documentElement.dataset.heroGpuMs = result.renderMs.toFixed(2);
}
@@ -763,6 +908,26 @@
// today, which is the right way for this to fail.
let travel = Infinity;
let previous = -1;
+ // Supported path only: the last value written to --extra-hit, so
+ // the sentinel observer below writes it at its 0.42 boundary
+ // crossings and never else — the one style write that survives on
+ // that path (pointer-events is not animatable, so it cannot become
+ // a scroll-driven animation).
+ let lastHit = null;
+ // Supported path only: the two 1px threshold sentinels and their
+ // IntersectionObservers (created in the scrollDriven block below),
+ // plus the degenerate-page flag measure() keeps for them. With
+ // these, the supported path has NO scroll listener at all: reading
+ // scroller.scrollTop while the scroll-driven animations have style
+ // dirty forces a full style+layout pass per frame (measured: 93ms
+ // forced UpdateStyleAndLayout per 6.9s of continuous flicks just
+ // from apply()), and both of apply()'s remaining jobs are threshold
+ // crossings the compositor can report instead.
+ let s1 = null;
+ let s2 = null;
+ let io1 = null;
+ let io2 = null;
+ let travelFinite = false;
let frame = 0;
// The constant part of the content's flow-local fade line — written
// by measure(), consumed every frame by apply().
@@ -836,7 +1001,6 @@
const gap = page.querySelector(".actions-gap");
const rowH = gap?.offsetHeight ?? 76;
page.style.setProperty("--row-h", `${rowH}px`);
-
// The park point: the content edge stops exactly where the shrunk
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the
// identity still in its resting slots.
@@ -860,6 +1024,59 @@
page.style.setProperty("--strip", `${strip}px`);
travel = span - strip > 1 ? span - strip : Infinity;
+ // The scroll-driven animations' constants (see the second
+ // @supports (animation-timeline: scroll()) block in the CSS).
+ // --travel is the shared animation-range span — the p
+ // denominator — so linear easing over 0..--travel gives
+ // animation progress ≡ p at every scrollTop. The 999999px
+ // sentinel mirrors travel = Infinity above (p pinned ~0: no
+ // animation progresses measurably), and every consumer reads
+ // var(--travel, 999999px) so the pre-measure first paint is
+ // inert too. --ride is the SAME delta as a length for
+ // to-keyframes, and must be a separate variable: tied to the
+ // range sentinel, a degenerate page's to-value would cancel
+ // into translate = -scrollTop; the 0px sentinel keeps it
+ // motionless exactly as p = 0 does today. --fade-span is the
+ // extras/badge fade's own range (travel / 2.4, where their
+ // opacity ramp clamps): dividing the 999999px sentinel instead
+ // would let a deep scroll on a degenerate page ghost-fade chips
+ // that today hold opacity 1 at any scrollTop.
+ const rideValid = span - strip > 1;
+ page.style.setProperty(
+ "--travel",
+ `${rideValid ? (span - strip).toFixed(1) : 999999}px`,
+ );
+ page.style.setProperty(
+ "--ride",
+ `${rideValid ? (span - strip).toFixed(1) : 0}px`,
+ );
+ page.style.setProperty(
+ "--fade-span",
+ `${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`,
+ );
+ // The chips row's total leftward slide for the scroll-driven
+ // path: the sum of the layout width both buttons ahead of it in
+ // the flex row shed (22 mirrors --play-shrink; 13 is
+ // .hero-preview's 40→27). On that path the buttons' REAL widths
+ // are frozen and shrink by transform instead, so the flex row no
+ // longer supplies this motion. Presence-aware so a
+ // conditionally-rendered preview button cannot leave a phantom
+ // 13px slide, and validity-gated like --ride: on a degenerate
+ // page the range sentinel would otherwise creep the chips
+ // ~1.75px at extreme scrollTop where today's pinned p holds
+ // them motionless. (The button/glyph scale rides keep constant
+ // to-poses — their same-class residual is ≤1.1px on geometry
+ // the 390px hero-zone minimum makes unreachable; accepted.)
+ page.style.setProperty(
+ "--row-slide",
+ `${
+ rideValid
+ ? (page.querySelector(".hero-play") ? 22 : 0) +
+ (page.querySelector(".hero-preview") ? 13 : 0)
+ : 0
+ }px`,
+ );
+
// ROUND 12 — the sheet's two poses, both in band coordinates.
// At rest its gradient sits on the VISIBLE hero (onset at 60% of
// the zone, spanning 42% of it — the live page's look; anchoring
@@ -874,6 +1091,15 @@
page.style.setProperty("--fog-span", `${fogSpan}px`);
page.style.setProperty("--fog-above", `${fogAbove}px`);
page.style.setProperty("--fog-rest", `${fogRest}px`);
+ // The sheet's pose delta for its scroll-driven glide (usually
+ // negative): from-pose translateY(--fog-rest), to-pose
+ // translateY(--fog-rest + --fog-ride) ≡ translateY(strip -
+ // fog-above) — the same two exact endpoints the fallback calc
+ // has. 0px sentinel, same reasoning as --ride above.
+ page.style.setProperty(
+ "--fog-ride",
+ `${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`,
+ );
// The content's fade line, precomputed so apply() is arithmetic
// only: flow-local cut = stripCut + scrollTop. The zone must sit
@@ -942,26 +1168,50 @@
page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`);
page.style.setProperty("--frame-h", `${scroller.clientHeight}px`);
}
+ // The extras' hit-gate sentinel parks at the scrollTop where
+ // p crosses 0.42. Probed live (2026-07-24, this WebView2): the
+ // observers' rootBounds top coincides with the page's box top
+ // at scrollTop 0 — the pane's 24px padding does NOT offset the
+ // crossing — but the observer reports integer-snapped rects,
+ // so the -1 below folds the sentinel's own 1px height into the
+ // placement: the snapped bottom then reaches the root's top at
+ // the same integer scrollTop the old `p > 0.42` first exceeded
+ // (probed flip: auto at 377, none at 378, boundary 377.58).
+ // Off-screen sentinel on a degenerate page, mirroring travel =
+ // Infinity pinning p at 0 (travelFinite keeps the observers
+ // honest about that case too).
+ travelFinite = Number.isFinite(travel);
+ if (s2) {
+ s2.style.top = travelFinite
+ ? `${(0.42 * travel - 1).toFixed(1)}px`
+ : "-99999px";
+ }
}
+ // FALLBACK ONLY (no scroll-driven animations). On the supported
+ // path apply() NEVER runs — there is no scroll listener at all: the
+ // scroll-driven animations own every per-frame style, and the two
+ // threshold flips apply() used to perform (data-scroll at p > 0,
+ // the extras' hit gate at p > 0.42) come from the IntersectionObserver
+ // sentinels in the scrollDriven block below, which the compositor
+ // reports without a single forced style+layout read.
function apply() {
frame = 0;
const scrollTop = scroller.scrollTop;
- // FALLBACK ONLY (no scroll-driven animations): the fade line
- // rides EVERY frame — including past the park, where --p is
- // pinned at 1 but the rows keep scrolling under the banner. It
- // must not sit behind the p-guard below, or the mask freezes at
- // the park and rows climb over the banner. On the scrollDriven
- // path the sticky .scrollbody + composited .fade-flow transform
- // own the fade line and nothing here updates per frame.
- if (!scrollDriven) {
- page.style.setProperty("--flow-cut", `${(stripCut + scrollTop).toFixed(1)}px`);
- }
+ // The fade line rides EVERY frame — including past the park,
+ // where --p is pinned at 1 but the rows keep scrolling under
+ // the banner. It must not sit behind the p-guard below, or the
+ // mask freezes at the park and rows climb over the banner.
+ page.style.setProperty("--flow-cut", `${(stripCut + scrollTop).toFixed(1)}px`);
const p = Math.min(1, Math.max(0, scrollTop / travel));
if (p === previous) return;
previous = p;
+ // Every consumer in the @supports not branch still rides the
+ // --p custom property, rewritten on every p change exactly as
+ // it always was.
page.style.setProperty("--p", p.toFixed(4));
- // Faded-out extras must stop taking clicks; opacity alone doesn't.
+ // Faded-out extras must stop taking clicks; opacity alone
+ // doesn't.
page.style.setProperty("--extra-hit", p > 0.42 ? "none" : "auto");
// The mask, the veil, the monochrome treatment and the wordmark's
// scale are all gated on [data-scroll], so at the top of the page
@@ -978,6 +1228,11 @@
function reflow() {
measure();
+ // Supported path: nothing per-frame is left to apply — the
+ // observers re-deliver on their own once measure() has moved
+ // the sentinels, and running apply() here would pay the exact
+ // forced-layout scrollTop read this path exists to avoid.
+ if (scrollDriven) return;
previous = -1;
apply();
}
@@ -990,6 +1245,22 @@
// divs unstyled and .scrollbody sizes itself as it always did.)
let flowObserver = null;
if (scrollDriven) {
+ // The named timeline every scroll-driven animation below the
+ // fade trio binds to (animation-timeline: --artist-pane), set
+ // once on the EXACT element apply() reads scrollTop from — the
+ // timeline and p can never disagree. It must be NAMED:
+ // scroll(nearest) is a trap here, because .hero-bg's
+ // overflow: hidden makes it a scroll container per css-overflow,
+ // so nearest-lookup from the hero layers would bind to a
+ // never-scrolling box and freeze them at their from pose.
+ // (.fade-flow alone keeps scroll(nearest block): its ancestor
+ // chain has no scroll container in the way.) A named timeline is
+ // visible to all descendants of the declaring element, and
+ // .artist-page is a descendant of the pane. Safe as an inline
+ // write: the only other inline-style writer on the pane
+ // (scrollbar.js floatScroll) sets individual properties, never
+ // cssText.
+ scroller.style.setProperty("scroll-timeline", "--artist-pane block");
const bodyEl = page.querySelector(".scrollbody");
const flowEl = page.querySelector(".fade-flow");
if (bodyEl && flowEl) {
@@ -1000,21 +1271,122 @@
flowObserver = new ResizeObserver(syncHeight);
flowObserver.observe(flowEl);
}
+
+ // The threshold sentinels: two 1px absolutely-positioned divs
+ // whose crossings of the scrollport's top edge reproduce
+ // apply()'s two boundary flips without any scrollTop read.
+ // Appended as the LAST children of the page — never before the
+ // pins (measure()'s "pins are first children" invariant) and
+ // never inside the fade trio. contain: strict keeps them out of
+ // every layout/paint pass.
+ const mkSentinel = (top) => {
+ const d = document.createElement("div");
+ d.setAttribute("aria-hidden", "true");
+ d.style.cssText =
+ "position:absolute;left:0;top:" +
+ top +
+ ";width:1px;height:1px;pointer-events:none;contain:strict;";
+ page.appendChild(d);
+ return d;
+ };
+ // s1 sits at the page's box top: fully visible exactly at
+ // scrollTop 0 (probed: rootBounds.top == the page's box top in
+ // this engine), so intersectionRatio < 1 ≡ p > 0. s2 parks off-
+ // screen until measure() places it at the 0.42 crossing.
+ s1 = mkSentinel("0px");
+ s2 = mkSentinel("-99999px");
+ io1 = new IntersectionObserver(
+ (entries) => {
+ // Crossings can batch under a stalled main thread —
+ // only the LAST entry is the current state.
+ const en = entries[entries.length - 1];
+ // travelFinite mirrors the degenerate-page guard:
+ // travel = Infinity pins p at 0, so data-scroll must
+ // never flip no matter how deep the scroll goes.
+ heroScrolled = travelFinite && en.intersectionRatio < 1;
+ },
+ { root: scroller, threshold: [1] },
+ );
+ io1.observe(s1);
+ io2 = new IntersectionObserver(
+ (entries) => {
+ const en = entries[entries.length - 1];
+ // Past the 0.42 boundary means the sentinel left the
+ // scrollport upward — its (snapped) bottom at or above
+ // the root's top. NOT isIntersecting: the observer
+ // counts edge-contact as intersecting, which would hold
+ // the flip a pixel past the boundary. travelFinite
+ // keeps the parked off-screen sentinel of a degenerate
+ // page reading "auto", exactly as pinned p = 0 does.
+ const past =
+ travelFinite &&
+ en.boundingClientRect.bottom <=
+ (en.rootBounds ? en.rootBounds.top : 0);
+ const hit = past ? "none" : "auto";
+ if (hit !== lastHit) {
+ lastHit = hit;
+ page.style.setProperty("--extra-hit", hit);
+ }
+ },
+ // Both edges: the 1 guarantees a notification for the
+ // full-visibility -> edge-contact transition (ratio 1 -> 0
+ // with isIntersecting unchanged), where the "none" flip now
+ // lands.
+ { root: scroller, threshold: [0, 1] },
+ );
+ io2.observe(s2);
}
reflow();
remeasureHero = reflow;
- scroller.addEventListener("scroll", onScroll, { passive: true });
- const observer = new ResizeObserver(reflow);
+ // Supported path: NO scroll listener. Even an empty per-frame
+ // handler would keep the main thread in the scroll loop, and
+ // apply()'s scrollTop read forces style+layout while the
+ // scroll-driven animations have style dirty. The sentinels above
+ // carry both of its jobs.
+ if (!scrollDriven) {
+ scroller.addEventListener("scroll", onScroll, { passive: true });
+ }
+ // A window resize (maximize especially) settles across SEVERAL
+ // layout passes, and a reflow fired mid-transition measures torn
+ // geometry — one such measure wedged the fade line 280px low at
+ // fullscreen (rows vanishing below the cut, Kyle's repro of the
+ // long-standing rows-stay-hidden intermittent) and NOTHING re-fired
+ // afterward to heal it. So: measure immediately for responsiveness,
+ // then once more after layout quiesces. The trailing pass never
+ // reschedules itself, so there is no loop.
+ let settleTimer = 0;
+ const reflowAndSettle = () => {
+ reflow();
+ clearTimeout(settleTimer);
+ settleTimer = setTimeout(reflow, 200);
+ };
+ const observer = new ResizeObserver(reflowAndSettle);
observer.observe(band);
const zone = page.querySelector(".hero-zone");
if (zone) observer.observe(zone);
+ // The pane itself too: a height-only window change (vertical
+ // maximize) moves the vh-capped banner/zone limits without
+ // necessarily resizing band or zone at their measured instant.
+ observer.observe(scroller);
return {
destroy() {
remeasureHero = () => {};
- scroller.removeEventListener("scroll", onScroll);
+ if (!scrollDriven) {
+ scroller.removeEventListener("scroll", onScroll);
+ }
+ // The pane outlives this page — take the named timeline back
+ // off it. (Nothing else references --artist-pane, and the
+ // {#if} teardown/mount is sequential, so two owners never
+ // interleave.)
+ scroller.style.removeProperty("scroll-timeline");
+ clearTimeout(settleTimer);
observer.disconnect();
flowObserver?.disconnect();
+ io1?.disconnect();
+ io2?.disconnect();
+ s1?.remove();
+ s2?.remove();
if (frame) cancelAnimationFrame(frame);
},
};
@@ -2842,55 +3214,18 @@
});
}
- let heroBusy = $state(false);
- let heroNotice = $state("");
- let heroNoticeTimer;
- onDestroy(() => clearTimeout(heroNoticeTimer));
-
- function showHeroNotice(message) {
- heroNotice = message;
- clearTimeout(heroNoticeTimer);
- heroNoticeTimer = setTimeout(() => (heroNotice = ""), 5000);
- }
-
- async function chooseHeroPhoto() {
- if (!page || heroBusy) return;
- heroBusy = true;
- try {
- const imported = await api.pickArtistHero(page.artist.id);
- if (imported) {
- const seed = page.artist;
- invalidateCatalogArtist(page.artist.id);
- clearCatalogHeroArtCache();
- await openCatalogArtist(page.artist.id, seed);
- showHeroNotice(`Custom hero saved · ${imported.width}×${imported.height}`);
- }
- } catch (error) {
- showHeroNotice(String(error).replace(/^Error:\s*/, ""));
- } finally {
- heroBusy = false;
- }
- }
-
- async function clearHeroPhoto() {
- if (!page || heroBusy) return;
- heroBusy = true;
- try {
- await api.clearArtistHero(page.artist.id);
- const seed = page.artist;
- invalidateCatalogArtist(page.artist.id);
- clearCatalogHeroArtCache();
- await openCatalogArtist(page.artist.id, seed);
- showHeroNotice("Custom hero removed");
- } catch (error) {
- showHeroNotice(String(error).replace(/^Error:\s*/, ""));
- } finally {
- heroBusy = false;
- }
- }
+ // The hero-chooser UI (Choose/Change hero pill + notice) lived in the
+ // glass bar until 2026-07-24; it is being re-homed elsewhere. The
+ // backend survives it: api.pickArtistHero / api.clearArtistHero and the
+ // Rust commands are intact — resurrect the old pill's handlers from git
+ // (defe225) when the new location lands.
-
+
{#if catalog.artistLoading && !page}
(ui.view = "catalog")}>
Results
@@ -2926,10 +3261,10 @@
+ into the page. The seam tail carries the band behind the glass
+ bar; the vertical scrim finishes the lower dissolve. During
+ rotation, the outgoing pair stays underneath until the new pair
+ fades in. -->
{#if seedUrl}
@@ -2938,14 +3273,30 @@
class:hidden={heroPainted}
style="background-image: url('{seedUrl}')"
>
+
+
{/if}
{#if previousBanner?.url}
-
+
+
+
{/if}
{#if selectedUsesGpu && banner}
-
+
+
+
{/if}
{#if gpuRendererMounted}
+
+
{/if}
{#if banner && !selectedUsesGpu}
{#key displayedBanner.key}
-
+
+
+
+
{/key}
{:else if !selectedUsesGpu}
{/if}
+
+
+
+
+ {#if selectedUsesGpu}
+
+ {:else if banner}
+ {#if previousBanner?.url}
+
+ {/if}
+ {#key displayedBanner.key}
+
+ {/key}
+ {/if}
+
+
@@ -3159,7 +3571,7 @@
(ui.view = "catalog")}>
@@ -3170,29 +3582,6 @@
{:else if resolver.error}
{resolver.error}
{/if}
-
- {#if heroNotice}
- {heroNotice}
- {/if}
-
-
- {page.banner_custom ? "Change hero" : "Choose hero"}
-
- {#if page.banner_custom}
-
- {/if}
-