forked from Keelrleek/SoulStream
hero commits wait for scroll calm, and a dev tripwire watches the rides
Kyle photographed the artist page interleaved with itself: the identity block parked over the Popular rows with every scroll-driven paint effect at its rest pose — wordmark unshrunk at full size, no fade on the rows passing under it — while the sticky geometry carried on correctly. It healed before it could be probed (no remount: a late measurement or observer delivery snapped it back), and a controlled reload-restore came up perfectly measured under a 14s watch, so the trigger was never pinned. Two answers shipped: THE GATE (backlog #3). The rotation TICK already skipped while the app scrolled recently, but that gate was 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. All four commit sites (GPU ready, preview paint, 4K finish crossfade, rotation/final) now route through deferHeroCommit, which waits for the same 600ms calm the tick waits for. The OLD banner and its twin stay mounted for the whole wait, so the mono overlay never sits empty mid-scroll; first paints are exempt (an empty hero is worse than a busy frame); a newer render supersedes a parked older one so it paints once; switching artists drops any parked commit; and the expensive snapshotGpuTwins bake rides INSIDE the deferred commit — it is the very task the gate exists to keep out of a scroll. gpuHeroReady clears heroLoadInFlight immediately (the load IS done; only the paint waits), so a superseded unpainted commit can never wedge the rotation machinery. THE TRIPWIRE (dev builds, dark glass only). Once a second, 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 plus dead or missing timelines. Faults land in globalThis.__heroTripwire and console.error as one latched snapshot per episode. Self-tested by injecting the sentinel with a 120px scroll: tripped exactly travel-sentinel within one tick while correctly NOT flagging the healthy ride (ct 4.29% = 120/2797). Next time the page breaks in someone's hands it hands over the culprit instead of a screenshot mystery. Verified live: a natural rotation completed through the calm path (heroGpuMs restamped 10.70 -> 29.60 during a fronted 26s watch).
This commit is contained in:
1 parent
e4a2bb6075
commit
0670b8654a
1 file changed
+117
-2
@@ -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,12 +567,112 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
function gpuHeroReady(source, result) {
|
||||
if (source !== selectedHeroSource || !selectedUsesGpu) return;
|
||||
// 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;
|
||||
deferHeroCommit(() => {
|
||||
if (source !== selectedHeroSource || !selectedUsesGpu) return;
|
||||
const prior = displayedBanner;
|
||||
previousBanner = prior.url && prior.kind !== "gpu" ? prior : null;
|
||||
@@ -583,7 +687,6 @@
|
||||
// hero immediately after the first one completed.
|
||||
plan: prior.source === source ? prior.plan ?? null : null,
|
||||
};
|
||||
heroLoadInFlight = false;
|
||||
initialBannerPaint = !prior.url;
|
||||
finishBannerPaint = false;
|
||||
clearTimeout(bannerTransitionTimer);
|
||||
@@ -593,9 +696,12 @@
|
||||
}, 650);
|
||||
}
|
||||
// Same-task snapshot for the twin + the seam tail's mirror — see
|
||||
// snapshotGpuTwins above.
|
||||
// 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,6 +863,8 @@
|
||||
if (!active || heroFingerprint !== fingerprint) return;
|
||||
|
||||
if (ready.kind === "preview") {
|
||||
deferHeroCommit(() => {
|
||||
if (!active || heroFingerprint !== fingerprint) return;
|
||||
initialBannerPaint = firstPaint;
|
||||
finishBannerPaint = false;
|
||||
displayedBanner = {
|
||||
@@ -765,6 +873,7 @@
|
||||
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,6 +893,8 @@
|
||||
// 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.
|
||||
deferHeroCommit(() => {
|
||||
if (!active || heroFingerprint !== fingerprint) return;
|
||||
previousBanner = displayedBanner.url ? displayedBanner : null;
|
||||
initialBannerPaint = false;
|
||||
finishBannerPaint = true;
|
||||
@@ -796,12 +907,15 @@
|
||||
bannerTransitionTimer = setTimeout(() => {
|
||||
previousBanner = null;
|
||||
}, HERO_FINISH_FADE_MS);
|
||||
});
|
||||
} catch {
|
||||
// Pre-cropped preview remains visible.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deferHeroCommit(() => {
|
||||
if (!active || heroFingerprint !== fingerprint) return;
|
||||
previousBanner = displayedBanner.url ? displayedBanner : null;
|
||||
initialBannerPaint = firstPaint;
|
||||
finishBannerPaint = false;
|
||||
@@ -814,6 +928,7 @@
|
||||
bannerTransitionTimer = setTimeout(() => {
|
||||
previousBanner = null;
|
||||
}, HERO_FADE_MS);
|
||||
});
|
||||
} catch {
|
||||
if (!active || heroFingerprint !== fingerprint) return;
|
||||
if (!failedBannerSources.includes(source)) {
|
||||
|
||||
Reference in new issue
Block a user