fix: artist page fade line no longer lags fast scroll (compositor-driven)

The content fade mask on .scrollbody lives in the scrolling element's local
box, so its cut stop (--flow-cut) must ride scroll to stay at a fixed screen
line. Driving that from heroScroll's rAF lagged the native compositor scroll
by a frame on fast flicks, so the cut slid up and rows briefly showed above
where they should clip (user-reported).

Move --flow-cut to a CSS scroll-driven animation (@property + @keyframes +
animation-timeline: scroll(nearest block), @supports-gated), so it's a pure
function of scroll position evaluated on the compositor and can't lag. The
keyframe delta and animation-range share one oversized constant (100000px)
so the value is base + scrollTop for ANY scroll distance — necessary because
the artist page changes its own content height as it scrolls, making the
total range non-constant. apply() keeps the per-frame path only as the
unsupported-engine fallback.

Verified live: screen cut constant at 217px across scrollTop 300..2500,
--flow-cut == base+scrollTop exactly.
This commit is contained in:
KeelrLeek committed 2026-07-23 17:46:04 -05:00
1 parent 86e0d86604
commit 67d98dfe79
1 file changed
+64 -2
+64 -2
View File
@@ -750,6 +750,12 @@
}
if (!page || !band || !scroller) return;
// Compositor-driven fade line where supported (Chromium 115+). When
// it is, apply() no longer touches --flow-cut per frame — the CSS
// scroll timeline owns it — so a fast scroll can't outrun the mask.
const scrollDriven =
typeof CSS !== "undefined" && CSS.supports("animation-timeline", "scroll()");
// Infinity until measured, and again if there is somehow no room to
// ride up in: --p then stays 0 and the page is simply the page it is
// today, which is the right way for this to fail.
@@ -896,6 +902,16 @@
}
stripCut =
BAND_TOP + strip + Math.round(rowBottom) + 2 - (body?.offsetTop ?? 0);
// Base for the compositor-driven fade line (CSS @keyframes
// flow-cut-ride). Only base is needed — the keyframe delta and
// animation-range are a fixed oversized constant, so the value
// rides scroll as base + scrollTop with no dependence on the
// scroller's (here scroll-varying) total range. stripCut is
// scroll-invariant by construction, so this is stable.
if (scrollDriven) {
page.style.setProperty("--flow-cut-base", `${stripCut.toFixed(1)}px`);
}
}
function apply() {
@@ -904,8 +920,12 @@
// 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`);
// freezes at the park and rows climb over the banner. Skipped when
// the CSS scroll timeline owns --flow-cut (see scrollDriven) — that
// path can't lag the native scroll the way this rAF could.
if (!scrollDriven) {
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;
@@ -3687,6 +3707,48 @@
);
}
/* --flow-cut on the COMPOSITOR. The mask lives in .scrollbody's local box,
which scrolls, so the cut stop must ride scroll to stay at a fixed
screen line (= stripCut + scrollTop). Driving that from a rAF lagged the
native scroll by a frame on fast flicks, so rows briefly poked ABOVE the
cut before the mask caught up (user-reported). A scroll-driven animation
makes --flow-cut a pure function of scroll position evaluated on the
compositor — it can never lag.
The animation range and the keyframe delta are the SAME oversized
constant (RIDE_PX, far beyond any real page's scroll), so at scroll s
the interpolated value is base + (s/RIDE_PX)*RIDE_PX = base + s exactly,
with NO dependence on the scroller's total range — which is essential
here because the artist page changes its own content height as it
scrolls (the tab-grid space reserve), so the total range is not even a
constant. measure() writes only base (= stripCut, scroll-invariant).
Where scroll-driven animations are unsupported, apply() keeps setting
--flow-cut per frame (the @supports gate leaves that path untouched). */
@property --flow-cut {
syntax: "<length>";
inherits: true;
initial-value: -99999px;
}
@keyframes flow-cut-ride {
from {
--flow-cut: var(--flow-cut-base, -99999px);
}
to {
/* base + RIDE_PX; RIDE_PX must equal the animation-range end. */
--flow-cut: calc(var(--flow-cut-base, -99999px) + 100000px);
}
}
@supports (animation-timeline: scroll()) {
.artist-page[data-scroll="1"] .scrollbody {
animation-name: flow-cut-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
animation-range: 0px 100000px;
}
}
/* Nothing but the controls themselves may take clicks: the block spans the
pane and floats over the track list once it has parked. */
.idpin {