diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 5a81919..a213339 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -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: ""; + 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 {