@brandocms/jupiter 5.0.0-beta.12 → 5.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,26 @@
1
1
  import { animate, motionValue, frame, cancelFrame } from 'motion'
2
2
  import _defaultsDeep from 'lodash.defaultsdeep'
3
3
  import Dom from '../Dom'
4
+ import prefersReducedMotion from '../../utils/prefersReducedMotion'
5
+
6
+ /**
7
+ * Modulo that centers the result around zero: (-base/2, base/2]
8
+ * Useful for finding shortest distance on a cycle.
9
+ */
10
+ function symmetricMod(value, base) {
11
+ let m = value % base
12
+ if (Math.abs(m) > base / 2) {
13
+ m = m > 0 ? m - base : m + base
14
+ }
15
+ return m
16
+ }
17
+
18
+ // Named constants (M5)
19
+ const CLONE_BUFFER_MULTIPLIER = 2.5
20
+ const MIN_CRAWL_SPEED = 0.001
21
+ const PING_PONG_PAUSE_MS = 200
22
+ const VELOCITY_WINDOW_MS = 150
23
+ const SPEED_RAMP_DURATION = 2
4
24
 
5
25
  /**
6
26
  * Looper Module
@@ -24,12 +44,13 @@ const DEFAULT_OPTIONS = {
24
44
  loop: true, // Infinite looping (false for linear scrolling)
25
45
  draggable: true, // Enable drag interaction
26
46
  endAlignment: 'right', // For non-looping: 'right' = last item at viewport right edge, 'start' = last item at viewport left edge
27
- minimumMovement: 3, // Pixels - movement below this is treated as click, above as drag
47
+ minimumMovement: 3, // Pixels for mouse - movement below this is treated as click, above as drag
48
+ touchMinimumMovement: 10, // Pixels for touch - higher threshold for finger imprecision
28
49
 
29
50
  // Inertia/throw configuration (when dragging and releasing)
30
51
  throwResistance: 325, // Time constant for deceleration (lower = more resistance/faster stop, higher = less resistance/longer glide)
31
52
  throwPower: 0.8, // Deceleration curve (0-1, higher = more gradual slowdown)
32
- throwVelocityMultiplier: 1.0, // Scale velocity for all throws (0.5 = half speed, 2.0 = double)
53
+ throwVelocityMultiplier: 0.8, // Scale velocity for all throws (0.5 = half speed, 2.0 = double)
33
54
  snapVelocityMultiplier: 0.8, // Additional scaling for snapped loopers (stacks with throwVelocityMultiplier)
34
55
 
35
56
  // Snap animation configuration (when snap: true)
@@ -63,6 +84,7 @@ function horizontalLoop(app, items, config) {
63
84
 
64
85
  const shouldLoop = config.loop !== false
65
86
  const shouldDrag = config.draggable !== false
87
+ const reducedMotion = prefersReducedMotion() && app?.opts?.respectReducedMotion
66
88
 
67
89
  // Container setup
68
90
  const center = config.center
@@ -92,6 +114,7 @@ function horizontalLoop(app, items, config) {
92
114
  let containerWidth = 0 // Cache container width to avoid layout reads on every frame
93
115
  let itemWrapOffsets = [] // Cache current wrap offset for each item
94
116
  let isCloneCache = [] // Cache which items are clones (avoid hasAttribute checks)
117
+ let itemsHaveTransforms = null // Cache whether any original items have CSS transforms (H5)
95
118
 
96
119
  // Drag state and cleanup handlers
97
120
  let dragState = {}
@@ -102,6 +125,12 @@ function horizontalLoop(app, items, config) {
102
125
  let navAnimation = null // Track navigation animation (next/previous/toIndex)
103
126
  let positionUnsubscribe = null // Track position listener for cleanup
104
127
  let renderUnsubscribe = null // Track frame.render loop for cleanup
128
+ let indexUnsubscribe = null // Track index display position listener (C3)
129
+ let pingPongTimeout = null // Track ping-pong pause timeout (C4)
130
+ let resumeCrawlGeneration = 0 // Generation counter for resumeCrawl race condition (C7)
131
+ let hoverCleanup = null // Track hover effects cleanup function (C1/C9)
132
+ let startPingPongCrawl = null // Closure-scoped ping-pong starter (M2)
133
+ let indexSetByNav = false // True when curIndex was set by toIndex, cleared on drag
105
134
 
106
135
  // Scroll direction tracking for wrap logic
107
136
  let scrollDirection = 0 // -1 = backward, 0 = neutral, 1 = forward
@@ -111,6 +140,9 @@ function horizontalLoop(app, items, config) {
111
140
  let indexElements = []
112
141
  let countElements = []
113
142
 
143
+ // Cache track element reference (M3)
144
+ const trackElement = items[0].parentElement
145
+
114
146
  /**
115
147
  * Measure total width of all items as currently laid out
116
148
  * @returns {number} Total width in pixels
@@ -165,7 +197,7 @@ function horizontalLoop(app, items, config) {
165
197
 
166
198
  // Use 2.5x container width to ensure plenty of buffer for wrapping
167
199
  // This prevents items from visibly moving to the back before they're off-screen
168
- const minRequiredWidth = containerWidth * 2.5 + maxItemWidth
200
+ const minRequiredWidth = containerWidth * CLONE_BUFFER_MULTIPLIER + maxItemWidth
169
201
 
170
202
  // Store original count to prevent exponential growth
171
203
  const originalItemCount = items.length
@@ -228,17 +260,29 @@ function horizontalLoop(app, items, config) {
228
260
  const rect = el.getBoundingClientRect()
229
261
  widths[i] = rect.width
230
262
 
231
- // Calculate xPercent based on current transform (expensive, only for originals)
232
- const computedStyle = window.getComputedStyle(el)
233
- const transform = computedStyle.transform
234
- let currentX = 0
235
-
236
- if (transform && transform !== 'none') {
237
- const matrix = new DOMMatrix(transform)
238
- currentX = matrix.m41
263
+ // Check transforms only if items have them (H5)
264
+ // Check once at first call, cache for subsequent calls
265
+ if (itemsHaveTransforms === null) {
266
+ itemsHaveTransforms = items.slice(0, originalItemCount || items.length).some(item => {
267
+ const t = window.getComputedStyle(item).transform
268
+ return t && t !== 'none'
269
+ })
239
270
  }
240
271
 
241
- xPercents[i] = (currentX / widths[i]) * 100
272
+ if (itemsHaveTransforms) {
273
+ const computedStyle = window.getComputedStyle(el)
274
+ const transform = computedStyle.transform
275
+ let currentX = 0
276
+
277
+ if (transform && transform !== 'none') {
278
+ const matrix = new DOMMatrix(transform)
279
+ currentX = matrix.m41
280
+ }
281
+
282
+ xPercents[i] = (currentX / widths[i]) * 100
283
+ } else {
284
+ xPercents[i] = 0
285
+ }
242
286
  }
243
287
  })
244
288
 
@@ -291,65 +335,38 @@ function horizontalLoop(app, items, config) {
291
335
  }
292
336
  }
293
337
 
338
+ /**
339
+ * Calculate snap position for an item (M4 - shared between loop and non-loop paths)
340
+ */
341
+ function calculateSnapPos(item, i) {
342
+ const curX = (xPercents[i] / 100) * widths[i]
343
+
344
+ if (config.peek) {
345
+ const itemRightEdge = item.offsetLeft + curX + widths[i] - startX
346
+ const viewportCenter = containerWidth / 2
347
+ return itemRightEdge + gap / 2 - viewportCenter
348
+ } else if (config.centerSlide) {
349
+ const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
350
+ const viewportCenter = containerWidth / 2
351
+ return itemCenter - viewportCenter
352
+ } else {
353
+ return item.offsetLeft + curX - startX
354
+ }
355
+ }
356
+
294
357
  /**
295
358
  * Calculate time positions for snapping
296
359
  * These represent when each item hits the "start" position
297
360
  */
298
361
  function populateSnapTimes() {
299
- if (!shouldLoop) {
300
- // For non-looping, calculate based on actual item positions (pixels)
301
- // This ensures snap and navigation work correctly
302
- items.forEach((item, i) => {
303
- const curX = (xPercents[i] / 100) * widths[i]
304
- let snapPos
305
-
306
- if (config.peek) {
307
- // Peek mode: viewport center at the gap AFTER this item
308
- // This shows: half(i) | gap | full(i+1) | gap | full(i+2) | gap | half(i+3)
309
- const itemRightEdge = item.offsetLeft + curX + widths[i] - startX
310
- const viewportCenter = containerWidth / 2
311
- snapPos = itemRightEdge + gap / 2 - viewportCenter
312
- } else if (config.centerSlide) {
313
- // Center mode: item's center at viewport's center
314
- const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
315
- const viewportCenter = containerWidth / 2
316
- snapPos = itemCenter - viewportCenter
317
- } else {
318
- // Normal mode: item's left edge at viewport's left edge
319
- snapPos = item.offsetLeft + curX - startX
320
- }
321
-
322
- times[i] = snapPos / pixelsPerSecond
323
- })
324
- return
325
- }
326
-
327
- // For looping, calculate based on item positions including gaps
328
362
  items.forEach((item, i) => {
329
- const curX = (xPercents[i] / 100) * widths[i]
330
- let snapPos
331
-
332
- if (config.peek) {
333
- // Peek mode: viewport center at the gap AFTER this item
334
- const itemRightEdge = item.offsetLeft + curX + widths[i] - startX
335
- const viewportCenter = containerWidth / 2
336
- snapPos = itemRightEdge + gap / 2 - viewportCenter
337
- } else if (config.centerSlide) {
338
- // Center mode: item's center at viewport's center
339
- const itemCenter = item.offsetLeft + curX + widths[i] / 2 - startX
340
- const viewportCenter = containerWidth / 2
341
- snapPos = itemCenter - viewportCenter
342
- } else {
343
- // Normal mode: item's left edge at viewport's left edge
344
- snapPos = item.offsetLeft + curX - startX
345
- }
346
-
347
- times[i] = snapPos / pixelsPerSecond
363
+ times[i] = calculateSnapPos(item, i) / pixelsPerSecond
348
364
  })
349
365
 
350
- // Adjust for container padding if present
351
- const itemsContainer = items[0].parentNode
352
- const containerPaddingLeft = parseFloat(getComputedStyle(itemsContainer).paddingLeft) || 0
366
+ if (!shouldLoop) return
367
+
368
+ // Adjust for container padding if present (looping only)
369
+ const containerPaddingLeft = parseFloat(getComputedStyle(trackElement).paddingLeft) || 0
353
370
 
354
371
  if (containerPaddingLeft > 0) {
355
372
  const paddingTime = containerPaddingLeft / pixelsPerSecond
@@ -365,15 +382,23 @@ function horizontalLoop(app, items, config) {
365
382
  function updateItemPositions(rawPos) {
366
383
  if (!shouldLoop) return
367
384
 
368
- // Initialize wrap offsets cache if needed
369
- if (itemWrapOffsets.length === 0) {
370
- itemWrapOffsets = new Array(items.length).fill(0)
371
- }
372
-
373
385
  // Items wrap by the full cycle distance (totalWidth) to maintain relative positions
374
386
  // This keeps all items within viewing distance as position grows/shrinks
375
387
  const cycleDistance = totalWidth
376
388
 
389
+ // Initialize wrap offsets - calculate correct cycle directly (not incrementally)
390
+ // so items land in the right place even when position is many cycles away
391
+ if (itemWrapOffsets.length === 0) {
392
+ itemWrapOffsets = new Array(items.length)
393
+ for (let i = 0; i < items.length; i++) {
394
+ const distance = offsetLefts[i] - rawPos
395
+ const offset = Math.round(distance / -cycleDistance) * cycleDistance
396
+ itemWrapOffsets[i] = offset
397
+ items[i].style.transform = offset !== 0 ? `translateX(${offset}px)` : 'none'
398
+ }
399
+ return
400
+ }
401
+
377
402
  // Wrap threshold: when an item is more than half a cycle from view, wrap it
378
403
  const wrapThreshold = cycleDistance / 2
379
404
 
@@ -414,28 +439,26 @@ function horizontalLoop(app, items, config) {
414
439
  animation.pause()
415
440
  }
416
441
 
417
- if (deep && shouldLoop) {
418
- // DEEP REFRESH: Reset everything for new dimensions
419
- // Clear all item wrap offsets and transforms
420
- for (let i = 0; i < items.length; i++) {
421
- items[i].style.transform = 'none'
422
- if (itemWrapOffsets[i] !== undefined) {
423
- itemWrapOffsets[i] = 0
424
- }
442
+ if (deep) {
443
+ // Save pre-resize measurements for proportional position restore
444
+ const oldOriginalItemsWidth = originalItemsWidth
445
+ const oldMaxScrollPosition = maxScrollPosition
446
+ const oldPosition = position.get()
447
+
448
+ if (shouldLoop) {
449
+ // DEEP REFRESH: Reset wrap offset tracking (DOM transforms written by updateItemPositions init)
450
+ itemWrapOffsets = []
425
451
  }
426
- itemWrapOffsets = []
427
- }
428
452
 
429
- // Remeasure everything
430
- populateWidths()
453
+ // Remeasure everything
454
+ populateWidths()
431
455
 
432
- if (deep) {
433
456
  // Check if we need to replicate more items
434
457
  const currentContainerWidth = container.offsetWidth
435
458
  const currentTotalWidth = getTotalWidthOfItems()
436
459
 
437
- // Use same 2.5x buffer as replication logic
438
- if (shouldLoop && currentTotalWidth < currentContainerWidth * 2.5) {
460
+ // Use same buffer multiplier as replication logic
461
+ if (shouldLoop && currentTotalWidth < currentContainerWidth * CLONE_BUFFER_MULTIPLIER) {
439
462
  replicateItemsIfNeeded()
440
463
  // Re-cache clone status for any new items
441
464
  isCloneCache = items.map((item, i) => i >= originalItemCount)
@@ -444,20 +467,33 @@ function horizontalLoop(app, items, config) {
444
467
 
445
468
  populateSnapTimes()
446
469
 
447
- // Reset position to start at first clone (like initial state)
448
- if (shouldLoop && !config.centerSlide) {
449
- position.set(originalItemsWidth)
450
- lastPositionForDirection = originalItemsWidth
451
- items[0].parentElement.style.transform = `translateX(${-originalItemsWidth}px)`
452
- } else if (shouldLoop && config.centerSlide) {
453
- // For center mode, go to middle slide
454
- const middleIndex = Math.floor(originalItemCount / 2)
455
- const targetTime = times[middleIndex]
456
- const initialPos = targetTime * pixelsPerSecond
457
- position.set(initialPos)
458
- lastPositionForDirection = initialPos
459
- items[0].parentElement.style.transform = `translateX(${-initialPos}px)`
460
- curIndex = middleIndex
470
+ // Restore position proportionally to preserve scroll progress across resize
471
+ let restoredPos
472
+ if (shouldLoop) {
473
+ const ratio = oldOriginalItemsWidth > 0
474
+ ? oldPosition / oldOriginalItemsWidth
475
+ : 1
476
+ restoredPos = ratio * originalItemsWidth
477
+ } else {
478
+ const ratio = oldMaxScrollPosition > 0
479
+ ? oldPosition / oldMaxScrollPosition
480
+ : 0
481
+ restoredPos = ratio * maxScrollPosition
482
+ }
483
+
484
+ position.set(restoredPos)
485
+ lastPositionForDirection = restoredPos
486
+
487
+ // Apply position and item wraps synchronously to avoid flash
488
+ // (frame.render is async so we must write DOM directly here)
489
+ trackElement.style.transform = `translateX(${-restoredPos}px)`
490
+ updateItemPositions(restoredPos)
491
+
492
+ // If snap is enabled, settle to nearest snap point
493
+ if (config.snap && !config.crawl) {
494
+ const snapPos = findNearestSnapPoint(restoredPos)
495
+ const clampedPos = shouldLoop ? snapPos : Math.max(0, Math.min(snapPos, maxScrollPosition))
496
+ animate(position, clampedPos, { duration: 0.3, ease: 'easeOut' })
461
497
  }
462
498
 
463
499
  // Recreate animation with new measurements
@@ -500,6 +536,7 @@ function horizontalLoop(app, items, config) {
500
536
  updateIndexDisplay()
501
537
  } else {
502
538
  // Light refresh - just update measurements
539
+ populateWidths()
503
540
  populateSnapTimes()
504
541
  // Update positions based on current scroll
505
542
  updateItemPositions(position.get())
@@ -562,6 +599,14 @@ function horizontalLoop(app, items, config) {
562
599
  * Initialize the loop animation
563
600
  */
564
601
  function init() {
602
+ // Inject CSS for drag pointer-events (H3) - once per page
603
+ if (!document.getElementById('looper-drag-style')) {
604
+ const style = document.createElement('style')
605
+ style.id = 'looper-drag-style'
606
+ style.textContent = '.looper-dragging [data-looper-item] { pointer-events: none; }'
607
+ document.head.appendChild(style)
608
+ }
609
+
565
610
  // Store original item count BEFORE replication
566
611
  originalItemCount = items.length
567
612
 
@@ -579,27 +624,25 @@ function horizontalLoop(app, items, config) {
579
624
  // whose individual translateX positions fall outside the container's bounds,
580
625
  // even when they are visually positioned within the viewport.
581
626
  // Apply overflow-x: clip on a parent element instead.
582
- const itemsContainer = items[0].parentElement
583
- const containerOverflow = getComputedStyle(itemsContainer).overflowX
627
+ const containerOverflow = getComputedStyle(trackElement).overflowX
584
628
  if (containerOverflow === 'clip') {
585
629
  console.warn(
586
- `[Looper] ⚠️ [data-looper] has overflow-x: clip which will hide looped items. Apply overflow-x: clip on a parent wrapper element instead.`,
587
- itemsContainer
630
+ `[Looper] [data-looper] has overflow-x: clip which will hide looped items. Apply overflow-x: clip on a parent wrapper element instead.`,
631
+ trackElement
588
632
  )
589
633
  }
590
634
 
591
635
  // Set initial container position
592
- const containerElement = items[0].parentElement
593
- containerElement.style.willChange = 'transform'
636
+ trackElement.style.willChange = 'transform'
594
637
 
595
638
  // For looping (non-center mode): start viewing first CLONE, not originals
596
639
  // This positions originals OFF-SCREEN LEFT so backward scroll reveals them smoothly
597
640
  if (shouldLoop && !config.centerSlide) {
598
641
  position.set(originalItemsWidth)
599
642
  lastPositionForDirection = originalItemsWidth
600
- containerElement.style.transform = `translateX(${-originalItemsWidth}px)`
643
+ trackElement.style.transform = `translateX(${-originalItemsWidth}px)`
601
644
  } else {
602
- containerElement.style.transform = 'translateX(0px)'
645
+ trackElement.style.transform = 'translateX(0px)'
603
646
  }
604
647
 
605
648
  // Set up RAF loop to update container position and wrap items
@@ -608,8 +651,6 @@ function horizontalLoop(app, items, config) {
608
651
  function startRenderLoop() {
609
652
  if (renderUnsubscribe) return // Already running
610
653
 
611
- const containerElement = items[0].parentElement
612
-
613
654
  // Track scroll direction for wrap logic
614
655
  const positionUnsubscribe = position.on('change', latest => {
615
656
  const delta = latest - lastPositionForDirection
@@ -622,7 +663,7 @@ function horizontalLoop(app, items, config) {
622
663
  renderUnsubscribe = frame.render(() => {
623
664
  // Use RAW position (no bounded) for container
624
665
  const currentPos = position.get()
625
- containerElement.style.transform = `translateX(${-currentPos}px)`
666
+ trackElement.style.transform = `translateX(${-currentPos}px)`
626
667
 
627
668
  // Wrap items based on raw position
628
669
  updateItemPositions(currentPos)
@@ -637,26 +678,26 @@ function horizontalLoop(app, items, config) {
637
678
  startRenderLoop()
638
679
  } else {
639
680
  // Non-looping: simple position listener to update container transform
640
- const containerElement = items[0].parentElement
641
681
  positionUnsubscribe = position.on('change', latest => {
642
- containerElement.style.transform = `translateX(${-latest}px)`
682
+ trackElement.style.transform = `translateX(${-latest}px)`
643
683
  })
644
684
  }
645
685
 
646
686
  // Create animation by animating the position motionValue
647
- if (shouldLoop && config.crawl) {
687
+ // Skip crawl entirely when reduced motion is preferred (H4)
688
+ if (shouldLoop && config.crawl && !reducedMotion) {
648
689
  const duration = totalWidth / pixelsPerSecond
649
690
 
650
691
  // Create initial animation (paused)
651
692
  animation = startLoopAnimation()
652
693
  animation.pause()
653
- } else if (!shouldLoop && config.crawl) {
694
+ } else if (!shouldLoop && config.crawl && !reducedMotion) {
654
695
  // Non-looping: ping-pong animation (crawl to end, reverse to start)
655
696
  // Use maxScrollPosition (last item at right edge) instead of totalWidth
656
697
  const duration = maxScrollPosition / pixelsPerSecond
657
698
 
658
699
  // Create a ping-pong crawl animation
659
- function startPingPongCrawl(fromStart = true) {
700
+ startPingPongCrawl = function pingPongCrawl(fromStart = true) {
660
701
  const currentPos = position.get()
661
702
  const target = fromStart ? maxScrollPosition : 0
662
703
  const remainingDist = Math.abs(target - currentPos)
@@ -672,17 +713,14 @@ function horizontalLoop(app, items, config) {
672
713
  // When reaching the end, reverse direction
673
714
  animation.then(() => {
674
715
  // Brief pause at boundary for visual clarity
675
- setTimeout(() => {
716
+ pingPongTimeout = setTimeout(() => {
676
717
  if (animation && animation.speed !== 0) {
677
- startPingPongCrawl(!fromStart)
718
+ pingPongCrawl(!fromStart)
678
719
  }
679
- }, 200) // Slightly longer pause for smooth reversal
720
+ }, PING_PONG_PAUSE_MS)
680
721
  })
681
722
  }
682
723
 
683
- // Store the ping-pong starter for later use
684
- config.startPingPongCrawl = startPingPongCrawl
685
-
686
724
  // Create initial animation (starts paused)
687
725
  animation = animate(position, maxScrollPosition, {
688
726
  duration,
@@ -696,8 +734,10 @@ function horizontalLoop(app, items, config) {
696
734
  setupDrag()
697
735
  }
698
736
 
699
- // Setup hover effects
700
- setupHoverEffects()
737
+ // Setup hover effects (skip in reduced motion - H4)
738
+ if (!reducedMotion) {
739
+ setupHoverEffects()
740
+ }
701
741
 
702
742
  // Setup slide index/count display elements
703
743
  if (config.wrapper) {
@@ -731,7 +771,7 @@ function horizontalLoop(app, items, config) {
731
771
  }
732
772
 
733
773
  // Update index display whenever position changes (closestIndex normalizes internally)
734
- position.on('change', updateIndexOnChange)
774
+ indexUnsubscribe = position.on('change', updateIndexOnChange)
735
775
  }
736
776
  }
737
777
 
@@ -777,10 +817,23 @@ function horizontalLoop(app, items, config) {
777
817
  function setupDrag() {
778
818
  // isDragging is now module-level so wrap detection can see it
779
819
  let startX = 0
820
+ let startY = 0
780
821
  let startPosition = 0
781
822
  let velocityTracker = [] // Track recent movements for velocity calculation
782
823
  let hasDragged = false // Did movement exceed minimumMovement threshold?
783
- let totalMovement = 0 // Total pixels moved (for click vs drag detection)
824
+ let axisDecided = false // Has the drag axis been determined? (horizontal vs vertical)
825
+ let stoppedAnimation = false // Was an animation stopped by this pointer down? (tap-to-stop)
826
+ let resumeTimeout = null // Delayed crawl resume after tap-to-stop
827
+ let activeMinimumMovement = config.minimumMovement // Adjusted per pointer type (touch vs mouse)
828
+
829
+ /**
830
+ * Capture-phase click handler that prevents link navigation after a drag.
831
+ * Added once per drag and auto-removes via { once: true }.
832
+ */
833
+ function swallowClick(e) {
834
+ e.preventDefault()
835
+ e.stopPropagation()
836
+ }
784
837
 
785
838
  /**
786
839
  * Calculate velocity from recent pointer movements
@@ -790,8 +843,8 @@ function horizontalLoop(app, items, config) {
790
843
  function getVelocity() {
791
844
  if (velocityTracker.length < 2) return 0
792
845
 
793
- // Use last 5 movements for smoothing
794
- const recent = velocityTracker.slice(-5)
846
+ // Use last 6 movements for smoothing
847
+ const recent = velocityTracker.slice(-6)
795
848
  let totalVelocity = 0
796
849
  let totalWeight = 0
797
850
 
@@ -821,17 +874,35 @@ function horizontalLoop(app, items, config) {
821
874
  if (e.button !== undefined && e.button !== 0) return
822
875
 
823
876
  isDragging = true
877
+ indexSetByNav = false
824
878
  startX = e.clientX
879
+ startY = e.clientY
825
880
  startPosition = position.get()
826
- velocityTracker = [{ x: e.clientX, time: Date.now() }]
881
+ velocityTracker = [{ x: e.clientX, time: e.timeStamp }]
827
882
  hasDragged = false // Reset - will be set true if movement exceeds threshold
828
- totalMovement = 0
883
+ axisDecided = false // Reset - will be decided on first significant movement
884
+
885
+ // Use higher threshold for touch to prevent accidental drags from finger imprecision
886
+ activeMinimumMovement = e.pointerType === 'touch'
887
+ ? config.touchMinimumMovement
888
+ : config.minimumMovement
829
889
 
830
890
  // Stop autoplay on user interaction
831
891
  if (loopController && loopController.stopAutoplay) {
832
892
  loopController.stopAutoplay()
833
893
  }
834
894
 
895
+ // Cancel any pending resume from a previous tap-to-stop
896
+ clearTimeout(resumeTimeout)
897
+
898
+ // Detect if we're stopping a running animation (tap-to-stop)
899
+ stoppedAnimation = !!(
900
+ inertiaAnimation ||
901
+ snapAnimation ||
902
+ (animation && animation.speed !== 0) ||
903
+ speedRampAnimation
904
+ )
905
+
835
906
  // Stop any ongoing animations
836
907
  if (inertiaAnimation) {
837
908
  inertiaAnimation.stop()
@@ -850,14 +921,20 @@ function horizontalLoop(app, items, config) {
850
921
  speedRampAnimation = null
851
922
  }
852
923
 
853
- // Prevent default to stop native drag behavior on links/images
854
- // We'll manually trigger click in onPointerUp if it wasn't a real drag
855
- e.preventDefault()
924
+ // For mouse: prevent default to stop native drag behavior on links/images
925
+ // For touch: don't preventDefault here we need the browser to be able to scroll
926
+ // if the gesture turns out to be vertical. CSS touch-action: pan-y handles horizontal.
927
+ if (e.pointerType === 'mouse') {
928
+ e.preventDefault()
929
+ }
856
930
 
857
- // Add move/up listeners to window for better tracking
858
- window.addEventListener('pointermove', onPointerMove, { passive: false })
859
- window.addEventListener('pointerup', onPointerUp)
860
- window.addEventListener('pointercancel', onPointerUp)
931
+ // Capture pointer to prevent events being lost when finger moves outside container
932
+ try { container.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
933
+
934
+ // Add move/up listeners to container (pointer capture routes events here)
935
+ container.addEventListener('pointermove', onPointerMove, { passive: false })
936
+ container.addEventListener('pointerup', onPointerUp)
937
+ container.addEventListener('pointercancel', onPointerUp)
861
938
  }
862
939
 
863
940
  /**
@@ -867,32 +944,49 @@ function horizontalLoop(app, items, config) {
867
944
  if (!isDragging) return
868
945
 
869
946
  const currentX = e.clientX
870
- const currentTime = Date.now()
871
-
872
- // Track total movement for click vs drag detection
873
- const movementDelta = Math.abs(currentX - startX)
947
+ const currentTime = e.timeStamp
948
+
949
+ // Axis locking: when movement first exceeds threshold, check if primarily
950
+ // horizontal or vertical. If vertical, abort drag and let the browser scroll.
951
+ if (!axisDecided) {
952
+ const deltaX = Math.abs(currentX - startX)
953
+ const deltaY = Math.abs(e.clientY - startY)
954
+ const maxDelta = Math.max(deltaX, deltaY)
955
+
956
+ // Wait until enough movement to decide
957
+ if (maxDelta < activeMinimumMovement) return
958
+
959
+ axisDecided = true
960
+
961
+ // If clearly vertical, abort — release pointer and let browser scroll.
962
+ // Bias toward carousel interaction: vertical must be 1.2x horizontal to abort.
963
+ if (deltaY > deltaX * 1.2) {
964
+ isDragging = false
965
+ try { container.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
966
+ container.removeEventListener('pointermove', onPointerMove)
967
+ container.removeEventListener('pointerup', onPointerUp)
968
+ container.removeEventListener('pointercancel', onPointerUp)
969
+ // Resume crawl if we stopped it on pointerdown
970
+ if (stoppedAnimation && config.crawl) {
971
+ resumeCrawl()
972
+ }
973
+ return
974
+ }
874
975
 
875
- // Check if this is now a real drag (exceeded minimum movement threshold)
876
- if (!hasDragged && movementDelta > config.minimumMovement) {
976
+ // Horizontal commit to drag
877
977
  hasDragged = true
878
- // Now that we know it's a drag, change cursor and disable pointer events on items
879
978
  container.style.cursor = 'grabbing'
880
- items.forEach(item => {
881
- item.style.pointerEvents = 'none'
882
- })
979
+ trackElement.classList.add('looper-dragging')
883
980
  }
884
981
 
885
- // Only update position if we've confirmed this is a drag
886
- if (!hasDragged) return
887
-
888
982
  // Prevent default only for actual drags (not clicks)
889
983
  e.preventDefault()
890
984
 
891
985
  // Track for velocity calculation
892
986
  velocityTracker.push({ x: currentX, time: currentTime })
893
987
 
894
- // Keep only recent movements (last 100ms)
895
- while (velocityTracker.length > 0 && currentTime - velocityTracker[0].time > 100) {
988
+ // Keep only recent movements
989
+ while (velocityTracker.length > 0 && currentTime - velocityTracker[0].time > VELOCITY_WINDOW_MS) {
896
990
  velocityTracker.shift()
897
991
  }
898
992
 
@@ -912,23 +1006,6 @@ function horizontalLoop(app, items, config) {
912
1006
  }
913
1007
  }
914
1008
 
915
- /**
916
- * Calculate where inertia would land based on velocity
917
- * Uses same physics as startInertia to predict landing position
918
- * Motion.js inertia formula: distance = velocity * timeConstant * (power / (1 - power))
919
- * @param {number} velocity - Cursor velocity in pixels per second
920
- * @returns {number} Predicted landing position
921
- */
922
- function calculateInertiaTarget(velocity) {
923
- const currentPos = position.get()
924
- const motionVelocity = -velocity
925
- const power = config.throwPower
926
- const timeConstant = config.throwResistance / 1000
927
- // Motion.js inertia distance formula
928
- const estimatedDistance = motionVelocity * timeConstant * (power / (1 - power))
929
- return currentPos + estimatedDistance
930
- }
931
-
932
1009
  /**
933
1010
  * Handle pointer up - end drag and start inertia
934
1011
  */
@@ -937,31 +1014,57 @@ function horizontalLoop(app, items, config) {
937
1014
 
938
1015
  isDragging = false
939
1016
 
1017
+ // Release pointer capture
1018
+ try { container.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
1019
+
940
1020
  // Clean up listeners
941
- window.removeEventListener('pointermove', onPointerMove)
942
- window.removeEventListener('pointerup', onPointerUp)
943
- window.removeEventListener('pointercancel', onPointerUp)
1021
+ container.removeEventListener('pointermove', onPointerMove)
1022
+ container.removeEventListener('pointerup', onPointerUp)
1023
+ container.removeEventListener('pointercancel', onPointerUp)
944
1024
 
945
1025
  // Reset cursor and re-enable hover effects (only if we actually dragged)
946
1026
  if (hasDragged) {
947
1027
  container.style.cursor = 'grab'
948
- items.forEach(item => {
949
- item.style.pointerEvents = ''
950
- })
1028
+ trackElement.classList.remove('looper-dragging')
1029
+
1030
+ // Swallow the next click event so links don't navigate after a drag.
1031
+ // The browser fires click after pointerup — capture it before it reaches any <a>.
1032
+ container.addEventListener('click', swallowClick, { capture: true, once: true })
951
1033
  }
952
1034
 
953
- // If this was a click (not a drag), trigger click on the element
1035
+ // If this was a click (not a drag):
1036
+ // - If we stopped a running animation, silently stop (like native iOS scroll tap-to-stop)
1037
+ // then resume crawl after a delay so it doesn't feel jittery
1038
+ // - Otherwise, forward the click to the element under the pointer
954
1039
  if (!hasDragged) {
955
- // Find the element under the pointer and trigger a click
956
- const clickedElement = document.elementFromPoint(e.clientX, e.clientY)
957
- if (clickedElement) {
958
- clickedElement.click()
1040
+ if (!stoppedAnimation) {
1041
+ const clickedElement = document.elementFromPoint(e.clientX, e.clientY)
1042
+ if (clickedElement) {
1043
+ clickedElement.click()
1044
+ }
1045
+ }
1046
+ if (stoppedAnimation && config.crawl) {
1047
+ clearTimeout(resumeTimeout)
1048
+ resumeTimeout = setTimeout(() => resumeCrawl(), 5000)
959
1049
  }
960
1050
  return
961
1051
  }
962
1052
 
963
- // Calculate final velocity
964
- const velocity = getVelocity()
1053
+ // Calculate velocity from pointermove samples only.
1054
+ // Do NOT use pointerup/pointercancel clientX — iOS Safari often reports 0
1055
+ // on pointercancel which corrupts velocity and direction calculations.
1056
+ let velocity = getVelocity()
1057
+
1058
+ // Use last reliable pointermove position for direction check (not e.clientX
1059
+ // which may be 0 from a pointercancel event on iOS)
1060
+ const lastTrackedX = velocityTracker.length > 0
1061
+ ? velocityTracker[velocityTracker.length - 1].x
1062
+ : e.clientX
1063
+ const overallDelta = lastTrackedX - startX
1064
+
1065
+ // Sanity check: velocity direction must match overall drag direction.
1066
+ if (overallDelta > 0 && velocity < 0) velocity = 0
1067
+ if (overallDelta < 0 && velocity > 0) velocity = 0
965
1068
 
966
1069
  // If snap is enabled, always use it (GSAP-style: snap modifies inertia target)
967
1070
  // Otherwise use old logic: inertia if velocity, or resume crawl
@@ -990,12 +1093,14 @@ function horizontalLoop(app, items, config) {
990
1093
  // Note: This is ALWAYS opposite, regardless of reversed setting
991
1094
  // (reversed only affects auto-crawl, not drag)
992
1095
  // Apply velocity multiplier for tuning throw feel
993
- const motionVelocity = -velocity * config.throwVelocityMultiplier
1096
+ // Reduce inertia when reduced motion is preferred (H4)
1097
+ const velocityMultiplier = reducedMotion ? config.throwVelocityMultiplier * 0.3 : config.throwVelocityMultiplier
1098
+ const motionVelocity = -velocity * velocityMultiplier
994
1099
 
995
- // Calculate estimated target based on inertia physics
1100
+ // Estimate target for Motion.js (it recomputes internally for type: 'inertia',
1101
+ // but we pass a reasonable target to avoid a zero-distance animation)
996
1102
  const power = config.throwPower
997
- const timeConstant = config.throwResistance / 1000 // Convert to seconds
998
- const estimatedDistance = motionVelocity * timeConstant * 0.5
1103
+ const estimatedDistance = power * motionVelocity
999
1104
  const targetPos = currentPos + estimatedDistance
1000
1105
 
1001
1106
  // Animate position motionValue with inertia
@@ -1094,6 +1199,24 @@ function horizontalLoop(app, items, config) {
1094
1199
  */
1095
1200
  function snapToNearest(velocity = 0) {
1096
1201
  const currentPos = position.get()
1202
+
1203
+ // Reduced motion: instant snap with short duration, no inertia (H4)
1204
+ if (reducedMotion) {
1205
+ const snapPos = findNearestSnapPoint(currentPos)
1206
+ const clampedPos = shouldLoop ? snapPos : Math.max(0, Math.min(snapPos, maxScrollPosition))
1207
+ snapAnimation = animate(position, clampedPos, {
1208
+ duration: 0.15,
1209
+ ease: 'easeOut',
1210
+ })
1211
+ snapAnimation
1212
+ .then(() => {
1213
+ snapAnimation = null
1214
+ updateIndexDisplay()
1215
+ })
1216
+ .catch(() => { snapAnimation = null })
1217
+ return
1218
+ }
1219
+
1097
1220
  // Apply both velocity multipliers for snapped loopers
1098
1221
  const motionVelocity =
1099
1222
  -velocity * config.throwVelocityMultiplier * config.snapVelocityMultiplier
@@ -1151,7 +1274,10 @@ function horizontalLoop(app, items, config) {
1151
1274
  * Reads current position and resumes infinite loop
1152
1275
  */
1153
1276
  function resumeCrawl() {
1154
- if (!config.crawl) return
1277
+ if (!config.crawl || reducedMotion) return
1278
+
1279
+ // Increment generation to invalidate any pending .then() callbacks (C7)
1280
+ const generation = ++resumeCrawlGeneration
1155
1281
 
1156
1282
  // Stop any existing animations
1157
1283
  if (animation) {
@@ -1168,7 +1294,10 @@ function horizontalLoop(app, items, config) {
1168
1294
  // Calculate position within current cycle (using originalItemsWidth)
1169
1295
  // Use proper modulo for negative positions (dragging right/backward)
1170
1296
  const cyclePos = ((currentPos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
1171
- const remainingDist = originalItemsWidth - cyclePos
1297
+ // For reversed carousels, remaining distance to complete cycle backward is cyclePos (C6)
1298
+ const remainingDist = config.reversed
1299
+ ? (cyclePos || originalItemsWidth)
1300
+ : (originalItemsWidth - cyclePos)
1172
1301
  const remainingDuration = remainingDist / pixelsPerSecond
1173
1302
 
1174
1303
  // Animate position to complete this cycle
@@ -1181,6 +1310,9 @@ function horizontalLoop(app, items, config) {
1181
1310
 
1182
1311
  // When cycle completes, restart infinite loop
1183
1312
  animation.then(() => {
1313
+ // Check generation to avoid stale .then() callbacks (C7)
1314
+ if (generation !== resumeCrawlGeneration) return
1315
+
1184
1316
  // Capture current speed before replacing animation
1185
1317
  const currentSpeed = animation.speed
1186
1318
 
@@ -1203,9 +1335,8 @@ function horizontalLoop(app, items, config) {
1203
1335
  if (speedRampAnimation) {
1204
1336
  speedRampAnimation.stop()
1205
1337
  // Calculate remaining ramp duration based on current speed
1206
- // speed goes from 0.001 to 1.0, so progress = (currentSpeed - 0.001) / (1.0 - 0.001)
1207
- const rampProgress = (currentSpeed - 0.001) / 0.999
1208
- const remainingRampDuration = 2 * (1 - rampProgress)
1338
+ const rampProgress = (currentSpeed - MIN_CRAWL_SPEED) / (1 - MIN_CRAWL_SPEED)
1339
+ const remainingRampDuration = SPEED_RAMP_DURATION * (1 - rampProgress)
1209
1340
 
1210
1341
  speedRampAnimation = animate(
1211
1342
  animation,
@@ -1216,9 +1347,9 @@ function horizontalLoop(app, items, config) {
1216
1347
  })
1217
1348
 
1218
1349
  // Start at nearly-stopped speed and ramp up to full speed
1219
- // Use 0.001 instead of 0 to keep animation running (speed = 0 completely pauses)
1220
- animation.speed = 0.001
1221
- speedRampAnimation = animate(animation, { speed: 1 }, { duration: 2, ease: 'easeIn' })
1350
+ // Use MIN_CRAWL_SPEED instead of 0 to keep animation running (speed = 0 completely pauses)
1351
+ animation.speed = MIN_CRAWL_SPEED
1352
+ speedRampAnimation = animate(animation, { speed: 1 }, { duration: SPEED_RAMP_DURATION, ease: 'easeIn' })
1222
1353
  }
1223
1354
 
1224
1355
  // Set up touch-action CSS for proper touch handling
@@ -1232,15 +1363,17 @@ function horizontalLoop(app, items, config) {
1232
1363
  dragState = {
1233
1364
  cleanup: () => {
1234
1365
  container.removeEventListener('pointerdown', onPointerDown)
1235
- window.removeEventListener('pointermove', onPointerMove)
1236
- window.removeEventListener('pointerup', onPointerUp)
1237
- window.removeEventListener('pointercancel', onPointerUp)
1366
+ container.removeEventListener('click', swallowClick, { capture: true })
1367
+ container.removeEventListener('pointermove', onPointerMove)
1368
+ container.removeEventListener('pointerup', onPointerUp)
1369
+ container.removeEventListener('pointercancel', onPointerUp)
1238
1370
  },
1239
1371
  }
1240
1372
  }
1241
1373
 
1242
1374
  /**
1243
- * Setup hover slow-down effects
1375
+ * Setup hover slow-down effects using event delegation on container (H1/C1/C5)
1376
+ * Single listener pair on container covers all items including future clones
1244
1377
  */
1245
1378
  function setupHoverEffects() {
1246
1379
  if (!config.crawl || !animation) return
@@ -1252,37 +1385,48 @@ function horizontalLoop(app, items, config) {
1252
1385
  // Track hover animations to prevent accumulation
1253
1386
  let hoverAnimation = null
1254
1387
 
1255
- items.forEach(item => {
1256
- item.addEventListener('mouseenter', () => {
1257
- if (!animation) return
1388
+ function onMouseEnter(e) {
1389
+ if (!animation) return
1390
+ if (!e.target.closest('[data-looper-item]')) return
1258
1391
 
1259
- // Stop previous hover animation before creating new one
1260
- if (hoverAnimation) {
1261
- hoverAnimation.stop()
1262
- }
1392
+ if (hoverAnimation) {
1393
+ hoverAnimation.stop()
1394
+ }
1263
1395
 
1264
- hoverAnimation = animate(
1265
- animation,
1266
- { speed: hoverSpeed },
1267
- { duration: config.ease.mouseOver.duration, ease: 'easeOut' }
1268
- )
1269
- })
1396
+ hoverAnimation = animate(
1397
+ animation,
1398
+ { speed: hoverSpeed },
1399
+ { duration: config.ease.mouseOver.duration, ease: 'easeOut' }
1400
+ )
1401
+ }
1270
1402
 
1271
- item.addEventListener('mouseleave', () => {
1272
- if (!animation) return
1403
+ function onMouseLeave(e) {
1404
+ if (!animation) return
1405
+ if (!e.target.closest('[data-looper-item]')) return
1273
1406
 
1274
- // Stop previous hover animation before creating new one
1275
- if (hoverAnimation) {
1276
- hoverAnimation.stop()
1277
- }
1407
+ if (hoverAnimation) {
1408
+ hoverAnimation.stop()
1409
+ }
1278
1410
 
1279
- hoverAnimation = animate(
1280
- animation,
1281
- { speed: targetSpeed },
1282
- { duration: config.ease.mouseOut.duration, ease: 'easeOut' }
1283
- )
1284
- })
1285
- })
1411
+ hoverAnimation = animate(
1412
+ animation,
1413
+ { speed: targetSpeed },
1414
+ { duration: config.ease.mouseOut.duration, ease: 'easeOut' }
1415
+ )
1416
+ }
1417
+
1418
+ container.addEventListener('mouseenter', onMouseEnter, true)
1419
+ container.addEventListener('mouseleave', onMouseLeave, true)
1420
+
1421
+ // Return cleanup function (C1/C9)
1422
+ hoverCleanup = () => {
1423
+ if (hoverAnimation) {
1424
+ hoverAnimation.stop()
1425
+ hoverAnimation = null
1426
+ }
1427
+ container.removeEventListener('mouseenter', onMouseEnter, true)
1428
+ container.removeEventListener('mouseleave', onMouseLeave, true)
1429
+ }
1286
1430
  }
1287
1431
 
1288
1432
  /**
@@ -1293,6 +1437,11 @@ function horizontalLoop(app, items, config) {
1293
1437
  function closestIndex(setCurrent = false) {
1294
1438
  if (!times || times.length === 0) return 0
1295
1439
 
1440
+ // If curIndex was set by toIndex (nav button), trust it.
1441
+ // Position-based lookup fails when maxScrollPosition clamps positions
1442
+ // too close together to distinguish items.
1443
+ if (indexSetByNav) return curIndex
1444
+
1296
1445
  // Use bounded position to find what's actually visible
1297
1446
  const currentPos = position.get()
1298
1447
 
@@ -1308,21 +1457,14 @@ function horizontalLoop(app, items, config) {
1308
1457
 
1309
1458
  let closest = 0
1310
1459
  let closestDist = Infinity
1460
+ const duration = shouldLoop ? originalItemsWidth / pixelsPerSecond : 0
1311
1461
 
1312
1462
  // Only check original items, not clones
1313
1463
  for (let i = 0; i < originalItemCount; i++) {
1314
1464
  const time = times[i]
1315
- let dist = Math.abs(time - currentTime)
1316
-
1317
- // For looping, check wrapped distance
1318
- if (shouldLoop) {
1319
- const duration = originalItemsWidth / pixelsPerSecond
1320
- const wrappedDist = Math.min(
1321
- Math.abs(time + duration - currentTime),
1322
- Math.abs(time - duration - currentTime)
1323
- )
1324
- dist = Math.min(dist, wrappedDist)
1325
- }
1465
+ const dist = shouldLoop
1466
+ ? Math.abs(symmetricMod(time - currentTime, duration))
1467
+ : Math.abs(time - currentTime)
1326
1468
 
1327
1469
  if (dist < closestDist) {
1328
1470
  closestDist = dist
@@ -1398,6 +1540,7 @@ function horizontalLoop(app, items, config) {
1398
1540
 
1399
1541
  // Update current index
1400
1542
  curIndex = targetIndex
1543
+ indexSetByNav = true
1401
1544
 
1402
1545
  // Update display immediately
1403
1546
  updateIndexDisplay()
@@ -1431,19 +1574,19 @@ function horizontalLoop(app, items, config) {
1431
1574
 
1432
1575
  const loopController = {
1433
1576
  position,
1434
- animation,
1577
+ get animation() { return animation },
1435
1578
  items,
1436
- times,
1579
+ get times() { return times },
1437
1580
  isReversed: config.reversed,
1438
1581
  isLooping: shouldLoop,
1439
1582
 
1440
1583
  play() {
1441
- if (!shouldLoop && config.crawl && config.startPingPongCrawl) {
1584
+ if (!shouldLoop && config.crawl && startPingPongCrawl) {
1442
1585
  // Non-looping: start ping-pong crawl
1443
1586
  const currentPos = position.get()
1444
1587
  // Determine direction based on current position
1445
1588
  const goForward = currentPos < maxScrollPosition / 2
1446
- config.startPingPongCrawl(goForward)
1589
+ startPingPongCrawl(goForward)
1447
1590
  } else if (animation) {
1448
1591
  animation.play()
1449
1592
  }
@@ -1544,17 +1687,51 @@ function horizontalLoop(app, items, config) {
1544
1687
  // Stop autoplay
1545
1688
  this.stopAutoplay()
1546
1689
 
1547
- // Stop animation
1690
+ // Stop all animations (C9)
1548
1691
  if (animation) {
1549
1692
  animation.stop()
1693
+ animation = null
1694
+ }
1695
+ if (speedRampAnimation) {
1696
+ speedRampAnimation.stop()
1697
+ speedRampAnimation = null
1698
+ }
1699
+ if (inertiaAnimation) {
1700
+ inertiaAnimation.stop()
1701
+ inertiaAnimation = null
1702
+ }
1703
+ if (snapAnimation) {
1704
+ snapAnimation.stop()
1705
+ snapAnimation = null
1706
+ }
1707
+ if (navAnimation) {
1708
+ navAnimation.stop()
1709
+ navAnimation = null
1710
+ }
1711
+
1712
+ // Clear pending timeouts (C4)
1713
+ if (pingPongTimeout) {
1714
+ clearTimeout(pingPongTimeout)
1715
+ pingPongTimeout = null
1716
+ }
1717
+
1718
+ // Cleanup hover effects (C1/C9)
1719
+ if (hoverCleanup) {
1720
+ hoverCleanup()
1721
+ hoverCleanup = null
1550
1722
  }
1551
1723
 
1552
1724
  // Stop frame.render loop
1553
1725
  stopRenderLoop()
1554
1726
 
1555
- // Cleanup position listener
1727
+ // Cleanup position listeners (C3)
1556
1728
  if (positionUnsubscribe) {
1557
1729
  positionUnsubscribe()
1730
+ positionUnsubscribe = null
1731
+ }
1732
+ if (indexUnsubscribe) {
1733
+ indexUnsubscribe()
1734
+ indexUnsubscribe = null
1558
1735
  }
1559
1736
 
1560
1737
  // Cleanup drag
@@ -1565,6 +1742,23 @@ function horizontalLoop(app, items, config) {
1565
1742
  // Cleanup resize listener
1566
1743
  window.removeEventListener('APPLICATION:RESIZE', handleResize)
1567
1744
 
1745
+ // Remove clone elements from DOM (C10)
1746
+ const clones = trackElement.querySelectorAll('[data-looper-clone]')
1747
+ clones.forEach(clone => clone.remove())
1748
+
1749
+ // Clear inline styles on track element (C10)
1750
+ trackElement.style.willChange = ''
1751
+ trackElement.style.transform = ''
1752
+
1753
+ // Clear inline styles on container
1754
+ container.style.touchAction = ''
1755
+ container.style.cursor = ''
1756
+
1757
+ // Clear item wrap transforms
1758
+ items.forEach(item => {
1759
+ item.style.transform = ''
1760
+ })
1761
+
1568
1762
  // Destroy position value
1569
1763
  position.destroy()
1570
1764
  },
@@ -1603,7 +1797,7 @@ export default class Looper {
1603
1797
 
1604
1798
  if (!wrapper) {
1605
1799
  console.error(
1606
- '[Looper] ⚠️ No wrapper element found (expected [data-looper-container] or .looper-wrapper)'
1800
+ '[Looper] No wrapper element found (expected [data-looper-container] or .looper-wrapper)'
1607
1801
  )
1608
1802
  }
1609
1803
 
@@ -1689,6 +1883,7 @@ export default class Looper {
1689
1883
  snapDuration: this.opts.snapDuration,
1690
1884
  snapBounce: this.opts.snapBounce,
1691
1885
  minimumMovement: this.opts.minimumMovement,
1886
+ touchMinimumMovement: this.opts.touchMinimumMovement,
1692
1887
  },
1693
1888
  })
1694
1889
  })
@@ -1720,22 +1915,26 @@ export default class Looper {
1720
1915
  // Replace stub with real loop
1721
1916
  element.$loop = loop
1722
1917
 
1723
- // Setup navigation buttons if present
1918
+ // Setup navigation buttons if present (C2 - store refs for cleanup)
1724
1919
  const next = Dom.find(element, '[data-panner-next]')
1725
1920
  const previous = Dom.find(element, '[data-panner-previous]')
1921
+ const navCleanups = []
1726
1922
 
1727
1923
  if (next) {
1728
- next.addEventListener('click', () => {
1729
- loop.next({ duration: 0.85, ease: 'easeInOut' })
1730
- })
1924
+ const onNext = () => loop.next({ duration: 0.85, ease: 'easeInOut' })
1925
+ next.addEventListener('click', onNext)
1926
+ navCleanups.push(() => next.removeEventListener('click', onNext))
1731
1927
  }
1732
1928
 
1733
1929
  if (previous) {
1734
- previous.addEventListener('click', () => {
1735
- loop.previous({ duration: 0.85, ease: 'easeInOut' })
1736
- })
1930
+ const onPrev = () => loop.previous({ duration: 0.85, ease: 'easeInOut' })
1931
+ previous.addEventListener('click', onPrev)
1932
+ navCleanups.push(() => previous.removeEventListener('click', onPrev))
1737
1933
  }
1738
1934
 
1935
+ // Store nav cleanup on the loop controller
1936
+ loop._navCleanups = navCleanups
1937
+
1739
1938
  // Reveal lazyload images: immediately reveal off-screen items (no visible transition),
1740
1939
  // defer reveal of viewport items until after wrapper fade-in for a nice per-image fade
1741
1940
  if (this.app?.lazyload && wrapper) {
@@ -1770,7 +1969,14 @@ export default class Looper {
1770
1969
  }
1771
1970
 
1772
1971
  destroy() {
1773
- this.loopers.forEach(loop => loop.destroy())
1972
+ this.loopers.forEach(loop => {
1973
+ // Clean up navigation button listeners (C2)
1974
+ if (loop._navCleanups) {
1975
+ loop._navCleanups.forEach(fn => fn())
1976
+ loop._navCleanups = null
1977
+ }
1978
+ loop.destroy()
1979
+ })
1774
1980
  this.loopers = []
1775
1981
  this.pendingLoopers = []
1776
1982
  }