resize: reads before writes everywhere, and an open album drags like a closed one
This commit is contained in:
1 parent
52f5878297
commit
606250c361
2 files changed
+412
-162
No files matched your search
+393
-159
@@ -927,11 +927,19 @@
|
||||
});
|
||||
|
||||
function measureHero(node) {
|
||||
const update = () => {
|
||||
if (node.clientWidth > 0) heroWidth = node.clientWidth;
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
if (node.clientWidth > 0) heroWidth = node.clientWidth;
|
||||
// Width from the ENTRY, not clientWidth: an entry is a post-layout
|
||||
// fact, while a DOM read in a delivery forces layout whenever an
|
||||
// earlier callback in the same round has written — and the hero
|
||||
// reflow writes on every frame of a drag. .hero-zone carries no
|
||||
// border or padding, so the border box IS the client width.
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const box = entries[entries.length - 1];
|
||||
const w = Math.round(
|
||||
box.borderBoxSize?.[0]?.inlineSize ?? box.contentRect.width,
|
||||
);
|
||||
if (w > 0) heroWidth = w;
|
||||
});
|
||||
observer.observe(node);
|
||||
return { destroy: () => observer.disconnect() };
|
||||
}
|
||||
@@ -1087,8 +1095,21 @@
|
||||
// The constant part of the content's flow-local fade line — written
|
||||
// by measure(), consumed every frame by apply().
|
||||
let stripCut = -99999;
|
||||
// measure()'s write block, deferred one rAF (latest wins). Written
|
||||
// inside the ResizeObserver delivery, the custom properties dirty
|
||||
// style mid-round and every later callback's first read — the grid
|
||||
// reserve's — pays a full forced layout, every frame of a drag.
|
||||
// Deferred, the writes ride the next frame's one natural layout.
|
||||
// Every consumer already tolerates the unwritten state (the
|
||||
// var(--travel, 999999px) sentinels exist for the pre-measure first
|
||||
// paint), so a one-frame lag is the same situation, briefer.
|
||||
let commit = null;
|
||||
let commitRaf = 0;
|
||||
// The chips' un-parked height, reused on parked non-settle passes so
|
||||
// a drag deep in the page skips the data-park toggle (see below).
|
||||
let chipsHCache = -1;
|
||||
|
||||
function measure() {
|
||||
function measure(settled = true) {
|
||||
const zone = page.querySelector(".hero-zone");
|
||||
if (!zone) return;
|
||||
// NEVER measure the pins themselves. They are `position: sticky`,
|
||||
@@ -1106,9 +1127,19 @@
|
||||
// is measured from .hero-zone, which is `position: relative` and
|
||||
// cannot lie.
|
||||
//
|
||||
// READS FIRST, WRITES LAST — this runs on every frame of a window
|
||||
// drag (the ResizeObserver below), and every style write here
|
||||
// dirties the very geometry the next read then forces back up to
|
||||
// date. Interleaved, one pass through this function cost up to six
|
||||
// forced layouts; batched, it costs one (two while parked, for the
|
||||
// data-park toggle). Nothing may read layout after the write block
|
||||
// starts.
|
||||
//
|
||||
// Measured from the TOP OF THE BAND, so these compare directly
|
||||
// against band-local gradient stops.
|
||||
const span = zone.offsetTop + zone.offsetHeight - BAND_TOP;
|
||||
const zoneTop = zone.offsetTop;
|
||||
const zoneH = zone.offsetHeight;
|
||||
const span = zoneTop + zoneH - BAND_TOP;
|
||||
// Constant by construction: the band's top sits at scrollport
|
||||
// -24px (BAND_TOP) and the glass bar's stuck bottom at +22px
|
||||
// (BAR_BOTTOM), so the bar clears the band's top by 46px and the
|
||||
@@ -1138,17 +1169,17 @@
|
||||
const slot = page.querySelector(".logo-slot");
|
||||
let markRest = 16;
|
||||
let inkPark = markPx;
|
||||
// Written in the write block below, and only when measured here —
|
||||
// absent copy/slot the previous values must stand, as they always
|
||||
// have.
|
||||
let markEnd = null;
|
||||
if (copy && slot) {
|
||||
markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight;
|
||||
page.style.setProperty("--mark-rest", `${markRest}px`);
|
||||
// offsetHeight, not a rect: the slot carries the ride-up scale
|
||||
// while scrolled and a rect would feed that back into itself.
|
||||
const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title");
|
||||
const markHeight = mark?.offsetHeight ?? 0;
|
||||
page.style.setProperty(
|
||||
"--mark-end",
|
||||
markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1",
|
||||
);
|
||||
markEnd = markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1";
|
||||
inkPark = markHeight > markPx ? markPx : markHeight || markPx;
|
||||
}
|
||||
// The Play/chips row's resting height, MEASURED off the row itself;
|
||||
@@ -1177,97 +1208,53 @@
|
||||
// reader halfway through a scroll. The toggle is synchronous, so
|
||||
// no paint can land between the two writes.
|
||||
const chipsEl = page.querySelector(".stat-chips");
|
||||
const gap = page.querySelector(".actions-gap");
|
||||
const parkAttr = page.dataset.park ?? "0";
|
||||
page.dataset.park = "0";
|
||||
const chipsH = chipsEl?.getBoundingClientRect().height ?? 0;
|
||||
page.dataset.park = parkAttr;
|
||||
const gapEl = page.querySelector(".actions-gap");
|
||||
// The one-line floor, per breakpoint, and the top padding derived
|
||||
// from it (see --row-pad-top). The row is padTop + the chips'
|
||||
// offset against the 50px lead row + the chips + 8px.
|
||||
// offset against the 50px lead row + the chips + 8px. Both reads
|
||||
// sit BEFORE the park toggle: taken after the attribute flip they
|
||||
// pay the style pass the flip dirties.
|
||||
const floor =
|
||||
parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76;
|
||||
const padTop = (floor - 66) / 2 + 8;
|
||||
const gapH = chipsEl ? 0 : gapEl?.offsetHeight ?? floor;
|
||||
// Guarded three ways. At rest data-park is already "0", and even
|
||||
// a same-value attribute write is a style invalidation a drag
|
||||
// frame cannot afford. Parked — scrolled deep, which is where the
|
||||
// discography drags happen — the toggle costs a forced layout to
|
||||
// un-collapse the chips, then a second when the next read lands
|
||||
// after the flip back; so mid-drag (settled = false) the last
|
||||
// measured height is reused and only the trailing settle pass,
|
||||
// the mount and the data-change remeasures pay the exact read.
|
||||
// Stale by at most one wrap boundary, above the viewport, for the
|
||||
// 200ms until the settle pass — and scroll anchoring absorbs the
|
||||
// correction.
|
||||
const parkAttr = page.dataset.park ?? "0";
|
||||
let chipsH;
|
||||
if (chipsEl && parkAttr !== "0" && !settled && chipsHCache >= 0) {
|
||||
chipsH = chipsHCache;
|
||||
} else {
|
||||
if (parkAttr !== "0") page.dataset.park = "0";
|
||||
chipsH = chipsEl?.getBoundingClientRect().height ?? 0;
|
||||
if (parkAttr !== "0") page.dataset.park = parkAttr;
|
||||
chipsHCache = chipsEl ? chipsH : -1;
|
||||
}
|
||||
const rowH = chipsEl
|
||||
? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8))
|
||||
: gap?.offsetHeight ?? floor;
|
||||
page.style.setProperty("--row-h", `${rowH}px`);
|
||||
: gapH;
|
||||
syncSocialDivider(page);
|
||||
// The park point: the content edge stops exactly where the shrunk
|
||||
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the
|
||||
// identity still in its resting slots.
|
||||
const strip =
|
||||
BAR_BOTTOM - BAND_TOP + PAD_TOP_PARKED + inkPark + markRest;
|
||||
page.style.setProperty("--banner-h", `${banner}px`);
|
||||
// Glue, not travel. --lift cancels .hero-copy's parked term
|
||||
// exactly, so its bottom stays 0 at every p — the wordmark
|
||||
// shrinks in place about its resting baseline. The row needs no
|
||||
// travel var at all any more: its box is pinned to its resting
|
||||
// slot in CSS.
|
||||
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
|
||||
|
||||
// Where the pins sit in flow. the glass bar's net flow height is
|
||||
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
|
||||
// same offset the pins do — and it is `position: relative`, so
|
||||
// asking it cannot feed a sticky offset back into the measurement
|
||||
// the way asking a pin would.
|
||||
page.style.setProperty("--pin-origin", `${zone.offsetTop}px`);
|
||||
page.style.setProperty("--hero-span", `${span}px`);
|
||||
page.style.setProperty("--strip", `${strip}px`);
|
||||
travel = span - strip > 1 ? span - strip : Infinity;
|
||||
|
||||
// The scroll-driven animations' constants (see the second
|
||||
// @supports (animation-timeline: scroll()) block in the CSS).
|
||||
// --travel is the shared animation-range span — the p
|
||||
// denominator — so linear easing over 0..--travel gives
|
||||
// animation progress ≡ p at every scrollTop. The 999999px
|
||||
// sentinel mirrors travel = Infinity above (p pinned ~0: no
|
||||
// animation progresses measurably), and every consumer reads
|
||||
// var(--travel, 999999px) so the pre-measure first paint is
|
||||
// inert too. --ride is the SAME delta as a length for
|
||||
// to-keyframes, and must be a separate variable: tied to the
|
||||
// range sentinel, a degenerate page's to-value would cancel
|
||||
// into translate = -scrollTop; the 0px sentinel keeps it
|
||||
// motionless exactly as p = 0 does today. --fade-span is the
|
||||
// extras/badge fade's own range (travel / 2.4, where their
|
||||
// opacity ramp clamps): dividing the 999999px sentinel instead
|
||||
// would let a deep scroll on a degenerate page ghost-fade chips
|
||||
// that today hold opacity 1 at any scrollTop.
|
||||
const rideValid = span - strip > 1;
|
||||
page.style.setProperty(
|
||||
"--travel",
|
||||
`${rideValid ? (span - strip).toFixed(1) : 999999}px`,
|
||||
);
|
||||
page.style.setProperty(
|
||||
"--ride",
|
||||
`${rideValid ? (span - strip).toFixed(1) : 0}px`,
|
||||
);
|
||||
page.style.setProperty(
|
||||
"--fade-span",
|
||||
`${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`,
|
||||
);
|
||||
// The chips row's total leftward slide for the scroll-driven
|
||||
// path: the sum of the layout width both buttons ahead of it in
|
||||
// the flex row shed (22 mirrors --play-shrink; 13 is
|
||||
// .hero-preview's 40→27). On that path the buttons' REAL widths
|
||||
// are frozen and shrink by transform instead, so the flex row no
|
||||
// longer supplies this motion. Presence-aware so a
|
||||
// conditionally-rendered preview button cannot leave a phantom
|
||||
// 13px slide, and validity-gated like --ride: on a degenerate
|
||||
// page the range sentinel would otherwise creep the chips
|
||||
// ~1.75px at extreme scrollTop where today's pinned p holds
|
||||
// them motionless. (The button/glyph scale rides keep constant
|
||||
// to-poses — their same-class residual is ≤1.1px on geometry
|
||||
// the 390px hero-zone minimum makes unreachable; accepted.)
|
||||
page.style.setProperty(
|
||||
"--row-slide",
|
||||
`${
|
||||
rideValid
|
||||
? (page.querySelector(".hero-play") ? 22 : 0) +
|
||||
(page.querySelector(".hero-preview") ? 13 : 0)
|
||||
: 0
|
||||
}px`,
|
||||
);
|
||||
// Probed with the other reads, consumed by the --row-slide write
|
||||
// below.
|
||||
const hasPlay = !!page.querySelector(".hero-play");
|
||||
const hasPreview = !!page.querySelector(".hero-preview");
|
||||
|
||||
// ROUND 12 — the sheet's two poses, both in band coordinates.
|
||||
// At rest its gradient sits on the VISIBLE hero (onset at 60% of
|
||||
@@ -1280,18 +1267,6 @@
|
||||
const fogSpan = Math.round(span * 0.42);
|
||||
const fogAbove = Math.round(fogSpan * 0.294);
|
||||
const fogRest = Math.round(span * 0.6);
|
||||
page.style.setProperty("--fog-span", `${fogSpan}px`);
|
||||
page.style.setProperty("--fog-above", `${fogAbove}px`);
|
||||
page.style.setProperty("--fog-rest", `${fogRest}px`);
|
||||
// The sheet's pose delta for its scroll-driven glide (usually
|
||||
// negative): from-pose translateY(--fog-rest), to-pose
|
||||
// translateY(--fog-rest + --fog-ride) ≡ translateY(strip -
|
||||
// fog-above) — the same two exact endpoints the fallback calc
|
||||
// has. 0px sentinel, same reasoning as --ride above.
|
||||
page.style.setProperty(
|
||||
"--fog-ride",
|
||||
`${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`,
|
||||
);
|
||||
|
||||
// The content's fade line, precomputed so apply() is arithmetic
|
||||
// only: flow-local cut = stripCut + scrollTop. The zone must sit
|
||||
@@ -1312,6 +1287,11 @@
|
||||
// the list rows above the chips' real bottom edge. .stat-chips
|
||||
// rides a translateX (chips-slide-ride), never a translateY, so its
|
||||
// measured bottom is as scroll-invariant as the button's.
|
||||
// Measured here, ahead of the write block, under the PREVIOUS
|
||||
// pass's custom properties: the quantity is the ink's offset below
|
||||
// --edge, which the glue keeps constant at every p and every
|
||||
// span/strip, so a one-pass-stale pose moves it by at most the
|
||||
// frame's own drift — and the trailing settle pass reads it clean.
|
||||
const body = page.querySelector(".scrollbody");
|
||||
const rowInk = [
|
||||
page.querySelector(".hero-play"),
|
||||
@@ -1352,48 +1332,146 @@
|
||||
// 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`);
|
||||
}
|
||||
// Unconditional: .fade-frame is scroll-driven-only, but .sheetclip
|
||||
// sizes itself from --frame-h on BOTH branches (it is what keeps the
|
||||
// 300vh sheet out of the pane's scroll range). Setting it on the
|
||||
// fallback path is otherwise inert — .fade-frame's rule lives inside
|
||||
// the @supports block and never matches there.
|
||||
page.style.setProperty("--frame-h", `${scroller.clientHeight}px`);
|
||||
// The extras' hit-gate sentinel parks at the scrollTop where
|
||||
// p crosses 0.42. Probed live (2026-07-24, this WebView2): the
|
||||
// observers' rootBounds top coincides with the page's box top
|
||||
// at scrollTop 0 — the pane's 24px padding does NOT offset the
|
||||
// crossing — but the observer reports integer-snapped rects,
|
||||
// so the -1 below folds the sentinel's own 1px height into the
|
||||
// placement: the snapped bottom then reaches the root's top at
|
||||
// the same integer scrollTop the old `p > 0.42` first exceeded
|
||||
// (probed flip: auto at 377, none at 378, boundary 377.58).
|
||||
// Off-screen sentinel on a degenerate page, mirroring travel =
|
||||
// Infinity pinning p at 0 (travelFinite keeps the observers
|
||||
// honest about that case too).
|
||||
// The last two geometry reads, then the write block.
|
||||
const scrollerPadTop = scrollDriven
|
||||
? parseFloat(getComputedStyle(scroller).paddingTop) || 0
|
||||
: 0;
|
||||
const frameH = scroller.clientHeight;
|
||||
|
||||
// JS state, not style — apply() and the sentinel observers read
|
||||
// these synchronously, so they are set with the reads, not
|
||||
// deferred with the writes.
|
||||
travelFinite = Number.isFinite(travel);
|
||||
if (s2) {
|
||||
s2.style.top = travelFinite
|
||||
? `${(0.42 * travel - 1).toFixed(1)}px`
|
||||
: "-99999px";
|
||||
|
||||
// ── the write block — geometry above, styles below, applied at
|
||||
// the next rAF (latest measurement wins; see `commit`) ──
|
||||
commit = () => {
|
||||
if (markEnd != null) {
|
||||
page.style.setProperty("--mark-rest", `${markRest}px`);
|
||||
page.style.setProperty("--mark-end", markEnd);
|
||||
}
|
||||
page.style.setProperty("--row-h", `${rowH}px`);
|
||||
page.style.setProperty("--banner-h", `${banner}px`);
|
||||
// Glue, not travel. --lift cancels .hero-copy's parked term
|
||||
// exactly, so its bottom stays 0 at every p — the wordmark
|
||||
// shrinks in place about its resting baseline. The row needs no
|
||||
// travel var at all any more: its box is pinned to its resting
|
||||
// slot in CSS.
|
||||
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
|
||||
// Where the pins sit in flow. the glass bar's net flow height is
|
||||
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
|
||||
// same offset the pins do — and it is `position: relative`, so
|
||||
// asking it cannot feed a sticky offset back into the measurement
|
||||
// the way asking a pin would.
|
||||
page.style.setProperty("--pin-origin", `${zoneTop}px`);
|
||||
page.style.setProperty("--hero-span", `${span}px`);
|
||||
page.style.setProperty("--strip", `${strip}px`);
|
||||
// The scroll-driven animations' constants (see the second
|
||||
// @supports (animation-timeline: scroll()) block in the CSS).
|
||||
// --travel is the shared animation-range span — the p
|
||||
// denominator — so linear easing over 0..--travel gives
|
||||
// animation progress ≡ p at every scrollTop. The 999999px
|
||||
// sentinel mirrors travel = Infinity above (p pinned ~0: no
|
||||
// animation progresses measurably), and every consumer reads
|
||||
// var(--travel, 999999px) so the pre-measure first paint is
|
||||
// inert too. --ride is the SAME delta as a length for
|
||||
// to-keyframes, and must be a separate variable: tied to the
|
||||
// range sentinel, a degenerate page's to-value would cancel
|
||||
// into translate = -scrollTop; the 0px sentinel keeps it
|
||||
// motionless exactly as p = 0 does today. --fade-span is the
|
||||
// extras/badge fade's own range (travel / 2.4, where their
|
||||
// opacity ramp clamps): dividing the 999999px sentinel instead
|
||||
// would let a deep scroll on a degenerate page ghost-fade chips
|
||||
// that today hold opacity 1 at any scrollTop.
|
||||
page.style.setProperty(
|
||||
"--travel",
|
||||
`${rideValid ? (span - strip).toFixed(1) : 999999}px`,
|
||||
);
|
||||
page.style.setProperty(
|
||||
"--ride",
|
||||
`${rideValid ? (span - strip).toFixed(1) : 0}px`,
|
||||
);
|
||||
page.style.setProperty(
|
||||
"--fade-span",
|
||||
`${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`,
|
||||
);
|
||||
// The chips row's total leftward slide for the scroll-driven
|
||||
// path: the sum of the layout width both buttons ahead of it in
|
||||
// the flex row shed (22 mirrors --play-shrink; 13 is
|
||||
// .hero-preview's 40→27). On that path the buttons' REAL widths
|
||||
// are frozen and shrink by transform instead, so the flex row no
|
||||
// longer supplies this motion. Presence-aware so a
|
||||
// conditionally-rendered preview button cannot leave a phantom
|
||||
// 13px slide, and validity-gated like --ride: on a degenerate
|
||||
// page the range sentinel would otherwise creep the chips
|
||||
// ~1.75px at extreme scrollTop where today's pinned p holds
|
||||
// them motionless. (The button/glyph scale rides keep constant
|
||||
// to-poses — their same-class residual is ≤1.1px on geometry
|
||||
// the 390px hero-zone minimum makes unreachable; accepted.)
|
||||
page.style.setProperty(
|
||||
"--row-slide",
|
||||
`${rideValid ? (hasPlay ? 22 : 0) + (hasPreview ? 13 : 0) : 0}px`,
|
||||
);
|
||||
page.style.setProperty("--fog-span", `${fogSpan}px`);
|
||||
page.style.setProperty("--fog-above", `${fogAbove}px`);
|
||||
page.style.setProperty("--fog-rest", `${fogRest}px`);
|
||||
// The sheet's pose delta for its scroll-driven glide (usually
|
||||
// negative): from-pose translateY(--fog-rest), to-pose
|
||||
// translateY(--fog-rest + --fog-ride) ≡ translateY(strip -
|
||||
// fog-above) — the same two exact endpoints the fallback calc
|
||||
// has. 0px sentinel, same reasoning as --ride above.
|
||||
page.style.setProperty(
|
||||
"--fog-ride",
|
||||
`${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`,
|
||||
);
|
||||
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.
|
||||
page.style.setProperty(
|
||||
"--cut-top",
|
||||
`${(cutScreen - scrollerPadTop).toFixed(1)}px`,
|
||||
);
|
||||
const hostFlowTop = zoneTop + zoneH - 18;
|
||||
const fadeS = Math.max(0, hostFlowTop - cutScreen);
|
||||
page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`);
|
||||
}
|
||||
// Unconditional: .fade-frame is scroll-driven-only, but .sheetclip
|
||||
// sizes itself from --frame-h on BOTH branches (it is what keeps the
|
||||
// 300vh sheet out of the pane's scroll range). Setting it on the
|
||||
// fallback path is otherwise inert — .fade-frame's rule lives inside
|
||||
// the @supports block and never matches there.
|
||||
page.style.setProperty("--frame-h", `${frameH}px`);
|
||||
// The extras' hit-gate sentinel parks at the scrollTop where
|
||||
// p crosses 0.42. Probed live (2026-07-24, this WebView2): the
|
||||
// observers' rootBounds top coincides with the page's box top
|
||||
// at scrollTop 0 — the pane's 24px padding does NOT offset the
|
||||
// crossing — but the observer reports integer-snapped rects,
|
||||
// so the -1 below folds the sentinel's own 1px height into the
|
||||
// placement: the snapped bottom then reaches the root's top at
|
||||
// the same integer scrollTop the old `p > 0.42` first exceeded
|
||||
// (probed flip: auto at 377, none at 378, boundary 377.58).
|
||||
// Off-screen sentinel on a degenerate page, mirroring travel =
|
||||
// Infinity pinning p at 0 (travelFinite keeps the observers
|
||||
// honest about that case too).
|
||||
if (s2) {
|
||||
s2.style.top = travelFinite
|
||||
? `${(0.42 * travel - 1).toFixed(1)}px`
|
||||
: "-99999px";
|
||||
}
|
||||
};
|
||||
if (!commitRaf) {
|
||||
commitRaf = requestAnimationFrame(() => {
|
||||
commitRaf = 0;
|
||||
const c = commit;
|
||||
commit = null;
|
||||
c?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,8 +1516,8 @@
|
||||
if (!frame) frame = requestAnimationFrame(apply);
|
||||
}
|
||||
|
||||
function reflow() {
|
||||
measure();
|
||||
function reflow(settled = true) {
|
||||
measure(settled);
|
||||
// Supported path: nothing per-frame is left to apply — the
|
||||
// observers re-deliver on their own once measure() has moved
|
||||
// the sentinels, and running apply() here would pay the exact
|
||||
@@ -1580,7 +1658,9 @@
|
||||
// reschedules itself, so there is no loop.
|
||||
let settleTimer = 0;
|
||||
const reflowAndSettle = () => {
|
||||
reflow();
|
||||
// Mid-drag pass: parked chips reuse their cached height. The
|
||||
// trailing pass (bare reflow, settled = true) measures exactly.
|
||||
reflow(false);
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(reflow, 200);
|
||||
};
|
||||
@@ -1653,6 +1733,8 @@
|
||||
s1?.remove();
|
||||
s2?.remove();
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
if (commitRaf) cancelAnimationFrame(commitRaf);
|
||||
commit = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2730,8 +2812,36 @@
|
||||
// what keeps the reveal's per-frame re-stand authoritative.
|
||||
let glide = 0;
|
||||
let glideTo = null;
|
||||
// "Instant" writes land in the NEXT rAF, never inside the
|
||||
// ResizeObserver delivery that asked for them. A write during
|
||||
// delivery makes the observer loop re-run style and layout in the
|
||||
// SAME frame to check for further changes — a whole extra layout
|
||||
// pass per frame, paid on every frame of a drag while the reserve
|
||||
// tracks the moving margin. Deferred one rAF, the write rides the
|
||||
// frame's single natural layout, and the echo delivery it provokes
|
||||
// is filtered by the entry guard below without a DOM read. A frame
|
||||
// of lag on a min-height floor is invisible — and the reveal never
|
||||
// waits on this path: it stands its own reserve synchronously.
|
||||
let pendingTo = null;
|
||||
let flushRaf = 0;
|
||||
const flushReserve = () => {
|
||||
flushRaf = 0;
|
||||
if (pendingTo == null || !node.isConnected) {
|
||||
pendingTo = null;
|
||||
return;
|
||||
}
|
||||
const standing = parseFloat(node.style.minHeight) || 0;
|
||||
const to = pendingTo;
|
||||
pendingTo = null;
|
||||
// Never lower under a running reveal: it may have raised the
|
||||
// standing value past this queued figure since the queueing pass,
|
||||
// and dropping beneath it would clamp the pane mid-ease.
|
||||
if (revealActive && to < standing) return;
|
||||
if (to !== standing) node.style.minHeight = `${to}px`;
|
||||
};
|
||||
const setReserve = (to) => {
|
||||
const from = parseFloat(node.style.minHeight) || 0;
|
||||
const standing = parseFloat(node.style.minHeight) || 0;
|
||||
const from = pendingTo ?? standing;
|
||||
if (glide) {
|
||||
if (to <= glideTo) return; // already heading there or lower
|
||||
cancelAnimationFrame(glide);
|
||||
@@ -2747,9 +2857,12 @@
|
||||
// fall happens afterwards, from retire(), where it can glide.
|
||||
if (revealActive && to < from) return;
|
||||
if (to >= from - 2 || revealActive) {
|
||||
if (node.style.minHeight !== `${to}px`) node.style.minHeight = `${to}px`;
|
||||
if (pendingTo === to || (pendingTo == null && standing === to)) return;
|
||||
pendingTo = to;
|
||||
if (!flushRaf) flushRaf = requestAnimationFrame(flushReserve);
|
||||
return;
|
||||
}
|
||||
pendingTo = null;
|
||||
glideTo = to;
|
||||
const start = performance.now();
|
||||
const DUR = 260;
|
||||
@@ -2757,24 +2870,63 @@
|
||||
if (!node.isConnected) { glide = 0; glideTo = null; return; }
|
||||
const t = Math.min(1, (now - start) / DUR);
|
||||
const ease = 1 - (1 - t) ** 3;
|
||||
node.style.minHeight = `${Math.round(from + (to - from) * ease)}px`;
|
||||
node.style.minHeight = `${Math.round(standing + (to - standing) * ease)}px`;
|
||||
if (t < 1) { glide = requestAnimationFrame(step); return; }
|
||||
glide = 0;
|
||||
glideTo = null;
|
||||
});
|
||||
};
|
||||
|
||||
// Found once, re-queried only after a teardown replaced them: the
|
||||
// pane and the tab bar cannot move while this grid is mounted.
|
||||
let cachedPane = null;
|
||||
let cachedBar = null;
|
||||
// The reveal reserve's expensive half — the open row's offset inside
|
||||
// the grid. Width alone cannot move it (tracks are fixed 150px): it
|
||||
// moves when the expander changes ROW — a column-count change, a
|
||||
// different album, art landing above it. Recomputing it on every
|
||||
// frame of a drag paid rects plus a subtree sweep 48 times for a
|
||||
// handful of distinct answers, and was most of the difference between
|
||||
// ~20ms and ~60ms p95 frames with a panel open. Keyed on exactly what
|
||||
// invalidates it; forced() clears the key for the DOM-driven cases
|
||||
// (open, row change, close, art landing).
|
||||
let rowOffCols = -1;
|
||||
let rowOffXp = null;
|
||||
let rowOffRow = null;
|
||||
let rowOff = 0;
|
||||
// A column-count change moves the expander only on Svelte's NEXT
|
||||
// flush, so an offset read in the same pass sees the old layout.
|
||||
let colsSettling = false;
|
||||
|
||||
const measure = () => {
|
||||
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
|
||||
if (gridCols[groupKey] !== n) gridCols = { ...gridCols, [groupKey]: n };
|
||||
if (gridCols[groupKey] !== n) {
|
||||
gridCols = { ...gridCols, [groupKey]: n };
|
||||
// Re-measure once the expander has landed on its new row —
|
||||
// the offset cached this pass is against a layout that is
|
||||
// about to change. (Today's per-frame recompute healed on the
|
||||
// next drag frame; a drag that ENDS on a column change has no
|
||||
// next frame, so this settle pass also closes that hole.)
|
||||
if (!colsSettling && node.querySelector(".album-expander")) {
|
||||
colsSettling = true;
|
||||
void tick().then(() => {
|
||||
colsSettling = false;
|
||||
if (node.isConnected) forced();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pane = scrollPaneFor(node);
|
||||
if (!cachedPane?.isConnected) cachedPane = scrollPaneFor(node);
|
||||
// Anchor on the TAB BAR, not the heading: the bar is what
|
||||
// `selectTab` pins, and the worst case is the bar sitting flush
|
||||
// at the pane's top. Everything below it (the bar itself + this
|
||||
// grid) must then still fill a pane-height, or the scroll clamps.
|
||||
const bar = node.closest(".pane")?.querySelector(".disco-tabs")
|
||||
?? document.querySelector(".disco-tabs");
|
||||
if (!cachedBar?.isConnected) {
|
||||
cachedBar = node.closest(".pane")?.querySelector(".disco-tabs")
|
||||
?? document.querySelector(".disco-tabs");
|
||||
}
|
||||
const pane = cachedPane;
|
||||
const bar = cachedBar;
|
||||
if (!pane || !bar) return;
|
||||
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
|
||||
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
|
||||
@@ -2799,18 +2951,82 @@
|
||||
const xp = node.querySelector(".album-expander:not([inert])")
|
||||
?? node.querySelector(".album-expander");
|
||||
const openRow = xp?.previousElementSibling;
|
||||
if (openRow) reserve = Math.max(reserve, revealReserve(node, openRow, pane, xp));
|
||||
if (openRow) {
|
||||
if (n !== rowOffCols || xp !== rowOffXp || openRow !== rowOffRow) {
|
||||
rowOff = revealRowOffset(node, openRow, xp);
|
||||
rowOffCols = n;
|
||||
rowOffXp = xp;
|
||||
rowOffRow = openRow;
|
||||
}
|
||||
// The cheap half stays live every frame: the pane's height and
|
||||
// the banner margin move with the window, the row offset does
|
||||
// not.
|
||||
reserve = Math.max(
|
||||
reserve,
|
||||
Math.round(rowOff + pane.clientHeight - revealMargin(node)),
|
||||
);
|
||||
}
|
||||
setReserve(reserve);
|
||||
};
|
||||
// The resize path still runs EVERY frame — the grid keeps scaling live.
|
||||
// What it stops doing is repeating work whose inputs have not moved.
|
||||
//
|
||||
// The reserve is a pure function of the grid's width and the pane's
|
||||
// height. When neither has changed since the last pass — which is every
|
||||
// observer callback provoked by our own minHeight write, and every
|
||||
// vertical-only resize — recomputing it cannot produce a different
|
||||
// answer, so the guard is exact rather than approximate.
|
||||
//
|
||||
// Sizes come from the ENTRIES, never from clientWidth/clientHeight: an
|
||||
// entry is a post-layout fact, but a DOM read here forces layout
|
||||
// whenever anything earlier in the same delivery has written — and the
|
||||
// reserve's own write provokes an echo delivery every time it moves.
|
||||
// The pane is observed alongside the grid for the same reason; its
|
||||
// height rides in with the entries and the echo path never touches
|
||||
// the DOM at all.
|
||||
let lastW = -1;
|
||||
let lastPaneH = -1;
|
||||
const onResize = (entries) => {
|
||||
let w = lastW;
|
||||
let paneH = lastPaneH;
|
||||
for (const entry of entries) {
|
||||
const box = entry.borderBoxSize?.[0];
|
||||
if (entry.target === node) {
|
||||
w = Math.round(box ? box.inlineSize : entry.contentRect.width);
|
||||
} else {
|
||||
paneH = Math.round(box ? box.blockSize : entry.contentRect.height);
|
||||
}
|
||||
}
|
||||
if (w === lastW && paneH === lastPaneH) return;
|
||||
lastW = w;
|
||||
lastPaneH = paneH;
|
||||
measure();
|
||||
};
|
||||
// Anything that changes the DOM rather than the box — an expander
|
||||
// opening, a row change, a close — must not be filtered by that guard:
|
||||
// its inputs are unchanged but its answer is not. The row-offset cache
|
||||
// is keyed on the same DOM, so it drops here too.
|
||||
const forced = () => {
|
||||
lastW = -1;
|
||||
lastPaneH = -1;
|
||||
rowOffCols = -1;
|
||||
rowOffXp = null;
|
||||
rowOffRow = null;
|
||||
measure();
|
||||
};
|
||||
|
||||
measure();
|
||||
gridMeasures.add(measure);
|
||||
const ro = new ResizeObserver(measure);
|
||||
gridMeasures.add(forced);
|
||||
const ro = new ResizeObserver(onResize);
|
||||
ro.observe(node);
|
||||
const paneEl = cachedPane ?? scrollPaneFor(node);
|
||||
if (paneEl) ro.observe(paneEl);
|
||||
return {
|
||||
destroy: () => {
|
||||
ro.disconnect();
|
||||
gridMeasures.delete(measure);
|
||||
gridMeasures.delete(forced);
|
||||
cancelAnimationFrame(glide);
|
||||
cancelAnimationFrame(flushRaf);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3280,6 +3496,16 @@
|
||||
* that then walked back toward it, and the page moved three times. */
|
||||
function vanishingAbove(grid, row, keep) {
|
||||
if (!grid) return 0;
|
||||
// Gather before measuring: with a single expander standing — every
|
||||
// frame outside the 460ms of a row switch — there is nothing on its
|
||||
// way out, and the rect and style reads below would be paid for an
|
||||
// answer that is always zero. This runs on every frame of a reveal
|
||||
// ease, so the empty case must cost only the subtree query.
|
||||
let others = null;
|
||||
for (const other of grid.querySelectorAll(".album-expander")) {
|
||||
if (other !== keep) (others ??= []).push(other);
|
||||
}
|
||||
if (!others) return 0;
|
||||
const rowTop = row.getBoundingClientRect().top;
|
||||
// The expander is a GRID ITEM on a row of its own, so the row-gap that
|
||||
// separates it from its neighbour goes with it. Counting only its
|
||||
@@ -3291,8 +3517,7 @@
|
||||
// is exactly what makes a 10px move read as a bump.
|
||||
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
|
||||
let px = 0;
|
||||
for (const other of grid.querySelectorAll(".album-expander")) {
|
||||
if (other === keep) continue;
|
||||
for (const other of others) {
|
||||
const r = other.getBoundingClientRect();
|
||||
// Its whole outer contribution: height, its own collapse margin,
|
||||
// and the row-gap only if the grid still uses one. expandSlide
|
||||
@@ -3304,11 +3529,20 @@
|
||||
return px;
|
||||
}
|
||||
|
||||
function revealReserve(grid, row, pane, keep) {
|
||||
const rowInGrid = row.getBoundingClientRect().top
|
||||
/** The open row's offset inside the grid, net of anything mid-outro above
|
||||
* it — the half of the reserve that costs layout reads. Split from
|
||||
* revealReserve so measureCols can cache it across a drag: width alone
|
||||
* cannot move it (tracks are fixed 150px), only a row change can. */
|
||||
function revealRowOffset(grid, row, keep) {
|
||||
return row.getBoundingClientRect().top
|
||||
- grid.getBoundingClientRect().top
|
||||
- vanishingAbove(grid, row, keep);
|
||||
return Math.round(rowInGrid + pane.clientHeight - revealMargin(grid));
|
||||
}
|
||||
|
||||
function revealReserve(grid, row, pane, keep) {
|
||||
return Math.round(
|
||||
revealRowOffset(grid, row, keep) + pane.clientHeight - revealMargin(grid),
|
||||
);
|
||||
}
|
||||
|
||||
function revealIntoView(node) {
|
||||
|
||||
Reference in new issue
Block a user