resize: reads before writes everywhere, and an open album drags like a closed one

This commit is contained in:
KeelrLeek committed 2026-07-31 16:58:48 -05:00
1 parent 52f5878297
commit 606250c361
2 files changed
+412 -162

No files matched your search

+393 -159
View File
@@ -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) {