diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 6abfdd0..088730f 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -522,6 +522,10 @@ }; if (artistChanged) { clearTimeout(bannerTransitionTimer); + // A commit parked for scroll calm belongs to the OLD artist; + // running it now would paint the wrong hero over the new one. + heroCommitParked = null; + clearTimeout(heroCommitTimer); displayedBanner = { source: "", url: null, key: "" }; previousBanner = null; initialBannerPaint = true; @@ -563,6 +567,100 @@ return () => clearInterval(timer); }); + // ── hero commits wait for scroll calm (backlog #3, 2026-08-31) ────── + // The 20s tick above already skips while the app scrolled recently, but + // that gate is start-only: a cache-miss payload (multi-MB raw-RGBA IPC + + // decode + texture/twin bake, a measured ~150ms main-thread task) used + // to COMMIT whenever its promise resolved — an arbitrary moment relative + // to input, ~150ms after the user resumed scrolling in the case that + // motivated the backlog entry. Every banner commit now waits for the + // same calm the tick waits for. The OLD banner and its twin stay mounted + // for the whole wait — nothing is cleared until the deferred commit + // runs — so the mono overlay never sits empty mid-scroll. First paints + // are exempt (an empty hero is worse than a busy frame) via the + // displayedBanner.url check. Latest wins: a finished render superseding + // a parked preview paints once, and every commit re-validates its own + // staleness inside itself, at commit time. + const HERO_COMMIT_IDLE_MS = 600; + let heroCommitParked = null; + let heroCommitTimer = 0; + function deferHeroCommit(commit) { + if (!displayedBanner.url || appScrollIdleFor(HERO_COMMIT_IDLE_MS)) { + heroCommitParked = null; + clearTimeout(heroCommitTimer); + commit(); + return; + } + heroCommitParked = commit; + clearTimeout(heroCommitTimer); + const poll = () => { + if (!heroCommitParked) return; + if (!appScrollIdleFor(HERO_COMMIT_IDLE_MS)) { + heroCommitTimer = setTimeout(poll, 200); + return; + } + const run = heroCommitParked; + heroCommitParked = null; + run(); + }; + heroCommitTimer = setTimeout(poll, 200); + } + $effect(() => () => { + heroCommitParked = null; + clearTimeout(heroCommitTimer); + }); + + // ── dev tripwire: the hero's rides vs its scroller (2026-08-31) ───── + // Kyle photographed the identity block parked over the Popular rows with + // every scroll-driven paint effect at its rest pose — a transient that + // healed before it could be probed. If it recurs, catch it in the act: + // once a second (and only at scroll calm, so a mid-flick frame gap never + // false-trips), compare the gfade ride's clock against scrollTop and + // check --travel for the pre-measure sentinel. One forced layout per + // second, dev builds and dark glass only (solid mode has no rides). + // Snapshots land in globalThis.__heroTripwire and console.error, latched + // once per episode. + $effect(() => { + if (!import.meta.env.DEV) return; + globalThis.__heroTripwire ??= []; + let tripped = false; + const timer = setInterval(() => { + if (!document.documentElement.classList.contains("glass")) return; + if (!appScrollIdleFor(300)) return; + const view = document.querySelector(".pane-shell > .content"); + const pin = view?.querySelector(".heropin"); + if (!view || !pin) return; + const st = view.scrollTop; + const range = Math.max(1, view.scrollHeight - view.clientHeight); + const travel = parseFloat(getComputedStyle(pin).getPropertyValue("--travel")); + const anim = view.querySelector(".gfade-frame")?.getAnimations()[0]; + const ct = anim + ? anim.currentTime === null + ? null + : parseFloat(anim.currentTime) + : undefined; + const faults = []; + if (st > 4 && travel > 900000) faults.push("travel-sentinel"); + if (st > 4 && anim && ct === null) faults.push("timeline-dead"); + if (st > 4 && !anim) faults.push("no-anim"); + if (typeof ct === "number" && Math.abs(ct - (st / range) * 100) > 3) { + faults.push("ride-desync"); + } + if (faults.length) { + if (!tripped) { + tripped = true; + const snap = { t: Date.now(), faults, scrollTop: st, range, travel, ct }; + globalThis.__heroTripwire.push(snap); + if (globalThis.__heroTripwire.length > 20) globalThis.__heroTripwire.shift(); + console.error("[hero-tripwire]", JSON.stringify(snap)); + } + } else { + tripped = false; + } + }, 1000); + return () => clearInterval(timer); + }); + function gpuHeroLoading(source) { if (source === selectedHeroSource) heroLoadInFlight = true; } @@ -570,32 +668,40 @@ function gpuHeroReady(source, result) { if (source !== selectedHeroSource || !selectedUsesGpu) return; - const prior = displayedBanner; - previousBanner = prior.url && prior.kind !== "gpu" ? prior : null; - displayedBanner = { - source, - url: `gpu:${heroSourceKey(source)}`, - key: `${heroSourceKey(source)}#gpu`, - kind: "gpu", - // Keep the preview's checksum-bound crop plan stable. Dropping it - // changed the child prop to null and caused a second render (and, - // for large payloads, a second backend decode/raw IPC) of the same - // hero immediately after the first one completed. - plan: prior.source === source ? prior.plan ?? null : null, - }; + // The LOAD is done now — only the PAINT may wait for calm. Clearing + // in-flight here (not inside the deferred commit) keeps the rotation + // machinery live even if this commit is later superseded unpainted. heroLoadInFlight = false; - initialBannerPaint = !prior.url; - finishBannerPaint = false; - clearTimeout(bannerTransitionTimer); - if (previousBanner) { - bannerTransitionTimer = setTimeout(() => { - previousBanner = null; - }, 650); - } - // Same-task snapshot for the twin + the seam tail's mirror — see - // snapshotGpuTwins above. - snapshotGpuTwins(); - document.documentElement.dataset.heroGpuMs = result.renderMs.toFixed(2); + deferHeroCommit(() => { + if (source !== selectedHeroSource || !selectedUsesGpu) return; + const prior = displayedBanner; + previousBanner = prior.url && prior.kind !== "gpu" ? prior : null; + displayedBanner = { + source, + url: `gpu:${heroSourceKey(source)}`, + key: `${heroSourceKey(source)}#gpu`, + kind: "gpu", + // Keep the preview's checksum-bound crop plan stable. Dropping it + // changed the child prop to null and caused a second render (and, + // for large payloads, a second backend decode/raw IPC) of the same + // hero immediately after the first one completed. + plan: prior.source === source ? prior.plan ?? null : null, + }; + initialBannerPaint = !prior.url; + finishBannerPaint = false; + clearTimeout(bannerTransitionTimer); + if (previousBanner) { + bannerTransitionTimer = setTimeout(() => { + previousBanner = null; + }, 650); + } + // Same-task snapshot for the twin + the seam tail's mirror — see + // snapshotGpuTwins above. Deferred along with the commit: the twin + // bake is the expensive half of exactly the task this gate exists + // to keep out of a scroll. + snapshotGpuTwins(); + document.documentElement.dataset.heroGpuMs = result.renderMs.toFixed(2); + }); } function gpuHeroError(source) { @@ -757,14 +863,17 @@ if (!active || heroFingerprint !== fingerprint) return; if (ready.kind === "preview") { - initialBannerPaint = firstPaint; - finishBannerPaint = false; - displayedBanner = { - source, - url: ready.url, - key: `${sourceKey}#preview`, - plan: cropPlan, - }; + deferHeroCommit(() => { + if (!active || heroFingerprint !== fingerprint) return; + initialBannerPaint = firstPaint; + finishBannerPaint = false; + displayedBanner = { + source, + url: ready.url, + key: `${sourceKey}#preview`, + plan: cropPlan, + }; + }); if (gpuFallbackOnly || provisionalSet) { // This source won the speed race, not the final provider // tier, or its GPU render genuinely failed. Keep its HD @@ -784,36 +893,42 @@ // vintage fade and grain then dissolve in over a short // window instead of popping in a single frame. The // preview is held underneath until the fade completes. - previousBanner = displayedBanner.url ? displayedBanner : null; - initialBannerPaint = false; - finishBannerPaint = true; - displayedBanner = { - source, - url: finished.url, - key: `${sourceKey}#finished`, - }; - clearTimeout(bannerTransitionTimer); - bannerTransitionTimer = setTimeout(() => { - previousBanner = null; - }, HERO_FINISH_FADE_MS); + deferHeroCommit(() => { + if (!active || heroFingerprint !== fingerprint) return; + previousBanner = displayedBanner.url ? displayedBanner : null; + initialBannerPaint = false; + finishBannerPaint = true; + displayedBanner = { + source, + url: finished.url, + key: `${sourceKey}#finished`, + }; + clearTimeout(bannerTransitionTimer); + bannerTransitionTimer = setTimeout(() => { + previousBanner = null; + }, HERO_FINISH_FADE_MS); + }); } catch { // Pre-cropped preview remains visible. } return; } - previousBanner = displayedBanner.url ? displayedBanner : null; - initialBannerPaint = firstPaint; - finishBannerPaint = false; - displayedBanner = { - source: ready.source, - url: ready.url, - key: `${heroSourceKey(ready.source)}#${loadGeneration}`, - }; - clearTimeout(bannerTransitionTimer); - bannerTransitionTimer = setTimeout(() => { - previousBanner = null; - }, HERO_FADE_MS); + deferHeroCommit(() => { + if (!active || heroFingerprint !== fingerprint) return; + previousBanner = displayedBanner.url ? displayedBanner : null; + initialBannerPaint = firstPaint; + finishBannerPaint = false; + displayedBanner = { + source: ready.source, + url: ready.url, + key: `${heroSourceKey(ready.source)}#${loadGeneration}`, + }; + clearTimeout(bannerTransitionTimer); + bannerTransitionTimer = setTimeout(() => { + previousBanner = null; + }, HERO_FADE_MS); + }); } catch { if (!active || heroFingerprint !== fingerprint) return; if (!failedBannerSources.includes(source)) {