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; if (!page || !band || !scroller) return;
// Compositor-driven fade line where supported (Chromium 115+). When // Compositor-driven fade line where supported (Chromium 115+). When
// it is, apply() no longer touches --flow-cut per frame — the CSS // it is, apply() never touches --flow-cut: the mask is a CONSTANT on
// scroll timeline owns it — so a fast scroll can't outrun the mask. // 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 = const scrollDriven =
typeof CSS !== "undefined" && CSS.supports("animation-timeline", "scroll()"); typeof CSS !== "undefined" && CSS.supports("animation-timeline", "scroll()");
@@ -900,29 +902,58 @@
band.getBoundingClientRect().top - band.getBoundingClientRect().top -
edgeNow; edgeNow;
} }
stripCut = // The cut line in scrollport coordinates — scroll-invariant by
BAND_TOP + strip + Math.round(rowBottom) + 2 - (body?.offsetTop ?? 0); // 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 // The compositor-driven fade line's constants (see the CSS):
// flow-cut-ride). Only base is needed — the keyframe delta and // --cut-top parks .fade-host's box top at the cut line,
// animation-range are a fixed oversized constant, so the value // --fade-s is the scrollTop at which that happens, and
// rides scroll as base + scrollTop with no dependence on the // --frame-h sizes .fade-frame to a pane height (from the parked
// scroller's (here scroll-varying) total range. stripCut is // cut it must reach past the viewport bottom; a pane is that
// scroll-invariant by construction, so this is stable. // with slack to spare). Both sides of the park equation live in
if (scrollDriven) { // the same scrollport frame (the pane's padding cancels), so S
page.style.setProperty("--flow-cut-base", `${stripCut.toFixed(1)}px`); // 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() { function apply() {
frame = 0; frame = 0;
const scrollTop = scroller.scrollTop; const scrollTop = scroller.scrollTop;
// The fade line rides EVERY frame — including past the park, // FALLBACK ONLY (no scroll-driven animations): the fade line
// where --p is pinned at 1 but the rows keep scrolling under the // rides EVERY frame — including past the park, where --p is
// banner. It must not sit behind the p-guard below, or the mask // pinned at 1 but the rows keep scrolling under the banner. It
// freezes at the park and rows climb over the banner. Skipped when // must not sit behind the p-guard below, or the mask freezes at
// the CSS scroll timeline owns --flow-cut (see scrollDriven) — that // the park and rows climb over the banner. On the scrollDriven
// path can't lag the native scroll the way this rAF could. // path the sticky .scrollbody + composited .fade-flow transform
// own the fade line and nothing here updates per frame.
if (!scrollDriven) { if (!scrollDriven) {
page.style.setProperty("--flow-cut", `${(stripCut + scrollTop).toFixed(1)}px`); page.style.setProperty("--flow-cut", `${(stripCut + scrollTop).toFixed(1)}px`);
} }
@@ -951,6 +982,26 @@
apply(); 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(); reflow();
remeasureHero = reflow; remeasureHero = reflow;
scroller.addEventListener("scroll", onScroll, { passive: true }); scroller.addEventListener("scroll", onScroll, { passive: true });
@@ -963,6 +1014,7 @@
remeasureHero = () => {}; remeasureHero = () => {};
scroller.removeEventListener("scroll", onScroll); scroller.removeEventListener("scroll", onScroll);
observer.disconnect(); observer.disconnect();
flowObserver?.disconnect();
if (frame) cancelAnimationFrame(frame); if (frame) cancelAnimationFrame(frame);
}, },
}; };
@@ -3150,15 +3202,25 @@
<div class="hero-zone" use:measureHero></div> <div class="hero-zone" use:measureHero></div>
<!-- LAYER 3 — THE CONTENT, on top of the sheet and the hero. Its one <!-- 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 special behaviour: rows fade out over 18px as they slide under
the banner line (a mask whose stop, --flow-cut, is recomputed the banner line. Where scroll-driven animations exist, the
every frame in heroScroll — a mask rides its element's box and height-0 sticky .fade-host parks at the cut line, .fade-frame
the box scrolls, so a static stop would ride away with it). (a real box hung off it) carries a CONSTANT clip + mask, and
Without this, the top layer would climb over the banner past the .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 park — the exact problem that made round 7 flip the hero on top
and start cutting it. Gated on data-scroll: the resting page and start cutting it. Mask gated on data-scroll: the resting
carries no mask at all. --> page carries none at all. Full mechanism notes at the CSS. -->
<div class="scrollbody"> <div class="scrollbody">
<div class="fade-host">
<div class="fade-frame">
<div class="fade-flow">
{#if page.partial_error} {#if page.partial_error}
<p class="resolving error">{page.partial_error}</p> <p class="resolving error">{page.partial_error}</p>
{:else if page.art_preview} {:else if page.art_preview}
@@ -3478,6 +3540,9 @@
</div> </div>
{/each} {/each}
</div> </div>
</div>
</div>
</div>
{/if} {/if}
</div> </div>
@@ -3568,8 +3633,11 @@
--fog-span: 170px; --fog-span: 170px;
--fog-above: 50px; --fog-above: 50px;
--fog-rest: 280px; --fog-rest: 280px;
/* The content's fade line, in .scrollbody-local coordinates — /* FALLBACK ONLY (no scroll-driven animations): the content's fade
rewritten every frame by heroScroll's apply(). */ 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; --flow-cut: -99999px;
/* MONOCHROME — the chosen treatment. Every hero lands in the same /* MONOCHROME — the chosen treatment. Every hero lands in the same
register and the accent is the only colour left on screen. The blur 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 /* 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 behaviour: rows fade out over 18px as they slide under the banner
line. The mask's stop is --flow-cut, recomputed every frame by line, with zero per-frame main-thread work where scroll-driven
heroScroll's apply() — a mask resolves in its element's LOCAL box and animations exist (the whole block below); the rAF fallback follows. */
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. */
.scrollbody { .scrollbody {
position: relative; position: relative;
z-index: 3; z-index: 3;
} }
/* 18px, not longer: the zone lives in the 24px slot between the pills' /* Positioned in BOTH branches: absolutely-positioned content descendants
bottom edge and the parked Popular heading — the only place it can used to resolve their containing block at .scrollbody; the scroll-
satisfy "fully visible parked" AND "gone before the pills" at once. */ driven transform (a containing block by itself) would otherwise
.artist-page[data-scroll="1"] .scrollbody { capture them mid-branch. .fade-flow's box coincides exactly with
-webkit-mask-image: linear-gradient( .scrollbody's content box, so anchoring is unchanged. */
180deg, .fade-flow {
transparent 0px, position: relative;
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)
);
} }
/* --flow-cut on the COMPOSITOR. The mask lives in .scrollbody's local box, /* THE FADE LINE, with zero per-frame main-thread work. The old design's
which scrolls, so the cut stop must ride scroll to stay at a fixed mask stop had to be rewritten every frame (rAF) because .scrollbody's
screen line (= stripCut + scrollTop). Driving that from a rAF lagged the box scrolls, and a fast flick outran it: the compositor scrolls ahead
native scroll by a frame on fast flicks, so rows briefly poked ABOVE the of the next main-thread commit and rows poked above the cut
cut before the mask caught up (user-reported). A scroll-driven animation (user-reported). A scroll-driven animation of a registered
makes --flow-cut a pure function of scroll position evaluated on the --flow-cut lagged the SAME way — custom-property animations are
compositor — it can never lag. main-thread; Chromium composites only transform/opacity/filter.
The animation range and the keyframe delta are the SAME oversized Three pieces, nothing per-frame:
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, .fade-host — a HEIGHT-0 sticky box (the pins' own trick) placed 18px
with NO dependence on the scroller's total range — which is essential above the content top, parking at the cut line (--cut-top, a screen
here because the artist page changes its own content height as it constant from measure()). Height 0 is load-bearing twice over: it is
scrolls (the tab-grid space reserve), so the total range is not even a what gives sticky its travel (a box consumes its own height from the
constant. measure() writes only base (= stripCut, scroll-invariant). room its containing block offers — the pins at height 0 ride the
Where scroll-driven animations are unsupported, apply() keeps setting whole page), and by construction the travel budget works out EXACTLY:
--flow-cut per frame (the @supports gate leaves that path untouched). */ available = contentBottom - hostTop = content height + 18 = travel
@property --flow-cut { needed for the park to hold until the last content pixel passes the
syntax: "<length>"; cut. Past that point sticky exhausts and the line drifts up with the
inherits: true; flow — harmless, nothing is left below it to fade.
initial-value: -99999px;
} .fade-frame — the clip-and-mask carrier, an absolutely positioned
@keyframes flow-cut-ride { REAL box hung off the host (top 0 = the host's box top = the cut
from { when parked), one pane tall (--frame-h, from measure(); reaches
--flow-cut: var(--flow-cut-base, -99999px); comfortably past the viewport bottom from the cut at every scroll
} position). Content the transform lifts above the frame top is
to { removed by `overflow: visible clip` — a paint-time clip at the
/* base + RIDE_PX; RIDE_PX must equal the animation-range end. */ padding-box edge, no scroll container created, horizontal overflow
--flow-cut: calc(var(--flow-cut-base, -99999px) + 100000px); (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()) { @supports (animation-timeline: scroll()) {
.artist-page[data-scroll="1"] .scrollbody { .fade-host {
animation-name: flow-cut-ride; 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-duration: auto;
animation-timing-function: linear; animation-timing-function: linear;
animation-fill-mode: both; animation-fill-mode: both;
animation-timeline: scroll(nearest block); 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)
);
} }
} }