diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index a213339..6bafb5f 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -751,8 +751,10 @@ 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. + // it is, apply() never touches --flow-cut: the mask is a CONSTANT on + // the sticky .scrollbody and a composited scroll-driven transform on + // .fade-flow carries the counter-motion — so a fast scroll can't + // outrun the mask. Must match the CSS @supports gate exactly. const scrollDriven = typeof CSS !== "undefined" && CSS.supports("animation-timeline", "scroll()"); @@ -900,29 +902,58 @@ band.getBoundingClientRect().top - edgeNow; } - stripCut = - BAND_TOP + strip + Math.round(rowBottom) + 2 - (body?.offsetTop ?? 0); + // The cut line in scrollport coordinates — scroll-invariant by + // construction (BAND_TOP and strip are scrollport-frame + // constants, rowBottom is measured relative to --edge). + const cutScreen = BAND_TOP + strip + Math.round(rowBottom) + 2; + stripCut = cutScreen - (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`); + // The compositor-driven fade line's constants (see the CSS): + // --cut-top parks .fade-host's box top at the cut line, + // --fade-s is the scrollTop at which that happens, and + // --frame-h sizes .fade-frame to a pane height (from the parked + // cut it must reach past the viewport bottom; a pane is that + // with slack to spare). Both sides of the park equation live in + // the same scrollport frame (the pane's padding cancels), so S + // is simply the host's flow top minus the cut. The host's flow + // top comes from .hero-zone — never from the host itself: + // measuring a sticky element to position that same element is + // the exact feedback loop the warning at the top of measure() + // exists for. The host sits at .scrollbody's top (= the zone's + // bottom) with a -18px margin hanging it 18px above the + // content top. + if (scrollDriven && body) { + // A sticky inset resolves from inside the pane's padding + // (the same 24px BAR_BOTTOM exists to cancel), so `top: X` + // parks the box at scrollport X + padding — measured live: + // top 217 parked at 241, a fade line one full slot too low + // AND a 24px content jump at the stick moment, since S below + // is derived in scrollport coordinates. Subtracting the + // padding parks the box on the exact line the mask fallback + // draws, and makes the stick moment land precisely at S. + const padTop = + parseFloat(getComputedStyle(scroller).paddingTop) || 0; + page.style.setProperty( + "--cut-top", + `${(cutScreen - padTop).toFixed(1)}px`, + ); + const hostFlowTop = zone.offsetTop + zone.offsetHeight - 18; + const fadeS = Math.max(0, hostFlowTop - cutScreen); + page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`); + page.style.setProperty("--frame-h", `${scroller.clientHeight}px`); } } function apply() { frame = 0; const scrollTop = scroller.scrollTop; - // 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. Skipped when - // the CSS scroll timeline owns --flow-cut (see scrollDriven) — that - // path can't lag the native scroll the way this rAF could. + // 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`); } @@ -951,6 +982,26 @@ apply(); } + // scrollDriven only: .fade-frame is absolutely positioned, so the + // content no longer sizes .scrollbody in flow — sync its height to + // .fade-flow's or the page below the hero collapses. offsetHeight + // ignores .fade-flow's scroll-driven transform, so observing it + // cannot feed back into itself. (The fallback path leaves the fade + // divs unstyled and .scrollbody sizes itself as it always did.) + let flowObserver = null; + if (scrollDriven) { + const bodyEl = page.querySelector(".scrollbody"); + const flowEl = page.querySelector(".fade-flow"); + if (bodyEl && flowEl) { + const syncHeight = () => { + bodyEl.style.height = `${flowEl.offsetHeight}px`; + }; + syncHeight(); + flowObserver = new ResizeObserver(syncHeight); + flowObserver.observe(flowEl); + } + } + reflow(); remeasureHero = reflow; scroller.addEventListener("scroll", onScroll, { passive: true }); @@ -963,6 +1014,7 @@ remeasureHero = () => {}; scroller.removeEventListener("scroll", onScroll); observer.disconnect(); + flowObserver?.disconnect(); if (frame) cancelAnimationFrame(frame); }, }; @@ -3150,15 +3202,25 @@
+ and start cutting it. Mask gated on data-scroll: the resting + page carries none at all. Full mechanism notes at the CSS. -->
+
+
+
{#if page.partial_error}

{page.partial_error}

{:else if page.art_preview} @@ -3478,6 +3540,9 @@
{/each}
+
+
+ {/if} @@ -3568,8 +3633,11 @@ --fog-span: 170px; --fog-above: 50px; --fog-rest: 280px; - /* The content's fade line, in .scrollbody-local coordinates — - rewritten every frame by heroScroll's apply(). */ + /* FALLBACK ONLY (no scroll-driven animations): the content's fade + line, in .scrollbody-local coordinates — rewritten every frame by + heroScroll's apply(). The supported path uses --cut-top/--fade-s + (written by measure()) instead; see the sticky .scrollbody and + .fade-flow rules below. */ --flow-cut: -99999px; /* MONOCHROME — the chosen treatment. Every hero lands in the same register and the accent is the only colour left on screen. The blur @@ -3676,76 +3744,170 @@ } /* LAYER 3 — the content, over the sheet and the hero. Its one special - behaviour: rows fade out over 68px as they slide under the banner - line. The mask's stop is --flow-cut, recomputed every frame by - heroScroll's apply() — a mask resolves in its element's LOCAL box and - the box scrolls, so a static stop would ride away with it (the exact - trap that blanked the round-10 wallpaper). Gated on data-scroll so - the resting page carries no mask at all. */ + behaviour: rows fade out over 18px as they slide under the banner + line, with zero per-frame main-thread work where scroll-driven + animations exist (the whole block below); the rAF fallback follows. */ .scrollbody { position: relative; z-index: 3; } - /* 18px, not longer: the zone lives in the 24px slot between the pills' - bottom edge and the parked Popular heading — the only place it can - satisfy "fully visible parked" AND "gone before the pills" at once. */ - .artist-page[data-scroll="1"] .scrollbody { - -webkit-mask-image: linear-gradient( - 180deg, - transparent 0px, - transparent var(--flow-cut), - rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px), - #000 calc(var(--flow-cut) + 18px) - ); - mask-image: linear-gradient( - 180deg, - transparent 0px, - transparent var(--flow-cut), - rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px), - #000 calc(var(--flow-cut) + 18px) - ); + /* Positioned in BOTH branches: absolutely-positioned content descendants + used to resolve their containing block at .scrollbody; the scroll- + driven transform (a containing block by itself) would otherwise + capture them mid-branch. .fade-flow's box coincides exactly with + .scrollbody's content box, so anchoring is unchanged. */ + .fade-flow { + position: relative; } - /* --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 FADE LINE, with zero per-frame main-thread work. The old design's + mask stop had to be rewritten every frame (rAF) because .scrollbody's + box scrolls, and a fast flick outran it: the compositor scrolls ahead + of the next main-thread commit and rows poked above the cut + (user-reported). A scroll-driven animation of a registered + --flow-cut lagged the SAME way — custom-property animations are + main-thread; Chromium composites only transform/opacity/filter. - 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); - } - } + Three pieces, nothing per-frame: + + .fade-host — a HEIGHT-0 sticky box (the pins' own trick) placed 18px + above the content top, parking at the cut line (--cut-top, a screen + constant from measure()). Height 0 is load-bearing twice over: it is + what gives sticky its travel (a box consumes its own height from the + room its containing block offers — the pins at height 0 ride the + whole page), and by construction the travel budget works out EXACTLY: + available = contentBottom - hostTop = content height + 18 = travel + needed for the park to hold until the last content pixel passes the + cut. Past that point sticky exhausts and the line drifts up with the + flow — harmless, nothing is left below it to fade. + + .fade-frame — the clip-and-mask carrier, an absolutely positioned + REAL box hung off the host (top 0 = the host's box top = the cut + when parked), one pane tall (--frame-h, from measure(); reaches + comfortably past the viewport bottom from the cut at every scroll + position). Content the transform lifts above the frame top is + removed by `overflow: visible clip` — a paint-time clip at the + padding-box edge, no scroll container created, horizontal overflow + (hover shadows, focus rings) untouched. The mask is CONSTANT: an + 18px feather from the frame top over the frame's own default-sized + gradient box. The frame having a real box that CONTAINS every + visible content pixel is the whole reason it exists: this engine's + handling of mask pixels OUTSIDE an element's border box sank two + earlier builds (a mask on the height-0 host itself composited ALL + box-overflowing content — the entire page — to alpha 0; mask-clip: + no-clip re-anchored the tile to the moving overflow rect and the + fade line drifted off-screen). Inside the box, no out-of-box + semantics are ever exercised. The frame's 18px top padding keeps the + feather over empty space at rest. + + .fade-flow — the counter-motion: a scroll-driven TRANSFORM animation + (composited — pixel-locked to the native scroll at any speed or + jank) translates the content by -(scrollTop - S), where S (--fade-s, + from measure()) is the scrollTop at which the host parks. Frame top + parks at the cut, content sits at frame-local 18 - (scrollTop - S) — + which equals its normal flow position at every scrollTop, stuck or + not; the handoff is seamless by construction. The keyframe delta and + the animation range are the SAME oversized constant, so translate = + -(scrollTop - S) exactly, independent of the scroller's total range + (which varies as this page scrolls — the tab-grid space reserve). + Before S the fill-mode holds translate at 0 and the page is + pixel-identical to today's. + + The frame takes the content out of flow, so .scrollbody's height is + synced to .fade-flow's by heroScroll (offsetHeight ignores the + transform — no feedback loop). + + The 18px zone, not longer: it lives in the 24px slot between the + pills' bottom edge and the parked Popular heading — the only place it + can satisfy "fully visible parked" AND "gone before the pills" at + once. Mask gated on data-scroll for rest parity (safe: pre-park the + content never reaches the line, so late gating cannot matter). The + STICKY, the CLIP and the ANIMATION are deliberately ungated — they + must be live the frame scrollTop crosses S even if the main thread + is stalled. One deliberate behaviour change: rows clipped above the + cut no longer hit-test (the old mask left invisible rows clickable + under the banner — the clip removing those ghost hits is a fix, not + a regression). */ @supports (animation-timeline: scroll()) { - .artist-page[data-scroll="1"] .scrollbody { - animation-name: flow-cut-ride; + .fade-host { + position: sticky; + /* Pre-measure fallback keeps sticky unreachable, not at 0 — + parking the content sheet over the hero on the first scrolled + frame of a fresh page would be quite a look. */ + top: var(--cut-top, 99999px); + height: 0; + margin-top: -18px; + } + + .fade-frame { + position: absolute; + top: 0; + left: 0; + right: 0; + height: var(--frame-h, 200vh); + padding-top: 18px; + overflow: visible clip; + } + + .artist-page[data-scroll="1"] .fade-frame { + -webkit-mask-image: linear-gradient( + 180deg, + transparent 0px, + rgba(0, 0, 0, 0.55) 9px, + #000 18px + ); + mask-image: linear-gradient( + 180deg, + transparent 0px, + rgba(0, 0, 0, 0.55) 9px, + #000 18px + ); + } + + .fade-flow { + animation-name: fade-flow-ride; animation-duration: auto; animation-timing-function: linear; animation-fill-mode: both; animation-timeline: scroll(nearest block); - animation-range: 0px 100000px; + animation-range: var(--fade-s, 0px) + calc(var(--fade-s, 0px) + 100000px); + } + + @keyframes fade-flow-ride { + from { + transform: translateY(0); + } + to { + /* Delta must equal the animation-range span above. */ + transform: translateY(-100000px); + } + } + } + + /* Fallback (no scroll-driven animations): the old design — mask on + .scrollbody, its stop (--flow-cut) rewritten every frame by + heroScroll's apply(). Can lag a fast flick; correct when settled. + .fade-host/.fade-frame/.fade-flow stay plain unstyled divs here — + any one piece of the trio without the others would freeze, clip or + double-move the content. */ + @supports not (animation-timeline: scroll()) { + .artist-page[data-scroll="1"] .scrollbody { + -webkit-mask-image: linear-gradient( + 180deg, + transparent 0px, + transparent var(--flow-cut), + rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px), + #000 calc(var(--flow-cut) + 18px) + ); + mask-image: linear-gradient( + 180deg, + transparent 0px, + transparent var(--flow-cut), + rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px), + #000 calc(var(--flow-cut) + 18px) + ); } }