artist page: compositor-locked fade line (sticky host + clipped frame + scroll-driven counter-translate)

This commit is contained in:
KeelrLeek committed 2026-07-24 09:15:56 -05:00
1 parent 67d98dfe79
commit 790a9c60c0
1 file changed
+246 -84
+246 -84
View File
@@ -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 @@
<div class="hero-zone" use:measureHero></div>
<!-- LAYER 3 — THE CONTENT, on top of the sheet and the hero. Its one
special behaviour: rows fade out over 68px as they slide under
the banner line (a mask whose stop, --flow-cut, is recomputed
every frame in heroScroll — a mask rides its element's box and
the box scrolls, so a static stop would ride away with it).
Without this, the top layer would climb over the banner past the
special behaviour: rows fade out over 18px as they slide under
the banner line. Where scroll-driven animations exist, the
height-0 sticky .fade-host parks at the cut line, .fade-frame
(a real box hung off it) carries a CONSTANT clip + mask, and
.fade-flow counter-translates the content past it with a
compositor-driven scroll animation — nothing updates per frame
on the main thread, so a
fast flick can never outrun the mask (the old per-frame
--flow-cut rewrite lagged the compositor scroll and rows poked
above the cut; a scroll-driven CUSTOM PROPERTY animation lagged
identically — only transform/opacity/filter composite). Without
the mask, the top layer would climb over the banner past the
park — the exact problem that made round 7 flip the hero on top
and start cutting it. Gated on data-scroll: the resting page
carries no mask at all. -->
and start cutting it. Mask gated on data-scroll: the resting
page carries none at all. Full mechanism notes at the CSS. -->
<div class="scrollbody">
<div class="fade-host">
<div class="fade-frame">
<div class="fade-flow">
{#if page.partial_error}
<p class="resolving error">{page.partial_error}</p>
{:else if page.art_preview}
@@ -3478,6 +3540,9 @@
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -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: "<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);
}
}
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)
);
}
}