diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index a2d64f8..ed8c391 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -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 = ""; }, }; }