Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: smooth scroll wheel — continuous positional scrolling with snap-to-nearest

Replace velocity-accumulation wheel behavior with direct positional
scrolling. Wheel events now drive position smoothly across pages;
when idle (120ms debounce), eases to nearest page via easeOutCubic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+92 -102
+92 -102
system/netlify/functions/sotce-net.mjs
··· 6517 6517 let lastDragY = 0; 6518 6518 let lastDragTime = 0; 6519 6519 6520 - // Pending wheel pages — accumulates scroll intent during animations 6521 - // so continuous scrolling chains smoothly across pages 6522 - let pendingWheelPages = 0; // accumulates: +N forward, -N backward 6523 - let fastScrolling = false; // true when chaining transitions rapidly 6520 + // Wheel scroll state — continuous positional scrolling with snap-to-nearest 6521 + let wheelOffset = 0; // accumulated scroll position in pixels 6522 + let isWheelScrolling = false; 6523 + let wheelSnapTimer = null; // debounce timer for snap animation 6524 + let wheelSnapping = false; // true when easing to nearest page 6524 6525 6525 6526 // Minimap/position indicator — shows during scrolling 6526 6527 let minimapOpacity = 0; // 0-1, fades in/out ··· 7602 7603 if (transitionDirection !== 0 && transitionTarget !== null) { 7603 7604 // Ease-out timing: fast departure, gentle arrival 7604 7605 const elapsed = performance.now() - transitionStartTime; 7605 - const duration = fastScrolling ? 160 : (transitionSlow ? 420 : 260); // ms 7606 + const duration = transitionSlow ? 420 : 260; // ms 7606 7607 const t = Math.min(1, elapsed / duration); 7607 7608 const eased = easeOutCubic(t); 7608 7609 transitionProgress = transitionStartProgress + (1 - transitionStartProgress) * eased; ··· 7628 7629 } 7629 7630 prefetchPages(currentPageIndex); 7630 7631 7631 - // If user kept scrolling during the animation, chain 7632 - // to the next page immediately for fluid continuous browsing. 7633 - if (pendingWheelPages !== 0) { 7634 - const dir = Math.sign(pendingWheelPages); 7635 - const nextIdx = currentPageIndex + dir; 7636 - pendingWheelPages -= dir; // Consume one page from queue 7637 - if (nextIdx >= 1 && nextIdx <= loadedFeedCount) { 7638 - goToPage(nextIdx); 7639 - } else { 7640 - pendingWheelPages = 0; // Hit boundary, clear queue 7641 - fastScrolling = false; 7642 - } 7643 - } else { 7644 - fastScrolling = false; 7645 - } 7632 + // Wheel events accumulate in wheelOffset and snap when 7633 + // idle, so transition chaining is no longer needed here. 7646 7634 } 7647 7635 } 7648 7636 7649 - // Apply wheel momentum with smooth inertia 7637 + // Wheel scroll: snap-to-nearest when user stops scrolling 7650 7638 if (isWheelScrolling && transitionDirection === 0) { 7651 - // Apply velocity to position immediately - no lag 7652 - wheelDelta += wheelVelocity; 7653 - dragDelta = wheelDelta; 7639 + if (wheelSnapping) { 7640 + const slideDistance = cardHeight + 40; 7641 + const threshold = slideDistance * 0.4; // 40% → snap to next page 7654 7642 7655 - // Very gentle friction for long momentum carry (additive scrolling) 7656 - const friction = 0.96; // Higher = momentum builds up from rapid flicks 7657 - wheelVelocity *= friction; 7658 - 7659 - // Stop when velocity is negligible 7660 - if (Math.abs(wheelVelocity) < 0.2) { 7661 - wheelVelocity = 0; 7662 - } 7663 - 7664 - // Check if we should transition to next/prev page(s) 7665 - const slideDistance = cardHeight + 40; 7666 - const threshold = slideDistance * 0.25; // Lower threshold for fluid page changes 7667 - 7668 - if (Math.abs(wheelDelta) > threshold) { 7669 - // Calculate how many pages to skip based on accumulated delta 7670 - const direction = Math.sign(wheelDelta); 7671 - const pagesToSkip = Math.floor(Math.abs(wheelDelta) / slideDistance); 7672 - const pagesJump = Math.max(1, pagesToSkip); // At least 1 page 7673 - 7674 - const targetIdx = displayedPageIndex + (direction * pagesJump); 7675 - const clampedTarget = Math.max(1, Math.min(loadedFeedCount, targetIdx)); 7676 - 7677 - console.log("🎡 Wheel scroll:", { 7678 - wheelDelta: wheelDelta.toFixed(1), 7679 - wheelVelocity: wheelVelocity.toFixed(2), 7680 - pagesToSkip: pagesJump, 7681 - from: displayedPageIndex, 7682 - to: clampedTarget 7683 - }); 7684 - 7685 - if (clampedTarget !== displayedPageIndex) { 7686 - // Jump to target page (can skip multiple pages!) 7687 - goToPage(clampedTarget, 0); 7688 - 7689 - // STOP wheel scrolling completely to prevent bouncing 7690 - isWheelScrolling = false; 7691 - wheelDelta = 0; 7692 - wheelVelocity = 0; 7693 - console.log("✅ Page changed, STOPPED all momentum"); 7643 + if (Math.abs(wheelOffset) > threshold) { 7644 + // Past threshold — animate to next page 7645 + const direction = Math.sign(wheelOffset); 7646 + const nextIdx = displayedPageIndex + direction; 7647 + if (nextIdx >= 1 && nextIdx <= loadedFeedCount) { 7648 + const startProgress = Math.min(0.95, Math.abs(wheelOffset) / slideDistance); 7649 + isWheelScrolling = false; 7650 + wheelOffset = 0; 7651 + wheelSnapping = false; 7652 + dragDelta = 0; 7653 + goToPage(nextIdx, startProgress); 7654 + } else { 7655 + // At boundary — rubber-band back 7656 + wheelOffset *= 0.82; 7657 + dragDelta = wheelOffset; 7658 + if (Math.abs(wheelOffset) < 0.5) { 7659 + wheelOffset = 0; 7660 + dragDelta = 0; 7661 + isWheelScrolling = false; 7662 + wheelSnapping = false; 7663 + } 7664 + } 7694 7665 } else { 7695 - // Hit boundary - stop completely 7696 - console.log("🛑 Hit boundary, stopping"); 7697 - wheelVelocity = 0; 7698 - wheelDelta = 0; 7699 - isWheelScrolling = false; 7666 + // Within threshold — ease back to current page 7667 + wheelOffset *= 0.82; 7668 + dragDelta = wheelOffset; 7669 + if (Math.abs(wheelOffset) < 0.5) { 7670 + wheelOffset = 0; 7671 + dragDelta = 0; 7672 + isWheelScrolling = false; 7673 + wheelSnapping = false; 7674 + } 7700 7675 } 7701 - } 7702 - 7703 - // Stop scrolling if velocity died out and we're close to center 7704 - if (wheelVelocity === 0 && Math.abs(wheelDelta) < threshold * 0.5) { 7705 - isWheelScrolling = false; 7706 - // Let snap-back animation take over 7676 + } else { 7677 + // Still actively scrolling — keep dragDelta in sync 7678 + dragDelta = wheelOffset; 7707 7679 } 7708 7680 7709 7681 // Prefetch adjacent pages 7710 - const nextIdx = wheelDelta > 0 ? displayedPageIndex + 1 : displayedPageIndex - 1; 7682 + const nextIdx = wheelOffset > 0 ? displayedPageIndex + 1 : displayedPageIndex - 1; 7711 7683 if (nextIdx >= 1 && nextIdx <= loadedFeedCount && !pageCache.has(nextIdx)) { 7712 7684 fetchPage(nextIdx); 7713 7685 } ··· 7822 7794 7823 7795 // Cancel any pending wheel scroll 7824 7796 isWheelScrolling = false; 7825 - wheelDelta = 0; 7826 - pendingWheelPages = 0; 7827 - fastScrolling = false; 7828 - clearTimeout(wheelIdleTimer); 7797 + wheelOffset = 0; 7798 + wheelSnapping = false; 7799 + clearTimeout(wheelSnapTimer); 7829 7800 7830 7801 canvas.setPointerCapture(e.pointerId); 7831 7802 canvas.style.cursor = "grabbing"; ··· 7913 7884 } 7914 7885 }); 7915 7886 7916 - // Scroll wheel navigation with smooth inertia 7917 - let wheelVelocity = 0; // pixels per frame 7918 - let wheelDelta = 0; 7919 - let wheelIdleTimer = null; 7920 - let isWheelScrolling = false; 7921 - let lastWheelTime = 0; 7922 - 7887 + // Scroll wheel navigation — continuous positional scrolling 7888 + // Wheel events directly drive position; snap-to-nearest on idle. 7923 7889 canvas.addEventListener("wheel", (e) => { 7924 7890 if (!document.body.contains(canvas)) return; 7925 7891 if (isFlipping || showingBack) return; 7926 - // Don't allow new wheel scrolls during page transitions 7927 - if (transitionDirection !== 0 && transitionProgress > 0 && transitionProgress < 1) return; 7892 + if (transitionDirection !== 0) return; 7928 7893 e.preventDefault(); 7929 7894 showMinimap(); 7930 7895 7931 - // Immediate, responsive velocity - no delay 7932 - const sensitivity = 2.0; // Higher = more responsive to wheel input 7933 - wheelVelocity += e.deltaY * sensitivity; 7896 + // Cancel any in-progress snap animation 7897 + wheelSnapping = false; 7934 7898 7935 - // Clamp at boundaries to prevent jank/jutter 7936 - const atStart = displayedPageIndex <= 1 && wheelVelocity < 0; 7937 - const atEnd = displayedPageIndex >= loadedFeedCount && wheelVelocity > 0; 7938 - if (atStart || atEnd) { 7939 - wheelVelocity = 0; 7940 - wheelDelta = 0; 7941 - return; 7899 + // Direct positional scrolling 7900 + const sensitivity = 1.5; 7901 + wheelOffset += e.deltaY * sensitivity; 7902 + 7903 + // Cross page boundaries for continuous multi-page scrolling 7904 + const slideDistance = cardHeight + 40; 7905 + while (wheelOffset > slideDistance && displayedPageIndex < loadedFeedCount) { 7906 + wheelOffset -= slideDistance; 7907 + displayedPageIndex++; 7908 + currentPageIndex = displayedPageIndex; 7909 + const item = pageCache.get(currentPageIndex); 7910 + if (item?.type === "question") { 7911 + updatePath("/q" + (item.questionNumber || currentPageIndex)); 7912 + } else { 7913 + updatePath("/" + (item?.pageNumber || currentPageIndex)); 7914 + } 7915 + prefetchPages(currentPageIndex); 7916 + } 7917 + while (wheelOffset < -slideDistance && displayedPageIndex > 1) { 7918 + wheelOffset += slideDistance; 7919 + displayedPageIndex--; 7920 + currentPageIndex = displayedPageIndex; 7921 + const item = pageCache.get(currentPageIndex); 7922 + if (item?.type === "question") { 7923 + updatePath("/q" + (item.questionNumber || currentPageIndex)); 7924 + } else { 7925 + updatePath("/" + (item?.pageNumber || currentPageIndex)); 7926 + } 7927 + prefetchPages(currentPageIndex); 7942 7928 } 7943 7929 7944 - // Higher cap for faster scrolling 7945 - const maxVelocity = 120; 7946 - wheelVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, wheelVelocity)); 7930 + // Rubber-band at boundaries 7931 + if (displayedPageIndex <= 1 && wheelOffset < 0) wheelOffset = Math.max(wheelOffset, -40); 7932 + if (displayedPageIndex >= loadedFeedCount && wheelOffset > 0) wheelOffset = Math.min(wheelOffset, 40); 7947 7933 7934 + dragDelta = wheelOffset; 7948 7935 isWheelScrolling = true; 7949 - lastWheelTime = performance.now(); 7936 + 7937 + // Debounce: start snap animation after user stops scrolling 7938 + clearTimeout(wheelSnapTimer); 7939 + wheelSnapTimer = setTimeout(() => { wheelSnapping = true; }, 120); 7950 7940 }, { passive: false }); 7951 7941 7952 7942 // Mousedown handler for button press state