Swipes and a reader that will not scroll are reported (#384)

A swipe that only bounces sends no request and throws nothing, so
#383's stop at the 50th item left nothing in the log or the traces, and
the reader that would not scroll in the same report was never found.

The page now reports each swipe in the reader (next, prev, list, end,
short; a tap is not one) with where the reader stood, under the tap's
trace, and scroll_stuck when a drag up or down with room to move the
reader left it where it was. That one is watched by where the finger
was, not by what it landed on, so a layer lying over the reader is
caught, and its state names what is under the finger, what is inert,
the dialog, the reader's transform, a #dpeek left behind, and how long
since the app came back. ipx logs swipe as info and scroll_stuck as a
warning, in a new free-text state field, and the dashboard counts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 20:46:31 +00:00
parent 385589ff77
commit 5c1970e8ee
7 changed files with 94 additions and 7 deletions

View File

@@ -159,12 +159,57 @@ document.addEventListener('touchend', () => {
if(g.dy >= PULL) refreshFeed();
return;
}
// Across, mostly sideways, and not a slow drag while selecting text.
const i = S.entries.findIndex(x => entryKey(x) === S.sel);
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
// What came of it, where the reader stood (#384): a swipe that only bounces sends no request,
// and #383's stop at the 50th item was seen by nobody but the person swiping.
const swiped = (what: string) => report('swipe', {msg: what, ms: Date.now() - g.t, feed: S.feed, filter: S.filter,
state: `${i + 1}/${S.entries.length} of ${S.total}${hasMore() ? ', more to load' : ''}`});
// Across, mostly sideways, and not a slow drag while selecting text. A tap is not a swipe at all.
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800){
if(Math.abs(g.dx) > 30) swiped('short');
return slide(0);
}
if(g.dx < 0 && i >= S.entries.length - 1){ swiped('end'); return slide(0); }
if(g.dx < 0){ swiped('next'); return slideOn(g.dx, () => stepEntry(1)); }
if(i > 0){ swiped('prev'); return slideOn(g.dx, () => stepEntry(-1)); }
swiped('list');
slideOn(g.dx, () => showDetail(null));
});
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });
/// A reader that will not scroll (#384): a drag up or down over an item with room to move that
/// way, after which it has not moved. Watched by where the finger is, not by what it landed on,
/// so something lying over the reader is caught too, and named. Reported with what could hold it,
/// since the cause of the one in #383 was never found.
let drag: {x: number, y: number, top: number, on: string} | null = null, shownAt = Date.now();
document.addEventListener('visibilitychange', () => { if(!document.hidden) shownAt = Date.now(); });
const named = (el: Element | null) => !el ? 'nothing'
: el.id ? '#' + el.id : el.tagName.toLowerCase() + (typeof el.className === 'string' && el.className ? '.' + el.className.split(' ')[0] : '');
/// A drag of dy down (dx across) that left the reader at the same scrollTop, from `before`, with
/// `room` to scroll in all. Only one with somewhere to go: up with more below, down with more above.
const stuckDrag = (dy: number, dx: number, before: number, after: number, room: number) =>
Math.abs(dy) >= 40 && Math.abs(dy) >= 2 * Math.abs(dx) && room >= 2 && after === before
&& (dy < 0 ? before < room - 1 : before > 0);
document.addEventListener('touchstart', ev => {
const d = $('#detail'), t = ev.touches[0];
drag = null;
if(ev.touches.length !== 1 || !d || !S.sel) return;
const r = d.getBoundingClientRect();
if(t.clientX < r.left || t.clientX > r.right || t.clientY < r.top || t.clientY > r.bottom) return;
drag = {x: t.clientX, y: t.clientY, top: d.scrollTop, on: named(ev.target as Element)};
}, {passive: true});
document.addEventListener('touchend', ev => {
const d = $('#detail'), t = ev.changedTouches[0], g = drag;
drag = null;
if(!g || !d || !t) return;
const dy = t.clientY - g.y, room = d.scrollHeight - d.clientHeight;
if(!stuckDrag(dy, t.clientX - g.x, g.top, d.scrollTop, room)) return;
const inert = ([...document.body.children] as HTMLElement[]).filter(el => el.inert).map(named).join(' ');
report('scroll_stuck', {feed: S.feed, state: [
`top ${g.top} of ${room}`, `drag ${Math.round(dy)}px`, `started on ${g.on}`,
`under the finger ${named(document.elementFromPoint(t.clientX, t.clientY))}`,
`inert ${inert || 'none'}`, `dialog ${$('#modal')?.classList.contains('on') ? 'open' : 'closed'}`,
`transform ${d.style.transform || 'none'}`, `peek ${$('#dpeek') ? 'left behind' : 'gone'}`,
`overflow ${getComputedStyle(d).overflowY}`, `reading ${document.body.classList.contains('reading')}`,
`shown ${Math.round((Date.now() - shownAt) / 1000)}s ago`, `${S.entries.length} loaded`].join(', ')});
});