artist page: keep the album reveal's target reachable

Opening an album juddered: the page ran at the clicked row, then stopped
dead just short of it.

revealIntoView lifts the row to the banner line, which needs a pane-height of
content beneath that row. Until the previous commit the pane's ~2000px of dead
scroll always supplied it. With the range ending at the content, a row in the
last screenful no longer has it, so the reveal eased toward a scrollTop the
range could not hold and slammed into the clamp — measured on a 29-album artist:
target 1846 against a max of 1594, 272px in a single frame, then 39 frames
pinned, and the row parked 598px below the line instead of on it. Not a race
with the expander either; it finishes growing at ~500ms and the target is still
252px out of reach.

Reserve the row's own pane-height, the same way the grid already reserves one
for the tab bar so a tab switch cannot clamp the scroll — extended to the open
expander's row and released when it closes.

- On the grid's min-height, because that is what moves this page: .scrollbody's
  height does not answer to padding at all (border-box against a fixed height —
  0px, 200px and 600px of padding-bottom all measured the same 1787px box).
- Reserved, not predicted. The expander's height is not the page's growth: the
  slack in this very reserve swallowed 435 of a 486px panel, so a 486px expander
  grew the page by 66.
- Stood up synchronously by the reveal as well as by measureCols, which only
  recomputes off a ResizeObserver — that fires after the 420ms animation has
  already parked against the old clamp.
- Released on the expander's destroy. measureCols cannot notice the panel
  leaving: the raised min-height is exactly what stops the grid's box from
  changing, so the observer sees nothing and the reserve would stick for the
  rest of the visit, leaving a screenful of empty page under the grid.
- Re-measured across the settle after an open. What moves the target row is the
  card art this very click asked for; those images land over the following few
  hundred ms and rows above the row grow as they do (measured: the row sat 488px
  into the grid when the reveal read it, 555px once settled). Only the first
  open of a page pays this.
- The reveal clamps its target regardless and idles on it for up to a second, so
  a corrected reserve still completes the travel instead of ending short.

Verified on a 29-album artist and an 11-album one: every open lands the row at
exactly the 280px line, first open of a fresh page included, with no direction
reversals and no frames against the clamp. Frame timing improves on both —
cold 13 frames over 16ms (worst 79.6) to 2 (worst 60.6, the album fetch and
decode, which predates this), warm 2 (worst 18.4) to 0 (worst 9). The pane's
scroll range still ends exactly at the content with no expander open, and
returns to it on close.
This commit is contained in:
KeelrLeek committed 2026-07-24 18:25:58 -05:00
1 parent e58410d9f8
commit 52b1b8e622
1 file changed
+144 -16
+144 -16
View File
@@ -2386,6 +2386,13 @@
// "one pane-height below the tab bar" keeps every scroll position where
// the bar is visible achievable, so the bar never moves. It is a
// min-height, so tabs with plenty of releases are unaffected.
// Every mounted grid's own measure(), so the expander can force a recompute.
// A ResizeObserver cannot cover this: the reserve is applied as the grid's
// min-height, which is precisely what stops the grid's box from changing
// when the panel comes and goes — the observer sees nothing and the reserve
// sticks at whatever the page happened to measure mid-settle.
const gridMeasures = new Set();
function measureCols(node, groupKey) {
const measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
@@ -2400,15 +2407,63 @@
?? document.querySelector(".disco-tabs");
if (!pane || !bar) return;
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
const reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
// Same argument one level down, for the open expander's own row.
// revealIntoView lifts that row to the banner line, and a row in the
// last screenful has less beneath it than the pane is tall — so the
// reveal eased toward a scrollTop the range could not hold, ran the
// curve on toward it and stopped dead against the clamp (measured on
// a 29-album artist: wanted 1846, max 1594, 272px in one frame then
// 39 frames pinned). Reserving the row's own pane-height fixes the
// target instead of chasing it, and it belongs HERE rather than only
// in the reveal: the expander's height is not the page's growth (the
// slack in this very reserve swallowed 435 of a 486px panel), so it
// cannot be predicted from the panel — only reserved against. The
// reveal stands the same figure up synchronously; this keeps it
// true across resizes and hands it back on close.
const openRow = node.querySelector(".album-expander")?.previousElementSibling;
if (openRow) reserve = Math.max(reserve, revealReserve(node, openRow, pane));
if (node.style.minHeight !== `${reserve}px`) node.style.minHeight = `${reserve}px`;
};
measure();
gridMeasures.add(measure);
const ro = new ResizeObserver(measure);
ro.observe(node);
return { destroy: () => ro.disconnect() };
return {
destroy: () => {
ro.disconnect();
gridMeasures.delete(measure);
},
};
}
// Re-measure when the expander opens, moves row or closes. On the FIRST open
// of a freshly loaded page the grid is still settling — card art and wrapped
// titles land and push the target row down (measured: 488px into the grid
// when the reveal read it, 555px once settled) — so the reserve taken at
// click time comes up short and the row parks below the line. The reveal
// idles on its target for a beat, so a corrected reserve arriving here is
// enough for it to finish the travel.
$effect(() => {
void openFamilyKey;
let done = false;
const remeasure = () => {
if (!done) gridMeasures.forEach((m) => m());
};
void tick().then(remeasure);
// Spread across the settle, because what moves the row is the card art
// this very click asked for: opening an album resolves and preloads its
// covers, those images land over the next few hundred ms, and rows above
// the target grow as they do. One measurement at click time is taken
// before any of that. Only the first open of a page pays it — afterwards
// the art is cached and the first measurement is already right.
const timers = [80, 250, 550, 850].map((ms) => setTimeout(remeasure, ms));
return () => {
done = true;
timers.forEach(clearTimeout);
};
});
/** Index in group.items after which the expander renders (-1 = not here):
* the last card of the open family's row. Same-row switches therefore keep
* the same insertion point and morph in place; cross-row switches move the
@@ -2792,30 +2847,94 @@
// mid-flight (measured settling ~500px short). Recomputing the target
// every frame and setting scrollTop directly is immune to both, and
// also tracks an old expander collapsing above during row switches.
/** The line the reveal lands a row on: clears the sticky results-head AND
* the parked banner under it, plus breathing room. READ, not hard-coded:
* --banner-h is a fraction of the hero now, so a constant tuned to the old
* 124px banner would drop a clicked row underneath a taller one. Matches
* .release's scroll-margin-top, which is the same expression in CSS. */
function revealMargin(node) {
const artistPage = node.closest(".artist-page");
const banner = artistPage
? parseFloat(getComputedStyle(artistPage).getPropertyValue("--banner-h")) || 176
: 176;
return Math.round(22 + banner + 18);
}
/** How tall the grid must be for revealIntoView to lift `row` to that line:
* the row's own offset inside the grid, plus a pane-height below it. Shared,
* because both users need the SAME number for different reasons — the reveal
* must have it standing before its first frame or it eases at a target the
* range cannot hold, and measureCols must maintain it across resizes and
* hand it back when the expander closes. */
function revealReserve(grid, row, pane) {
const rowInGrid = row.getBoundingClientRect().top
- grid.getBoundingClientRect().top;
return Math.round(rowInGrid + pane.clientHeight - revealMargin(grid));
}
function revealIntoView(node) {
const row = node.previousElementSibling; // last card of the open row
const pane = scrollPaneFor(node);
if (!row || !pane) return;
// Clears the sticky results-head AND the parked banner under it, plus
// breathing room. READ, not hard-coded: --banner-h is a fraction of the
// hero now, so a constant tuned to the old 124px banner would drop a
// clicked row underneath a taller one. Matches .release's
// scroll-margin-top, which is the same expression in CSS.
const page = node.closest(".artist-page");
const banner = page
? parseFloat(getComputedStyle(page).getPropertyValue("--banner-h")) || 176
: 176;
const MARGIN = Math.round(22 + banner + 18);
const MARGIN = revealMargin(node);
const DURATION = 420;
const from = pane.scrollTop;
const start = performance.now();
// Stand the reserve up NOW, before the first frame. measureCols owns it
// and recomputes the identical figure, but only off a ResizeObserver —
// which fires after this animation has already run its 420ms and parked
// against the old clamp (measured: the reserve landed, raising the range
// to 1852, with the page long since stopped at 1528).
// On the grid's min-height because that is what moves this page:
// .scrollbody's height does not answer to padding at all — 0px, 200px
// and 600px of padding-bottom all measured the same 1787px box.
const grid = row.parentElement;
if (grid?.classList.contains("grid")) {
const need = revealReserve(grid, row, pane);
if ((parseFloat(grid.style.minHeight) || 0) < need) {
grid.style.minHeight = `${need}px`;
}
}
// Long enough to absorb a grid that is still settling, short enough that
// nothing can creep for a noticeable time after the panel is open.
const SETTLE = 1000;
let raf = requestAnimationFrame(function step(now) {
const t = Math.min(1, (now - start) / DURATION);
const elapsed = now - start;
const t = Math.min(1, elapsed / DURATION);
const ease = 1 - (1 - t) ** 3;
const target = row.getBoundingClientRect().top
const want = row.getBoundingClientRect().top
- pane.getBoundingClientRect().top + pane.scrollTop - MARGIN;
pane.scrollTop = from + (target - from) * ease;
raf = t < 1 ? requestAnimationFrame(step) : null;
// Clamped regardless of the reserve: easing at a target the range
// cannot hold is exactly what made the open judder.
const target = Math.max(
0,
Math.min(want, pane.scrollHeight - pane.clientHeight),
);
if (t < 1) {
pane.scrollTop = from + (target - from) * ease;
raf = requestAnimationFrame(step);
return;
}
// The ease can finish against a target that has since moved: on the
// FIRST open of a freshly loaded page the grid is still settling —
// card art and wrapped titles land, the row lifts, and the reserve
// measured a moment ago comes up short (measured 1458 against the
// 1525 the settled page wants, landing the row 61px below the line).
// Close the remainder exponentially rather than ending short or
// snapping; it is imperceptible when the gap is already zero, which
// is the normal case.
// Waiting on `want`, not on the clamped target: while the reserve is
// still short the two are equal the moment the page reaches the
// clamp, and quitting there is exactly how the row gets left below
// the line. Idle here until the reserve grows or SETTLE expires.
const gap = target - pane.scrollTop;
if (Math.abs(want - pane.scrollTop) < 0.5 || elapsed > SETTLE) {
if (Math.abs(gap) < 1) pane.scrollTop = target;
raf = null;
return;
}
if (Math.abs(gap) > 0.5) pane.scrollTop += gap * 0.2;
raf = requestAnimationFrame(step);
});
const cancel = () => raf && cancelAnimationFrame(raf);
// The user's own scrolling wins immediately.
@@ -2824,6 +2943,15 @@
destroy: () => {
cancel();
pane.removeEventListener("wheel", cancel);
// Hand the reveal's reserve back. measureCols cannot notice on
// its own: its ResizeObserver only fires when the grid's BOX
// changes, and the raised min-height is exactly what stops it
// changing when the panel leaves — so the reserve would stick
// for the rest of the visit and leave a screenful of empty page
// below the grid. Clearing it shrinks the grid, which is the
// resize that makes measureCols recompute the plain tab-bar
// reserve (or the next row's, on a cross-row switch).
if (grid?.classList.contains("grid")) grid.style.minHeight = "";
},
};
}