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)
+ );
}
}