From 2cf8a65c7f3cb2351d744ffd1cdd1ac5bcbfe4b1 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 10 Sep 2026 15:36:54 -0700 Subject: [PATCH] =?UTF-8?q?ruler:=20re-arm=20the=20snap=20on=20real=20inpu?= =?UTF-8?q?t=20only=20=E2=80=94=20the=20velocity=20check=20leaked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/ruler/heading-flow.tsx | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx b/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx index 77cbc1e1ff..d353619f4e 100644 --- a/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx +++ b/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx @@ -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]);