aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 00:30:22 -0700
committerAdam Soltys <[email protected]>2026-10-09 00:30:22 -0700
commit62edcc721bf762cddad73ade54d8b580913051c7 (patch)
treebda08449dfc488724b6736b49c85180c0835ce16
parentb6819d91422adc10ea71ea5174c8e3e2badd1340 (diff)
downloadcoinosv3-62edcc721bf762cddad73ade54d8b580913051c7.tar.gz
coinosv3-62edcc721bf762cddad73ade54d8b580913051c7.zip
Desktop feed scrolls like a phone: a mouse-wheel notch coasts and slows (quick turns stack, a reverse turn brakes, trackpads left alone), and a mouse can grab the feed and flick it (a sideways drag still selects text, a drag doesn't click what it started on)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/app.js97
-rw-r--r--src/style.css2
-rw-r--r--tools/feed-momentum-test.js97
3 files changed, 196 insertions, 0 deletions
diff --git a/src/app.js b/src/app.js
index 1bb837e..0b05913 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6378,6 +6378,103 @@ if (INTENT_URI) {
document.addEventListener('pointercancel', () => { if (drag) drag.el.classList.remove('dragging'); drag = null; });
})();
+// The feed scrolls like a phone on a desktop. A mouse wheel's notch gives
+// it a push that coasts and slows, so a few turns travel a few posts and
+// quick turns stack up; a trackpad (which already coasts) is left alone.
+// And a mouse can grab the feed and flick it: drag up or down, let go while
+// moving and it keeps going. A drag that starts sideways is still a text
+// selection, and a drag that moved isn't a click on what it started on.
+(() => {
+ if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+ const SCOPE = '.feed-page, .notes-feed';
+ const TAU = 325; // ms: how quickly a coast dies down (the phone's feel)
+ const VMAX = 9; // px/ms
+ const inScope = (t) => {
+ const el = t && t.closest ? t : t && t.parentElement;
+ if (!el || !el.closest(SCOPE)) return false;
+ if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false;
+ // something inside that scrolls on its own (a caption, a list) keeps its wheel
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
+ if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false;
+ }
+ return true;
+ };
+ let v = 0, acc = 0, raf = 0, last = 0;
+ const stop = () => { v = 0; acc = 0; if (raf) cancelAnimationFrame(raf); raf = 0; };
+ const tick = (now) => {
+ const dt = Math.min(40, now - last); last = now;
+ acc += v * dt;
+ const step = Math.trunc(acc);
+ if (step) {
+ const y = window.scrollY;
+ window.scrollBy({ top: step, behavior: 'instant' });
+ acc -= step;
+ if (window.scrollY === y) return stop(); // an end of the page
+ }
+ v *= Math.exp(-dt / TAU);
+ if (Math.abs(v) < 0.02) return stop();
+ raf = requestAnimationFrame(tick);
+ };
+ const coast = (nv) => {
+ v = Math.max(-VMAX, Math.min(VMAX, nv));
+ if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); }
+ };
+ // ---- the wheel
+ let smoothUntil = 0; // a trackpad's events were just seen: hands off
+ window.addEventListener('wheel', (e) => {
+ if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return;
+ const notch = e.deltaMode === 1 || (e.wheelDeltaY && e.wheelDeltaY % 120 === 0 && !e.deltaX);
+ if (!notch) { smoothUntil = e.timeStamp + 400; stop(); return; }
+ 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 * 2.4) / 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(); });
+ window.addEventListener('touchstart', stop, { passive: true });
+ // ---- grab and flick
+ let drag = null;
+ document.addEventListener('pointerdown', (e) => {
+ if (raf) stop(); // a click catches a coasting page, like a finger does
+ if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return;
+ drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] };
+ });
+ document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out
+ document.addEventListener('pointermove', (e) => {
+ if (!drag) return;
+ if (!(e.buttons & 1)) { drag = null; return; }
+ if (!drag.on) {
+ const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y);
+ if (dx < 6 && dy < 6) return;
+ if (dx > dy) { drag = null; return; } // sideways: selecting text
+ drag.on = true;
+ document.documentElement.classList.add('grab-scrolling');
+ try { getSelection().removeAllRanges(); } catch {}
+ }
+ window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' });
+ drag.ly = e.clientY;
+ drag.pts.push([e.timeStamp, e.clientY]);
+ while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift();
+ });
+ const end = (e) => {
+ if (!drag) return;
+ const d = drag; drag = null;
+ if (!d.on) return;
+ document.documentElement.classList.remove('grab-scrolling');
+ const p = d.pts, a = p[0], b = p[p.length - 1];
+ // released while moving: throw it; held still first: just let go
+ if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 1.2);
+ const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
+ document.addEventListener('click', eat, { capture: true, once: true });
+ setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 0);
+ };
+ document.addEventListener('pointerup', end);
+ document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; });
+})();
+
window.addEventListener('load', () => {
if (!pendingMigratedName()) return;
const started = Date.now();
diff --git a/src/style.css b/src/style.css
index 422e51a..e3f55c8 100644
--- a/src/style.css
+++ b/src/style.css
@@ -712,6 +712,8 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.feed-chips::-webkit-scrollbar { display: none; }
/* with a mouse: a thin bar says the strip scrolls, and it drags (app.js) */
+/* grabbing the feed to scroll it (app.js) */
+.grab-scrolling, .grab-scrolling * { cursor: grabbing !important; user-select: none !important; }
@media (hover: hover) and (pointer: fine) {
.feed-chips { scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; padding-bottom: 6px; cursor: grab; }
.feed-chips::-webkit-scrollbar { display: block; height: 6px; }
diff --git a/tools/feed-momentum-test.js b/tools/feed-momentum-test.js
new file mode 100644
index 0000000..5b218d3
--- /dev/null
+++ b/tools/feed-momentum-test.js
@@ -0,0 +1,97 @@
+// Desktop feed scrolling feels like a phone: a mouse-wheel notch coasts
+// farther than the browser's own step, quick notches stack, a reverse turn
+// brakes; a trackpad's smooth deltas are left to the browser; a mouse drag
+// scrolls and a release while moving throws it; a drag that moved doesn't
+// click; a sideways drag is left alone (text selection).
+// Uses a tall stand-in feed so no relays are needed.
+// Run: bun tools/feed-momentum-test.js
+import puppeteer from './puppeteer.js';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const p = new URL(req.url).pathname;
+ if (p.startsWith('/punks') || p === '/verify-worker.js' || p.startsWith('/locales') || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = []; page.on('pageerror', (e) => errs.push(String(e)));
+const y = () => page.evaluate(() => window.scrollY);
+const settle = async () => { let a = -1, b = await y(); while (a !== b) { await sleep(150); a = b; b = await y(); } return b; };
+const top = async () => { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(100); };
+// a real wheel notch, as Chrome makes one (wheelDeltaY ±120)
+const notch = (dir = 1, smooth = false) => page.evaluate((d, s) => {
+ const t = document.querySelector('#stand-in p:nth-child(3)');
+ const ev = new WheelEvent('wheel', { deltaY: s ? d * 7.5 : d * 100, deltaMode: 0, bubbles: true, cancelable: true, clientX: 200, clientY: 300 });
+ Object.defineProperty(ev, 'wheelDeltaY', { value: s ? -d * 9 : -d * 120 });
+ t.dispatchEvent(ev);
+ return ev.defaultPrevented;
+}, dir, smooth);
+try {
+ await page.setViewport({ width: 1280, height: 900 });
+ await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
+ await sleep(800);
+ await page.evaluate(() => {
+ const f = document.createElement('div'); f.id = 'stand-in'; f.className = 'feed-page';
+ for (let i = 0; i < 400; i++) { const p = document.createElement('p'); p.style.cssText = 'height:120px;margin:0'; p.textContent = 'post ' + i; f.append(p); }
+ const b = document.createElement('button'); b.id = 'tgt'; b.textContent = 'a button in a post'; b.style.cssText = 'position:relative;height:200px;width:600px';
+ b.onclick = () => { window.__clicks = (window.__clicks || 0) + 1; };
+ f.prepend(b);
+ document.body.prepend(f);
+ });
+
+ console.log('[wheel]');
+ check('a notch is taken over', await notch(1));
+ const one = await settle();
+ check('one notch coasts well past a plain 100px step', one > 180 && one < 400, one + 'px');
+ await top();
+ for (let i = 0; i < 5; i++) { await notch(1); await sleep(40); }
+ const five = await settle();
+ check('five quick notches travel much farther', five > one * 4, five + 'px');
+ await top();
+ for (let i = 0; i < 3; i++) { await notch(1); await sleep(40); }
+ await sleep(60); await notch(-1);
+ const brake = await settle();
+ check('a reverse turn brakes the coast', brake < 3 * one, brake + 'px');
+ 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)));
+ await sleep(500);
+
+ console.log('[grab and flick]');
+ await top();
+ await page.mouse.move(300, 700); await page.mouse.down();
+ for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 40); await sleep(8); }
+ const during = await y();
+ await page.mouse.up();
+ check('dragging up scrolls with the mouse', during >= 380 && during <= 420, during + 'px');
+ const flung = await settle();
+ check('letting go while moving throws it', flung > during + 400, flung + 'px');
+ await top();
+ await page.mouse.move(300, 700); await page.mouse.down();
+ for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 30); await sleep(8); }
+ await sleep(250); // hold still
+ const held = await y();
+ await page.mouse.up();
+ check('held still before letting go: it stays put', (await settle()) - held < 5, held + ' → ' + await y());
+ await top();
+ const b = await (await page.$('#tgt')).boundingBox();
+ await page.evaluate(() => { window.__clicks = 0; });
+ await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
+ for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 50, b.y + 150 - i * 15);
+ await page.mouse.up(); await sleep(50);
+ check('a drag that started on a button doesn\'t click it', await page.evaluate(() => window.__clicks) === 0);
+ await page.click('#tgt'); await sleep(50);
+ check('...but a plain click still does', await page.evaluate(() => window.__clicks) === 1);
+ await top();
+ await page.mouse.move(100, 500); await page.mouse.down();
+ for (let i = 1; i <= 10; i++) await page.mouse.move(100 + i * 30, 505);
+ await page.mouse.up();
+ check('a sideways drag doesn\'t scroll (it selects text)', (await settle()) === 0);
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); }
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);