artist page: compositor-locked fade line (sticky host + clipped frame + scroll-driven counter-translate)
This commit is contained in:
1 parent
67d98dfe79
commit
790a9c60c0
1 file changed
+246
-84
+246
-84
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user