aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 00:09:21 -0700
committerAdam Soltys <[email protected]>2026-10-09 00:09:21 -0700
commit882ceda969af232e14a2a932b66c45b2e6e3f04f (patch)
treec623ba032e3fee41b115f5ae62c406c7ebc863d7
parent4655e373ebe5353041377974587c3ac4dffd2d5e (diff)
downloadcoinosv3-882ceda969af232e14a2a932b66c45b2e6e3f04f.tar.gz
coinosv3-882ceda969af232e14a2a932b66c45b2e6e3f04f.zip
Keep carousel flick destination through stale scroll-end events
-rw-r--r--src/app.js37
-rw-r--r--tools/carousel-flick-test.js103
2 files changed, 128 insertions, 12 deletions
diff --git a/src/app.js b/src/app.js
index 9d5c86e..6945c10 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4570,6 +4570,16 @@ function balanceCard() {
// a card mid-drag is browsing, not choosing; release is the choice.
const settle = (el) => {
if (el._dragging) return;
+ // Direct scrollLeft writes during a mouse drag can queue scrollend.
+ // It may arrive AFTER release, before the smooth glide's first frame.
+ // Only the chosen destination can end that glide; an intermediate
+ // position must not switch the pane back or restore snap early.
+ if (el._mouseTarget != null) {
+ if (Math.abs(el.scrollLeft - el._mouseTarget) > 2) return;
+ el._mouseTarget = null;
+ clearTimeout(el._snapT);
+ el.style.scrollSnapType = '';
+ }
endCarouselDrag(); // motion has stopped — unfreeze the strip and sync it
const mid = el.scrollLeft + el.clientWidth / 2;
let best = 0, bestD = Infinity;
@@ -4642,6 +4652,8 @@ function balanceCard() {
// where it started. Kill its timer too so they don't pile up.
el._dragGen = (el._dragGen || 0) + 1;
clearTimeout(el._snapT);
+ clearTimeout(el._settle);
+ el._mouseTarget = null;
const x0 = e.clientX, s0 = el.scrollLeft;
const kids0 = [...el.children];
// the card the drag started on — a flick throws relative to it
@@ -4674,9 +4686,6 @@ function balanceCard() {
window.removeEventListener('pointerup', up);
el.classList.remove('grabbing');
el._dragging = false;
- // safety: if the release glide produces no scroll events, settle
- // never fires to unfreeze the strip — do it after a beat
- setTimeout(() => { if (!el._dragging && (el._skipMorph || _renderDeferred)) 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
@@ -4699,17 +4708,21 @@ function balanceCard() {
else if (Math.abs(travelled) > 0.15) dir = travelled > 0 ? 1 : -1; // pulled ~a sixth of a card
const best = Math.max(0, Math.min(kids.length - 1, fromIdx + dir));
const k = kids[best];
- if (k) el.scrollTo({ left: k.offsetLeft + k.offsetWidth / 2 - el.clientWidth / 2, behavior: 'smooth' });
- // Restore mandatory snap only once THIS glide has come to rest, and
- // only if no newer drag has taken over — a fixed timer used to fire
- // mid-glide and snap the card back to its origin. scrollend marks
- // the true end; the timer is just a fallback for when the target is
- // already reached (no scroll, no scrollend).
+ // Clamp to the real scroll range: the end cards cannot always be
+ // perfectly centered within the strip's padding.
+ el._mouseTarget = k ? Math.max(0, Math.min(el.scrollWidth - el.clientWidth,
+ k.offsetLeft + k.offsetWidth / 2 - el.clientWidth / 2)) : el.scrollLeft;
+ el.scrollTo({ left: el._mouseTarget, behavior: 'smooth' });
+ // The shared settle handler restores snap only at this destination.
+ // If the browser omits scrollend, finish there after a bounded wait.
+ // A newer grab invalidates this fallback, including its unfreeze.
const gen = el._dragGen;
- const restore = () => { if (el._dragGen === gen && !el._dragging) el.style.scrollSnapType = ''; };
- el.addEventListener('scrollend', restore, { once: true });
clearTimeout(el._snapT);
- el._snapT = setTimeout(restore, 700);
+ el._snapT = setTimeout(() => {
+ if (el._dragGen !== gen || el._dragging || el._mouseTarget == null) return;
+ el.scrollTo({ left: el._mouseTarget, behavior: 'instant' });
+ settle(el);
+ }, 700);
// The chosen card is known RIGHT HERE — commit it now so the
// history below loads under the glide. The hold lifts (renders may
// run again) but the strip stays morph-frozen until settle; settle
diff --git a/tools/carousel-flick-test.js b/tools/carousel-flick-test.js
new file mode 100644
index 0000000..81e3028
--- /dev/null
+++ b/tools/carousel-flick-test.js
@@ -0,0 +1,103 @@
+// Exercise the production carousel handlers and DOM morph with native browser
+// scrolling; wallet/network data is replaced by two fixed account faces.
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+const source = await Bun.file('src/app.js').text();
+const dom = source.slice(source.indexOf('function h('), source.indexOf('const root = document.getElementById'));
+const carousel = source.slice(source.indexOf(' if (carouselMode) {'), source.indexOf(" const dtRaw = animWindow('acct'"));
+const end = source.slice(source.indexOf('function endCarouselDrag()'), source.indexOf('function renderCrash('));
+const tab = source.slice(source.indexOf(" const TAB_ORDER = ['history', 'receive', 'send'];"), source.indexOf(" return h(\n 'div',", source.indexOf(" const TAB_ORDER = ['history', 'receive', 'send'];")));
+const animation = source.slice(source.indexOf('const _uiSeen ='), source.indexOf('// A balance that counts'));
+const js = `${dom}
+${animation}
+let _carDragging = false, _renderDeferred = false, _accDir = null;
+const ui = { account: 'spending', tab: 'history' }, ACCOUNT_KEY = 'test-account';
+const accountSel = () => ui.account;
+const root = document.getElementById('app');
+window.changes = [];
+function balanceCard() {
+ const carouselMode = true;
+ const sel = accountSel(), faceFor = (v) => h('div', { style: 'height:110px' }, v), cardExtras = () => [];
+ ${carousel}
+}
+${end}
+function tabContent() { return h('div', { style: 'height:350px' }, ui.account + ':' + ui.tab); }
+function render() {
+ if (_carDragging) { _renderDeferred = true; return; }
+ ${tab}
+ const next = h('div', {}, balanceCard(), pane);
+ if (root.firstChild) morph(root.firstChild, next); else root.append(next);
+ if (changes.at(-1) !== ui.account) changes.push(ui.account);
+}
+window.state = () => ({ account: ui.account, changes: [...changes], frozen: !!document.querySelector('.bal-carousel')._skipMorph });
+render();`;
+const css = await Bun.file('src/style.css').text();
+const html = `<!doctype html><style>${css}\n#app { width:400px; margin:40px auto; }</style><div id="app"></div><script>${js}</script>`;
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'Content-Type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.goto(server.url.href);
+ await page.waitForSelector('.bal-carousel');
+ await Bun.sleep(400);
+ for (const injectStaleEnd of [false, true]) {
+ for (const direction of [-1, 1]) {
+ await page.evaluate(() => { changes.length = 0; changes.push(ui.account); });
+ const box = await page.$eval('.bal-carousel', (e) => { const r = e.getBoundingClientRect(); return { x: r.x + r.width/2, y: r.y + 70 }; });
+ await page.mouse.move(box.x, box.y); await page.mouse.down();
+ await page.mouse.move(box.x + direction * 30, box.y);
+ await page.mouse.move(box.x + direction * 75, box.y);
+ if (injectStaleEnd) {
+ // A queued scrollend from the final direct scrollLeft write arrives
+ // after release, before the first smooth-scroll animation frame.
+ await page.evaluate(() => window.addEventListener('pointerup', () => {
+ document.querySelector('.bal-carousel').dispatchEvent(new Event('scrollend'));
+ }, { once: true }));
+ }
+ await page.mouse.up();
+ await Bun.sleep(950);
+ const state = await page.evaluate(() => window.state());
+ console.log({ injectStaleEnd, direction, ...state });
+ assert.deepEqual(state.changes, direction < 0 ? ['spending', 'savings'] : ['savings', 'spending'], 'one content transition per flick');
+ assert.equal(state.frozen, false);
+ }
+ }
+ // A small slow pull stays on the same card and still releases the freeze.
+ await page.evaluate(() => {
+ changes.length = 0; changes.push(ui.account);
+ const el = document.querySelector('.bal-carousel');
+ el.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, clientX: 300, bubbles: true }));
+ window.dispatchEvent(new PointerEvent('pointermove', { clientX: 290 }));
+ window.dispatchEvent(new PointerEvent('pointerup'));
+ });
+ await Bun.sleep(800);
+ assert.deepEqual((await page.evaluate(() => window.state())).changes, ['spending']);
+ assert.equal((await page.evaluate(() => window.state())).frozen, false);
+ // Simulate a browser dropping the requested smooth scroll entirely: the
+ // fallback must finish the intended switch, not unfreeze at the old card.
+ await page.evaluate(() => {
+ const el = document.querySelector('.bal-carousel');
+ el.scrollTo = function (opts) { if (opts.behavior !== 'smooth') Element.prototype.scrollTo.call(this, opts); };
+ el.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, clientX: 300, bubbles: true }));
+ window.dispatchEvent(new PointerEvent('pointermove', { clientX: 220 }));
+ window.dispatchEvent(new PointerEvent('pointerup'));
+ });
+ await Bun.sleep(850);
+ assert.deepEqual((await page.evaluate(() => window.state())).changes, ['spending', 'savings']);
+ assert.equal((await page.evaluate(() => window.state())).frozen, false);
+ await page.evaluate(() => { delete document.querySelector('.bal-carousel').scrollTo; });
+ // Grab again before a glide finishes, and hold beyond its old fallback.
+ const box = await page.$eval('.bal-carousel', (el) => { const r = el.getBoundingClientRect(); return { x: r.x + 150, y: r.y + 70 }; });
+ await page.mouse.move(box.x, box.y); await page.mouse.down();
+ await page.mouse.move(box.x + 80, box.y); await page.mouse.up();
+ await page.mouse.down(); await page.mouse.move(box.x + 60, box.y);
+ await Bun.sleep(800);
+ assert.equal((await page.evaluate(() => window.state())).frozen, true, 'old fallback cannot unfreeze a new drag');
+ await page.mouse.up(); await Bun.sleep(850);
+ assert.equal((await page.evaluate(() => window.state())).frozen, false);
+ assert.deepEqual(errors, []);
+ console.log('✓ short pull, missing scrollend/glide fallback and re-grab hold');
+ console.log('✓ fast flicks in both directions switch content once, including a stale drag scrollend');
+} finally { await browser.close(); server.stop(true); }