@brandocms/jupiter 5.0.0-beta.13 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandocms/jupiter",
3
- "version": "5.0.0-beta.13",
3
+ "version": "5.0.0-beta.14",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -418,6 +418,7 @@ export default class DoubleHeader {
418
418
  unpin() {
419
419
  if (!this.preventUnpin) {
420
420
  this._pinned = false
421
+ this._updateHeaderHeight()
421
422
  this.opts.onUnpin(this)
422
423
  }
423
424
  }
@@ -425,6 +426,7 @@ export default class DoubleHeader {
425
426
  pin() {
426
427
  if (!this.preventPin) {
427
428
  this._pinned = true
429
+ this._updateHeaderHeight()
428
430
  this.opts.onSmall(this)
429
431
  this.opts.onPin(this)
430
432
  }
@@ -434,6 +436,7 @@ export default class DoubleHeader {
434
436
  this._small = false
435
437
  this.auxEl.setAttribute('data-header-big', '')
436
438
  this.auxEl.removeAttribute('data-header-small')
439
+ this._updateHeaderHeight()
437
440
  this.opts.onNotSmall(this)
438
441
  }
439
442
 
@@ -441,9 +444,20 @@ export default class DoubleHeader {
441
444
  this._small = true
442
445
  this.auxEl.setAttribute('data-header-small', '')
443
446
  this.auxEl.removeAttribute('data-header-big')
447
+ this._updateHeaderHeight()
444
448
  this.opts.onSmall(this)
445
449
  }
446
450
 
451
+ /**
452
+ * Update the --header-height CSS variable on :root.
453
+ * Uses el height when pinned (el is the main header, auxEl is secondary).
454
+ * Set to 0px when unpinned.
455
+ */
456
+ _updateHeaderHeight() {
457
+ const height = this._pinned ? `${this.el.clientHeight}px` : '0px'
458
+ document.documentElement.style.setProperty('--header-height', height)
459
+ }
460
+
447
461
  shouldUnpin(toleranceExceeded) {
448
462
  if (this._navVisible) {
449
463
  return true
@@ -579,6 +579,7 @@ export default class FixedHeader {
579
579
  this._pinned = false
580
580
  this.el.setAttribute('data-header-unpinned', '')
581
581
  this.el.removeAttribute('data-header-pinned')
582
+ this._updateHeaderHeight()
582
583
  this.opts.onUnpin(this)
583
584
  }
584
585
 
@@ -589,6 +590,7 @@ export default class FixedHeader {
589
590
  this._pinned = true
590
591
  this.el.setAttribute('data-header-pinned', '')
591
592
  this.el.removeAttribute('data-header-unpinned')
593
+ this._updateHeaderHeight()
592
594
  this.opts.onPin(this)
593
595
  }
594
596
 
@@ -596,6 +598,7 @@ export default class FixedHeader {
596
598
  this._small = false
597
599
  this.el.setAttribute('data-header-big', '')
598
600
  this.el.removeAttribute('data-header-small')
601
+ this._updateHeaderHeight()
599
602
  this.opts.onNotSmall(this)
600
603
  }
601
604
 
@@ -603,9 +606,19 @@ export default class FixedHeader {
603
606
  this._small = true
604
607
  this.el.setAttribute('data-header-small', '')
605
608
  this.el.removeAttribute('data-header-big')
609
+ this._updateHeaderHeight()
606
610
  this.opts.onSmall(this)
607
611
  }
608
612
 
613
+ /**
614
+ * Update the --header-height CSS variable on :root.
615
+ * Set to the header's current height when pinned, 0px when unpinned.
616
+ */
617
+ _updateHeaderHeight() {
618
+ const height = this._pinned ? `${this.el.clientHeight}px` : '0px'
619
+ document.documentElement.style.setProperty('--header-height', height)
620
+ }
621
+
609
622
  notAltBg() {
610
623
  this._altBg = false
611
624
  this.el.setAttribute('data-header-reg-bg', '')
@@ -29,7 +29,7 @@ const DEFAULT_OPTIONS = {
29
29
  if (links.opts.scrollOffsetNav) {
30
30
  const header = document.querySelector('header[data-nav]')
31
31
  const headerHeight = header ? header.clientHeight : 0
32
- target = { y: target, offsetY: headerHeight }
32
+ target = { y: target, offsetY: -headerHeight }
33
33
  }
34
34
  links.app.scrollTo(target, links.opts.scrollDuration, links.opts.triggerEvents)
35
35
  },
@@ -19,7 +19,7 @@ function symmetricMod(value, base) {
19
19
  const CLONE_BUFFER_MULTIPLIER = 2.5
20
20
  const MIN_CRAWL_SPEED = 0.001
21
21
  const PING_PONG_PAUSE_MS = 200
22
- const VELOCITY_WINDOW_MS = 100
22
+ const VELOCITY_WINDOW_MS = 150
23
23
  const SPEED_RAMP_DURATION = 2
24
24
 
25
25
  /**
@@ -44,12 +44,13 @@ const DEFAULT_OPTIONS = {
44
44
  loop: true, // Infinite looping (false for linear scrolling)
45
45
  draggable: true, // Enable drag interaction
46
46
  endAlignment: 'right', // For non-looping: 'right' = last item at viewport right edge, 'start' = last item at viewport left edge
47
- 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
48
49
 
49
50
  // Inertia/throw configuration (when dragging and releasing)
50
51
  throwResistance: 325, // Time constant for deceleration (lower = more resistance/faster stop, higher = less resistance/longer glide)
51
52
  throwPower: 0.8, // Deceleration curve (0-1, higher = more gradual slowdown)
52
- 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)
53
54
  snapVelocityMultiplier: 0.8, // Additional scaling for snapped loopers (stacks with throwVelocityMultiplier)
54
55
 
55
56
  // Snap animation configuration (when snap: true)
@@ -816,9 +817,23 @@ function horizontalLoop(app, items, config) {
816
817
  function setupDrag() {
817
818
  // isDragging is now module-level so wrap detection can see it
818
819
  let startX = 0
820
+ let startY = 0
819
821
  let startPosition = 0
820
822
  let velocityTracker = [] // Track recent movements for velocity calculation
821
823
  let hasDragged = false // Did movement exceed minimumMovement threshold?
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
+ }
822
837
 
823
838
  /**
824
839
  * Calculate velocity from recent pointer movements
@@ -828,8 +843,8 @@ function horizontalLoop(app, items, config) {
828
843
  function getVelocity() {
829
844
  if (velocityTracker.length < 2) return 0
830
845
 
831
- // Use last 5 movements for smoothing
832
- const recent = velocityTracker.slice(-5)
846
+ // Use last 6 movements for smoothing
847
+ const recent = velocityTracker.slice(-6)
833
848
  let totalVelocity = 0
834
849
  let totalWeight = 0
835
850
 
@@ -861,15 +876,33 @@ function horizontalLoop(app, items, config) {
861
876
  isDragging = true
862
877
  indexSetByNav = false
863
878
  startX = e.clientX
879
+ startY = e.clientY
864
880
  startPosition = position.get()
865
- velocityTracker = [{ x: e.clientX, time: Date.now() }]
881
+ velocityTracker = [{ x: e.clientX, time: e.timeStamp }]
866
882
  hasDragged = false // Reset - will be set true if movement exceeds threshold
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
867
889
 
868
890
  // Stop autoplay on user interaction
869
891
  if (loopController && loopController.stopAutoplay) {
870
892
  loopController.stopAutoplay()
871
893
  }
872
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
+
873
906
  // Stop any ongoing animations
874
907
  if (inertiaAnimation) {
875
908
  inertiaAnimation.stop()
@@ -888,14 +921,20 @@ function horizontalLoop(app, items, config) {
888
921
  speedRampAnimation = null
889
922
  }
890
923
 
891
- // Prevent default to stop native drag behavior on links/images
892
- // We'll manually trigger click in onPointerUp if it wasn't a real drag
893
- 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
+ }
930
+
931
+ // Capture pointer to prevent events being lost when finger moves outside container
932
+ try { container.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
894
933
 
895
- // Add move/up listeners to window for better tracking
896
- window.addEventListener('pointermove', onPointerMove, { passive: false })
897
- window.addEventListener('pointerup', onPointerUp)
898
- window.addEventListener('pointercancel', onPointerUp)
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)
899
938
  }
900
939
 
901
940
  /**
@@ -905,22 +944,41 @@ function horizontalLoop(app, items, config) {
905
944
  if (!isDragging) return
906
945
 
907
946
  const currentX = e.clientX
908
- const currentTime = Date.now()
909
-
910
- // Track total movement for click vs drag detection
911
- 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
+ }
912
975
 
913
- // Check if this is now a real drag (exceeded minimum movement threshold)
914
- if (!hasDragged && movementDelta > config.minimumMovement) {
976
+ // Horizontal commit to drag
915
977
  hasDragged = true
916
- // Now that we know it's a drag, change cursor and disable pointer events on items
917
978
  container.style.cursor = 'grabbing'
918
979
  trackElement.classList.add('looper-dragging')
919
980
  }
920
981
 
921
- // Only update position if we've confirmed this is a drag
922
- if (!hasDragged) return
923
-
924
982
  // Prevent default only for actual drags (not clicks)
925
983
  e.preventDefault()
926
984
 
@@ -948,23 +1006,6 @@ function horizontalLoop(app, items, config) {
948
1006
  }
949
1007
  }
950
1008
 
951
- /**
952
- * Calculate where inertia would land based on velocity
953
- * Uses same physics as startInertia to predict landing position
954
- * Motion.js inertia formula: distance = velocity * timeConstant * (power / (1 - power))
955
- * @param {number} velocity - Cursor velocity in pixels per second
956
- * @returns {number} Predicted landing position
957
- */
958
- function calculateInertiaTarget(velocity) {
959
- const currentPos = position.get()
960
- const motionVelocity = -velocity
961
- const power = config.throwPower
962
- const timeConstant = config.throwResistance / 1000
963
- // Motion.js inertia distance formula
964
- const estimatedDistance = motionVelocity * timeConstant * (power / (1 - power))
965
- return currentPos + estimatedDistance
966
- }
967
-
968
1009
  /**
969
1010
  * Handle pointer up - end drag and start inertia
970
1011
  */
@@ -973,29 +1014,57 @@ function horizontalLoop(app, items, config) {
973
1014
 
974
1015
  isDragging = false
975
1016
 
1017
+ // Release pointer capture
1018
+ try { container.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
1019
+
976
1020
  // Clean up listeners
977
- window.removeEventListener('pointermove', onPointerMove)
978
- window.removeEventListener('pointerup', onPointerUp)
979
- window.removeEventListener('pointercancel', onPointerUp)
1021
+ container.removeEventListener('pointermove', onPointerMove)
1022
+ container.removeEventListener('pointerup', onPointerUp)
1023
+ container.removeEventListener('pointercancel', onPointerUp)
980
1024
 
981
1025
  // Reset cursor and re-enable hover effects (only if we actually dragged)
982
1026
  if (hasDragged) {
983
1027
  container.style.cursor = 'grab'
984
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 })
985
1033
  }
986
1034
 
987
- // 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
988
1039
  if (!hasDragged) {
989
- // Find the element under the pointer and trigger a click
990
- const clickedElement = document.elementFromPoint(e.clientX, e.clientY)
991
- if (clickedElement) {
992
- 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)
993
1049
  }
994
1050
  return
995
1051
  }
996
1052
 
997
- // Calculate final velocity
998
- 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
999
1068
 
1000
1069
  // If snap is enabled, always use it (GSAP-style: snap modifies inertia target)
1001
1070
  // Otherwise use old logic: inertia if velocity, or resume crawl
@@ -1028,10 +1097,10 @@ function horizontalLoop(app, items, config) {
1028
1097
  const velocityMultiplier = reducedMotion ? config.throwVelocityMultiplier * 0.3 : config.throwVelocityMultiplier
1029
1098
  const motionVelocity = -velocity * velocityMultiplier
1030
1099
 
1031
- // 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)
1032
1102
  const power = config.throwPower
1033
- const timeConstant = config.throwResistance / 1000 // Convert to seconds
1034
- const estimatedDistance = motionVelocity * timeConstant * 0.5
1103
+ const estimatedDistance = power * motionVelocity
1035
1104
  const targetPos = currentPos + estimatedDistance
1036
1105
 
1037
1106
  // Animate position motionValue with inertia
@@ -1294,9 +1363,10 @@ function horizontalLoop(app, items, config) {
1294
1363
  dragState = {
1295
1364
  cleanup: () => {
1296
1365
  container.removeEventListener('pointerdown', onPointerDown)
1297
- window.removeEventListener('pointermove', onPointerMove)
1298
- window.removeEventListener('pointerup', onPointerUp)
1299
- 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)
1300
1370
  },
1301
1371
  }
1302
1372
  }
@@ -1813,6 +1883,7 @@ export default class Looper {
1813
1883
  snapDuration: this.opts.snapDuration,
1814
1884
  snapBounce: this.opts.snapBounce,
1815
1885
  minimumMovement: this.opts.minimumMovement,
1886
+ touchMinimumMovement: this.opts.touchMinimumMovement,
1816
1887
  },
1817
1888
  })
1818
1889
  })
@@ -79,7 +79,7 @@ function logComputedStyle(element, props = ['opacity', 'transform']) {
79
79
  /**
80
80
  * @typedef {Object} MoonwalkOptions
81
81
  * @property {string|null} [on=Events.APPLICATION_REVEALED] - Event name to trigger animations. Set to `null` to trigger manually via `ready()`.
82
- * @property {number} [initialDelay=0.1] - Delay before starting animations
82
+ * @property {number} [initialDelay=100] - Delay in ms before starting animations
83
83
  * @property {boolean} [clearLazyload=false] - Clear data-ll-srcset attributes
84
84
  * @property {boolean} [clearNestedSections=true] - Remove nested data-moonwalk-section attributes
85
85
  * @property {boolean} [clearNestedWalks=true] - Remove nested data-moonwalk attributes
@@ -108,7 +108,7 @@ const DEFAULT_OPTIONS = {
108
108
  * Set a delay for the initial reveal. Could be useful if you want the reveal to happen
109
109
  * after for instance a header has been revealed
110
110
  */
111
- initialDelay: 0.1,
111
+ initialDelay: 100,
112
112
 
113
113
  /**
114
114
  * Clear out all `data-ll-srcset` from moonwalk elements
@@ -834,7 +834,7 @@ export default class Moonwalk {
834
834
  if (this.opts.initialDelay) {
835
835
  setTimeout(() => {
836
836
  this.ready()
837
- }, this.opts.initialDelay * 1000)
837
+ }, this.opts.initialDelay)
838
838
  } else {
839
839
  this.ready()
840
840
  }
@@ -576,6 +576,7 @@ export default class StickyHeader {
576
576
  this._pinned = false
577
577
  this.el.setAttribute('data-header-unpinned', '')
578
578
  this.el.removeAttribute('data-header-pinned')
579
+ this._updateHeaderHeight()
579
580
  this.opts.onUnpin(this)
580
581
  }
581
582
 
@@ -586,6 +587,7 @@ export default class StickyHeader {
586
587
  this._pinned = true
587
588
  this.el.setAttribute('data-header-pinned', '')
588
589
  this.el.removeAttribute('data-header-unpinned')
590
+ this._updateHeaderHeight()
589
591
  this.opts.onPin(this)
590
592
  }
591
593
 
@@ -593,6 +595,7 @@ export default class StickyHeader {
593
595
  this._small = false
594
596
  this.el.setAttribute('data-header-big', '')
595
597
  this.el.removeAttribute('data-header-small')
598
+ this._updateHeaderHeight()
596
599
  this.opts.onNotSmall(this)
597
600
  }
598
601
 
@@ -600,9 +603,19 @@ export default class StickyHeader {
600
603
  this._small = true
601
604
  this.el.setAttribute('data-header-small', '')
602
605
  this.el.removeAttribute('data-header-big')
606
+ this._updateHeaderHeight()
603
607
  this.opts.onSmall(this)
604
608
  }
605
609
 
610
+ /**
611
+ * Update the --header-height CSS variable on :root.
612
+ * Set to the header's current height when pinned, 0px when unpinned.
613
+ */
614
+ _updateHeaderHeight() {
615
+ const height = this._pinned ? `${this.el.clientHeight}px` : '0px'
616
+ document.documentElement.style.setProperty('--header-height', height)
617
+ }
618
+
606
619
  notAltBg() {
607
620
  this._altBg = false
608
621
  this.el.setAttribute('data-header-reg-bg', '')