From 700fe908330878668ac8fcdcfaa7682a34d115c8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 4 Sep 2026 07:31:56 -0700 Subject: THE carousel stutter, root-caused via a per-frame scroll trace: a real flick showed scrollLeft climbing (19→40→64) then JUMPING to 0 and stalling ~80ms before gliding — a position jump at a steady 17ms/frame, not a dropped frame. Cause: a background render (ark update, balance tick) firing mid-drag; the morph rebuilds the strip and strips the drag's inline scroll-snap-type:none, so mandatory snap yanks the card to its origin. Fix: renders are held for the sub-second of a carousel drag and one flushes when motion stops, so the DOM never churns under the finger. Verified: a mid-drag render now leaves scrollLeft untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr --- src/app.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index bd8c495..61e86e2 100644 --- a/src/app.js +++ b/src/app.js @@ -421,12 +421,24 @@ function animatedAmount(key, sat) { return el; } +let _carDragging = false; // a balance-carousel drag is in progress +let _renderDeferred = false; function render() { + // A background render mid carousel-drag rebuilds the strip; the morph then + // strips the drag's inline scroll-snap-type:none, so mandatory snap yanks + // the card back to its origin — the "jump partway through" stutter (a + // position jump, not a dropped frame). Hold renders for the ~sub-second of a + // drag and flush one when it ends; the DOM never churns under the finger. + if (_carDragging) { _renderDeferred = true; return; } // A render that throws must never brick the page silently: stale DOM keeps // its handlers, every one of them calls render again, and every tap turns // into a no-op. Show the error and offer the way out instead. try { renderInner(); } catch (err) { renderCrash(err); } } +function endCarouselDrag() { + _carDragging = false; + if (_renderDeferred) { _renderDeferred = false; render(); } +} function renderCrash(err) { console.error('render failed:', err); try { @@ -3448,6 +3460,7 @@ function balanceCard() { // a card mid-drag is browsing, not choosing; release is the choice. const settle = (el) => { if (el._dragging) { dlog('settle-skip:dragging'); return; } + endCarouselDrag(); // motion has stopped — release the render hold (flushes any deferred render) const mid = el.scrollLeft + el.clientWidth / 2; let best = 0, bestD = Infinity; [...el.children].forEach((k, i) => { @@ -3481,8 +3494,13 @@ function balanceCard() { // (scrollend would be the semantic signal but it doesn't fire reliably // for programmatic scrolls across browsers, so it's not relied on.) onScrollend: (e) => { const el = e.currentTarget; if (!el._dragging) { clearTimeout(el._settle); settle(el); } }, - onTouchstart: (e) => { const el = e.currentTarget; el._dragging = true; clearTimeout(el._settle); }, - onTouchend: (e) => { e.currentTarget._dragging = false; }, // onScroll's debounce settles it + onTouchstart: (e) => { const el = e.currentTarget; el._dragging = true; _carDragging = true; clearTimeout(el._settle); }, + onTouchend: (e) => { + e.currentTarget._dragging = false; // onScroll's debounce settles it + // safety: if momentum produces no scroll events, settle (which clears + // _carDragging) may never fire — release the render hold anyway + setTimeout(() => { if (!e.currentTarget._dragging && _carDragging) endCarouselDrag(); }, 700); + }, // Desktop: scroll-snap has no mouse drag, so emulate one — grab the // strip and pull. Touch keeps the native pan (pointerType check); // buttons keep their clicks untouched. Mandatory snap fights raw @@ -3514,6 +3532,7 @@ function balanceCard() { if (!moved && Math.abs(dx) > 4) { moved = true; el._dragging = true; + _carDragging = true; // hold renders so the morph can't strip snap-none mid-drag el.classList.add('grabbing'); el.style.scrollSnapType = 'none'; } @@ -3529,7 +3548,10 @@ function balanceCard() { window.removeEventListener('pointerup', up); el.classList.remove('grabbing'); el._dragging = false; - if (!moved) return; + // safety: if the release glide produces no scroll events, settle + // never fires to release the render hold — do it after a beat + setTimeout(() => { if (!el._dragging && _carDragging) endCarouselDrag(); }, 700); + if (!moved) { endCarouselDrag(); return; } el._dragged = Date.now(); // the release's click must not double as a card tap // Commit to a neighbor on a small, deliberate move — not only when // the drag drags PAST the halfway point. A short flick that traveled -- cgit v1.2.3