diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 01:06:37 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 01:06:37 -0700 |
| commit | 817bcc3ba7b2aaad388bc5b2e5cda5ccf7b27766 (patch) | |
| tree | dcee40a6ed25e6b7e8a1f0dcfa180aca9db05f97 | |
| parent | 537b116400089ce15a1bdbeb0873cb88dfa35fe3 (diff) | |
| download | coinosv3-817bcc3ba7b2aaad388bc5b2e5cda5ccf7b27766.tar.gz coinosv3-817bcc3ba7b2aaad388bc5b2e5cda5ccf7b27766.zip | |
Restart wheel coast timing and respond immediately on reversal
| -rw-r--r-- | src/app.js | 15 | ||||
| -rw-r--r-- | tools/feed-momentum-test.js | 14 | ||||
| -rw-r--r-- | tools/feed-wheel-reverse-test.js | 50 |
3 files changed, 76 insertions, 3 deletions
@@ -6428,9 +6428,18 @@ if (INTENT_URI) { if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return; e.preventDefault(); const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; - // reverse turns brake first; same-way turns add to the coast already going - const keep = Math.sign(v) === Math.sign(px) ? v : 0; - coast(keep + (px * 3) / TAU); + // A reversal catches the old coast immediately. Discard its fractional + // step and frame clock too: after a busy frame, applying the NEW velocity + // to the OLD elapsed time made the first reverse step jump. Give the + // first notch a small response now, then coast the remaining distance. + if (v && px && Math.sign(v) !== Math.sign(px)) { + stop(); + const first = Math.sign(px) * Math.min(16, Math.abs(px * 3 / TAU) * 16); + window.scrollBy({ top: first, behavior: 'instant' }); + coast((px * 3 - first) / TAU); + } else { + coast(v + (px * 3) / TAU); + } }, { passive: false }); // anything else that moves the page takes over from a coast window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); }); diff --git a/tools/feed-momentum-test.js b/tools/feed-momentum-test.js index 9f61667..29e4934 100644 --- a/tools/feed-momentum-test.js +++ b/tools/feed-momentum-test.js @@ -57,6 +57,20 @@ try { await sleep(60); await notch(-1); const brake = await settle(); check('a reverse turn brakes the coast', brake < 3 * one, brake + 'px'); + for (const dir of [1, -1]) { + await page.evaluate(() => window.scrollTo(0, 2000)); + await notch(dir); await sleep(60); + const first = await page.evaluate((dir) => { + const ev = new WheelEvent('wheel', { deltaY: -dir * 100, deltaMode: 0, bubbles: true, cancelable: true }); + Object.defineProperty(ev, 'wheelDeltaY', { value: dir * 120 }); + const before = scrollY; + document.querySelector('#stand-in p').dispatchEvent(ev); + return scrollY - before; + }, dir); + check('reversal responds immediately with a small step (' + dir + ')', first * dir < 0 && Math.abs(first) <= 16, first + 'px'); + await settle(); + } + await top(); check('a trackpad\'s smooth deltas are the browser\'s', !(await notch(1, true))); check('...and a notch right after them too (still a trackpad)', !(await notch(1))); diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js new file mode 100644 index 0000000..d8cc44a --- /dev/null +++ b/tools/feed-wheel-reverse-test.js @@ -0,0 +1,50 @@ +// Exercise the real input/coast handlers with controlled frame timing. +import assert from 'node:assert/strict'; +import { runInNewContext } from 'node:vm'; +const source = await Bun.file('src/app.js').text(); +const start = source.indexOf('(() => {', source.indexOf('// The feed scrolls like a phone')); +const end = source.indexOf("\nwindow.addEventListener('load'", start); +assert(start > 0 && end > start); +function fixture() { + let now = 100, id = 0; + const handlers = new Map(), frames = new Map(), steps = []; + const window = { scrollY: 5000, + addEventListener: (name, fn) => handlers.set(name, fn), + scrollBy: ({ top }) => { steps.push(top); window.scrollY += top; }, + }; + const document = { body: {}, addEventListener() {}, removeEventListener() {} }; + const target = { closest: (selector) => selector.includes('.feed-page') ? target : null, + scrollHeight: 100, clientHeight: 100, parentElement: document.body }; + runInNewContext(source.slice(start, end), { window, document, + matchMedia: () => ({ matches: false }), performance: { now: () => now }, + requestAnimationFrame: (fn) => { frames.set(++id, fn); return id; }, + cancelAnimationFrame: (id) => frames.delete(id), setTimeout, clearTimeout, + }); + return { + get y() { return window.scrollY; }, steps, + wheel(dir) { handlers.get('wheel')({ deltaY: dir * 100, wheelDeltaY: -dir * 120, + deltaMode: 0, deltaX: 0, target, timeStamp: now, preventDefault() {} }); }, + advance(ms) { now += ms; }, + frame(stamp = now) { const callbacks = [...frames.values()]; frames.clear(); for (const fn of callbacks) fn(stamp); }, + }; +} +for (const dir of [1, -1]) { + const f = fixture(); + f.wheel(dir); f.advance(17); f.frame(); + f.wheel(dir); f.advance(17); f.frame(); + // A busy main thread has missed several frames when the reverse arrives. + f.advance(120); + const before = f.y; + f.wheel(-dir); + assert((f.y - before) * dir < 0, 'first reverse notch responds immediately'); + assert(Math.abs(f.y - before) < 20, 'reverse starts with a small bounded step'); + const reversed = f.y; + f.frame(0); // a queued animation frame can carry an older timestamp + assert.equal(f.y, reversed, 'old frame time cannot revive the previous direction'); + f.advance(17); f.frame(); + assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction'); + assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay'); + for (let i = 0; i < 100; i++) { f.advance(17); f.frame(); } + assert(Math.abs(f.y - before) > 280 && Math.abs(f.y - before) < 330, 'one notch retains its coast distance'); +} +console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, and coast distance'); |
