ruler: re-arm the snap on real input only — the velocity check leaked

The arming invariant had a hole: 'snapping' clears early (the moment no
word is mid-flip, which happens mid-animation), so the velocity-based
re-arm read the snap's own animation tail as organic scrolling and let
the ping-pong chain re-arm itself. Velocity can't distinguish a snap's
motion from the user's.

Re-arming now lives exclusively on unambiguous input events — wheel,
touchmove, keydown, and rail thumb drags. Programmatic scrolls produce
none of these, so after a heading jump or a snap the system stays
disarmed until the user actually acts.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
This commit is contained in:
Tanish Manku committed 2026-09-10 15:36:54 -07:00
1 parent 937bcefa68
commit 2cf8a65c7f
1 file changed
+19 -10
@@ -512,16 +512,12 @@ export default function HeadingFlow({
const midIdx = phases.findIndex(
(ph) => ph.flipP > 0.04 && ph.flipP < 0.96,
);
// Re-arm only on organic scroll: meaningful velocity while no
// programmatic animation (jump or snap) is driving the page.
if (
!st.armed &&
!st.snapping &&
!st.jumping &&
Math.abs(st.vel) > 40
) {
st.armed = true;
}
// Re-arming lives on real input events (see the listeners below) —
// NEVER on velocity: `snapping` clears early once no word is
// mid-flip, while the snap's scroll is still moving, so a velocity
// check mistakes the snap's own tail for user scrolling and lets
// snap chains re-arm themselves. Rail thumb drags count as intent.
if (rulerZoom.dragging) st.armed = true;
if (st.snapping) {
if (midIdx < 0) st.snapping = false;
} else if (
@@ -566,8 +562,21 @@ export default function HeadingFlow({
}
};
// Organic input is the ONLY thing that re-arms the snap. Wheel, touch,
// and keyboard are unambiguous user intent; every programmatic scroll
// (jump, snap) produces none of these.
const rearm = () => {
fs.current.armed = true;
};
window.addEventListener("wheel", rearm, { passive: true });
window.addEventListener("touchmove", rearm, { passive: true });
window.addEventListener("keydown", rearm);
gsap.ticker.add(tick);
return () => {
window.removeEventListener("wheel", rearm);
window.removeEventListener("touchmove", rearm);
window.removeEventListener("keydown", rearm);
gsap.ticker.remove(tick);
};
}, [mode, metrics, sections, geom, side, layoutVersion]);