aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js41
-rw-r--r--tools/feed-wheel-reverse-test.js57
2 files changed, 62 insertions, 36 deletions
diff --git a/src/app.js b/src/app.js
index dae7b17..96c6af1 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6617,13 +6617,17 @@ if (INTENT_URI) {
// 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 SCOPE = '.feed-page, .notes-feed, .thread-page'; // a thread scrolls the same as a feed (Adam, 2026-10-16)
const TAU = 325; // ms: how quickly a coast dies down (the phone's feel)
const VMAX = 16; // 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;
+ // (A video or an audio player is part of the feed: a notch over a clip
+ // coasts like one over the words — the wheel does nothing to a player
+ // itself, so there is nothing to leave it. Adam, 2026-10-16: "over a
+ // video it doesn't initiate a coast, just a single notch".)
+ if (el.closest('input, textarea, select, [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;
@@ -6653,16 +6657,19 @@ if (INTENT_URI) {
if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); }
};
// ---- the wheel
- // A turn back against the coast catches the page at once and is a plain
- // notch from rest — no flight the other way, since the next couple of
- // notches after a reversal (CALM) don't wind the speed up, so a couple of
- // correcting notches travel a couple of notches; a spin that keeps going
- // winds up again from the third. (A spell of quarter-size "hunting"
- // notches ramping back up was tried here and felt like a stutter and a
- // slow patch; a 700 ms spell without wind-up after a reversal was tried
- // too and a spin begun inside it felt slow — Adam, 2026-10-16.)
- const CALM = 2; // notches after a reversal without wind-up
- let calmLeft = 0; // (lastDir/lastMoveAt: the tick's, above)
+ // A turn back against the coast catches the page at once and is a gentler
+ // push the other way — half a notch's travel, at half the speed — so the
+ // reader who overshot a post settles back onto it instead of flying past
+ // it the other way. The next notch the same way is a plain one, winding
+ // up from whatever speed the half push left, so a spin on after a
+ // reversal gets going again at once. (A spell of quarter-size "hunting"
+ // notches ramping back up over five turns was tried here and felt like a
+ // stutter and a slow patch; a full notch then two without wind-up felt
+ // like an overshoot — Adam, 2026-10-16: "could the first coast in the
+ // opposite direction be smaller/slower than usual? then speed up if we
+ // scroll again in the same direction".)
+ const REVERSE = 0.5; // of a notch: the first push back against a coast
+ // (lastDir/lastMoveAt: the tick's, above)
let smoothUntil = 0; // a trackpad's events were just seen: hands off
window.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return;
@@ -6680,19 +6687,19 @@ if (INTENT_URI) {
// 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
- // notch a small response now, then coast the rest of its travel.
+ // notch a small response now, then coast the rest of its (half) travel.
stop();
- calmLeft = CALM;
- const first = dir * Math.min(16, Math.abs(px * 3 / TAU) * 16);
+ const travel = px * 3 * REVERSE;
+ const first = dir * Math.min(16, Math.abs(travel / TAU) * 16);
window.scrollBy({ top: first, behavior: 'instant' });
lastDir = dir; lastMoveAt = now;
- coast((px * 3 - first) / TAU, TAU);
+ coast((travel - first) / TAU, TAU);
} else {
// A notch into a page already moving pushes harder: the faster it
// goes, the more each turn adds (up to 2.5x), so keeping the wheel
// turning winds the speed up instead of settling at a stroll. From
// rest a notch is the same push as ever.
- const gain = calmLeft-- > 0 ? 1 : 1 + Math.min(1.5, Math.abs(v) / 4);
+ const gain = 1 + Math.min(1.5, Math.abs(v) / 4);
coast(v + (px * 3 * gain) / TAU, TAU);
}
}, { passive: false });
diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js
index 632bbc6..be5ad54 100644
--- a/tools/feed-wheel-reverse-test.js
+++ b/tools/feed-wheel-reverse-test.js
@@ -5,7 +5,7 @@ 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() {
+function fixture(inside = '') {
let now = 100, id = 0;
const handlers = new Map(), frames = new Map(), steps = [];
const window = { scrollY: 5000,
@@ -13,7 +13,8 @@ function fixture() {
scrollBy: ({ top }) => { steps.push(top); window.scrollY += top; },
};
const document = { body: {}, addEventListener() {}, removeEventListener() {} };
- const target = { closest: (selector) => selector.includes('.feed-page') ? target : null,
+ // the target sits in a feed page (or, for a thread case, a thread page), inside the given element
+ const target = { closest: (selector) => selector.split(/,\s*/).some((p) => p === inside || (p === '.feed-page' && inside !== '.thread-page')) ? target : null,
scrollHeight: 100, clientHeight: 100, parentElement: document.body };
runInNewContext(source.slice(start, end), { window, document,
matchMedia: () => ({ matches: false }), performance: { now: () => now },
@@ -46,29 +47,47 @@ for (const dir of [1, -1]) {
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');
settle(f);
- // The turn back is a plain notch from rest: its full travel, no flight the other way.
+ // The turn back is a gentler push than a notch: half a notch's travel, so
+ // a reader who overshot settles back rather than flying the other way.
const back = (before - f.y) * dir;
- assert(back > 280 && back < 330, 'a reverse notch against a coast travels one notch: ' + back);
- // For a moment after a reversal notches don't wind the speed up: a
- // reversal and a quick second notch travel about two notches, not a flight.
- let calm;
+ assert(back > 130 && back < 170, 'a reverse notch against a coast travels half a notch: ' + back);
+ // The next notch the same way is a plain one again, winding up from the
+ // half push's speed: a reversal and a quick second notch travel about a
+ // notch and a half.
+ let pair;
{ f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); // coasting again, the other way
- const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); calm = (f.y - y) * dir;
- assert(calm > 540 && calm < 700, 'a reversal and a quick second notch: about two notches of travel: ' + calm); }
- // From rest, well after, the wind-up is back: the same two quick notches go further.
- { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > calm + 40, 'two quick notches with the wind-up go further: ' + s + ' vs ' + calm); }
- // A spin that keeps going after a reversal winds up again from its third
- // notch: a reversal plus four quick notches goes well past five plain ones.
+ const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); pair = (f.y - y) * dir;
+ assert(pair > 440 && pair < 560, 'a reversal and a quick second notch: about a notch and a half: ' + pair); }
+ // From rest the same two quick notches go further: a full first push, and the wind-up.
+ { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > pair + 100, 'two quick notches from rest go further: ' + s + ' vs ' + pair); }
+ // A spin that keeps going after a reversal winds up from its second
+ // notch: a reversal plus four quick notches goes well past four plain ones.
let spin;
{ f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame();
const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); spin = (f.y - y) * dir;
- assert(spin > 5 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); }
- // ...and the same five quick notches from rest go about as far (the two calm ones cost little).
+ assert(spin > 4 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); }
+ // ...and the same five quick notches from rest go about as far (the half push costs little).
{ f.advance(2500); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); const s = (f.y - y) * dir;
- assert(s < spin * 1.25, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); }
+ assert(s < spin * 1.3, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); }
// From rest a notch either way is a full push.
{ f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); }
- // A turn back against a coast that only just died is a reversal too (caught, no wind-up).
- { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 520 && s < 700, 'reversing right after the coast dies: no wind-up: ' + s); }
+ // A turn back against a coast that only just died is a reversal too: the half push.
+ { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'reversing right after the coast dies is the half push: ' + s); }
+ // Well after the coast died it is a plain notch from rest, whichever way.
+ { f.advance(2500); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a notch the other way from rest is a full push: ' + s); }
}
-console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a plain notch, two calm notches, then the wind-up is back');
+// A notch over a video (or an audio player) in the feed coasts like any
+// other — the wheel does nothing to a player itself — and so does one on a
+// thread page.
+for (const sel of ['video', 'audio', '.thread-page']) {
+ const f = fixture(sel);
+ const y = f.y; f.wheel(1); settle(f);
+ assert(f.y - y > 280 && f.y - y < 330, 'a notch over ' + sel + ' coasts a full push: ' + (f.y - y));
+}
+// ...while a notch in a text box, or in the media pager, is left alone.
+for (const sel of ['textarea', '.media-pager']) {
+ const f = fixture(sel);
+ const y = f.y; f.wheel(1); settle(f);
+ assert.equal(f.y, y, 'a notch in ' + sel + ' is not ours');
+}
+console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a half push that winds up again from the next notch, videos and threads coast');