@brandocms/jupiter 5.0.0-beta.13 → 5.0.0-beta.15

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.15",
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)
@@ -66,6 +67,10 @@ const DEFAULT_OPTIONS = {
66
67
  mouseOut: { speed: 1, duration: 0.75 },
67
68
  },
68
69
 
70
+ // Called when the looper is ready to reveal. Receives (wrapper, loop) args.
71
+ // Override to control the reveal animation yourself. Default fades in the wrapper.
72
+ onReveal: null,
73
+
69
74
  selector: '[data-moonwalk-run="loop"]',
70
75
  }
71
76
 
@@ -816,9 +821,23 @@ function horizontalLoop(app, items, config) {
816
821
  function setupDrag() {
817
822
  // isDragging is now module-level so wrap detection can see it
818
823
  let startX = 0
824
+ let startY = 0
819
825
  let startPosition = 0
820
826
  let velocityTracker = [] // Track recent movements for velocity calculation
821
827
  let hasDragged = false // Did movement exceed minimumMovement threshold?
828
+ let axisDecided = false // Has the drag axis been determined? (horizontal vs vertical)
829
+ let stoppedAnimation = false // Was an animation stopped by this pointer down? (tap-to-stop)
830
+ let resumeTimeout = null // Delayed crawl resume after tap-to-stop
831
+ let activeMinimumMovement = config.minimumMovement // Adjusted per pointer type (touch vs mouse)
832
+
833
+ /**
834
+ * Capture-phase click handler that prevents link navigation after a drag.
835
+ * Added once per drag and auto-removes via { once: true }.
836
+ */
837
+ function swallowClick(e) {
838
+ e.preventDefault()
839
+ e.stopPropagation()
840
+ }
822
841
 
823
842
  /**
824
843
  * Calculate velocity from recent pointer movements
@@ -828,8 +847,8 @@ function horizontalLoop(app, items, config) {
828
847
  function getVelocity() {
829
848
  if (velocityTracker.length < 2) return 0
830
849
 
831
- // Use last 5 movements for smoothing
832
- const recent = velocityTracker.slice(-5)
850
+ // Use last 6 movements for smoothing
851
+ const recent = velocityTracker.slice(-6)
833
852
  let totalVelocity = 0
834
853
  let totalWeight = 0
835
854
 
@@ -861,15 +880,33 @@ function horizontalLoop(app, items, config) {
861
880
  isDragging = true
862
881
  indexSetByNav = false
863
882
  startX = e.clientX
883
+ startY = e.clientY
864
884
  startPosition = position.get()
865
- velocityTracker = [{ x: e.clientX, time: Date.now() }]
885
+ velocityTracker = [{ x: e.clientX, time: e.timeStamp }]
866
886
  hasDragged = false // Reset - will be set true if movement exceeds threshold
887
+ axisDecided = false // Reset - will be decided on first significant movement
888
+
889
+ // Use higher threshold for touch to prevent accidental drags from finger imprecision
890
+ activeMinimumMovement = e.pointerType === 'touch'
891
+ ? config.touchMinimumMovement
892
+ : config.minimumMovement
867
893
 
868
894
  // Stop autoplay on user interaction
869
895
  if (loopController && loopController.stopAutoplay) {
870
896
  loopController.stopAutoplay()
871
897
  }
872
898
 
899
+ // Cancel any pending resume from a previous tap-to-stop
900
+ clearTimeout(resumeTimeout)
901
+
902
+ // Detect if we're stopping a running animation (tap-to-stop)
903
+ stoppedAnimation = !!(
904
+ inertiaAnimation ||
905
+ snapAnimation ||
906
+ (animation && animation.speed !== 0) ||
907
+ speedRampAnimation
908
+ )
909
+
873
910
  // Stop any ongoing animations
874
911
  if (inertiaAnimation) {
875
912
  inertiaAnimation.stop()
@@ -888,11 +925,15 @@ function horizontalLoop(app, items, config) {
888
925
  speedRampAnimation = null
889
926
  }
890
927
 
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()
928
+ // For mouse: prevent default to stop native drag behavior on links/images
929
+ // For touch: don't preventDefault here we need the browser to be able to scroll
930
+ // if the gesture turns out to be vertical. CSS touch-action: pan-y handles horizontal.
931
+ if (e.pointerType === 'mouse') {
932
+ e.preventDefault()
933
+ }
894
934
 
895
- // Add move/up listeners to window for better tracking
935
+ // Add move/up listeners to window so events are never lost
936
+ // (pointer capture can silently fail or be released mid-drag)
896
937
  window.addEventListener('pointermove', onPointerMove, { passive: false })
897
938
  window.addEventListener('pointerup', onPointerUp)
898
939
  window.addEventListener('pointercancel', onPointerUp)
@@ -905,22 +946,40 @@ function horizontalLoop(app, items, config) {
905
946
  if (!isDragging) return
906
947
 
907
948
  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)
949
+ const currentTime = e.timeStamp
950
+
951
+ // Axis locking: when movement first exceeds threshold, check if primarily
952
+ // horizontal or vertical. If vertical, abort drag and let the browser scroll.
953
+ if (!axisDecided) {
954
+ const deltaX = Math.abs(currentX - startX)
955
+ const deltaY = Math.abs(e.clientY - startY)
956
+ const maxDelta = Math.max(deltaX, deltaY)
957
+
958
+ // Wait until enough movement to decide
959
+ if (maxDelta < activeMinimumMovement) return
960
+
961
+ axisDecided = true
962
+
963
+ // If clearly vertical, abort — release pointer and let browser scroll.
964
+ // Bias toward carousel interaction: vertical must be 1.2x horizontal to abort.
965
+ if (deltaY > deltaX * 1.2) {
966
+ isDragging = false
967
+ window.removeEventListener('pointermove', onPointerMove)
968
+ window.removeEventListener('pointerup', onPointerUp)
969
+ window.removeEventListener('pointercancel', onPointerUp)
970
+ // Resume crawl if we stopped it on pointerdown
971
+ if (stoppedAnimation && config.crawl) {
972
+ resumeCrawl()
973
+ }
974
+ return
975
+ }
912
976
 
913
- // Check if this is now a real drag (exceeded minimum movement threshold)
914
- if (!hasDragged && movementDelta > config.minimumMovement) {
977
+ // Horizontal commit to drag
915
978
  hasDragged = true
916
- // Now that we know it's a drag, change cursor and disable pointer events on items
917
979
  container.style.cursor = 'grabbing'
918
980
  trackElement.classList.add('looper-dragging')
919
981
  }
920
982
 
921
- // Only update position if we've confirmed this is a drag
922
- if (!hasDragged) return
923
-
924
983
  // Prevent default only for actual drags (not clicks)
925
984
  e.preventDefault()
926
985
 
@@ -948,23 +1007,6 @@ function horizontalLoop(app, items, config) {
948
1007
  }
949
1008
  }
950
1009
 
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
1010
  /**
969
1011
  * Handle pointer up - end drag and start inertia
970
1012
  */
@@ -982,20 +1024,45 @@ function horizontalLoop(app, items, config) {
982
1024
  if (hasDragged) {
983
1025
  container.style.cursor = 'grab'
984
1026
  trackElement.classList.remove('looper-dragging')
1027
+
1028
+ // Swallow the next click event so links don't navigate after a drag.
1029
+ // The browser fires click after pointerup — capture it before it reaches any <a>.
1030
+ container.addEventListener('click', swallowClick, { capture: true, once: true })
985
1031
  }
986
1032
 
987
- // If this was a click (not a drag), trigger click on the element
1033
+ // If this was a click (not a drag):
1034
+ // - If we stopped a running animation, silently stop (like native iOS scroll tap-to-stop)
1035
+ // then resume crawl after a delay so it doesn't feel jittery
1036
+ // - Otherwise, forward the click to the element under the pointer
988
1037
  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()
1038
+ if (!stoppedAnimation) {
1039
+ const clickedElement = document.elementFromPoint(e.clientX, e.clientY)
1040
+ if (clickedElement) {
1041
+ clickedElement.click()
1042
+ }
1043
+ }
1044
+ if (stoppedAnimation && config.crawl) {
1045
+ clearTimeout(resumeTimeout)
1046
+ resumeTimeout = setTimeout(() => resumeCrawl(), 5000)
993
1047
  }
994
1048
  return
995
1049
  }
996
1050
 
997
- // Calculate final velocity
998
- const velocity = getVelocity()
1051
+ // Calculate velocity from pointermove samples only.
1052
+ // Do NOT use pointerup/pointercancel clientX — iOS Safari often reports 0
1053
+ // on pointercancel which corrupts velocity and direction calculations.
1054
+ let velocity = getVelocity()
1055
+
1056
+ // Use last reliable pointermove position for direction check (not e.clientX
1057
+ // which may be 0 from a pointercancel event on iOS)
1058
+ const lastTrackedX = velocityTracker.length > 0
1059
+ ? velocityTracker[velocityTracker.length - 1].x
1060
+ : e.clientX
1061
+ const overallDelta = lastTrackedX - startX
1062
+
1063
+ // Sanity check: velocity direction must match overall drag direction.
1064
+ if (overallDelta > 0 && velocity < 0) velocity = 0
1065
+ if (overallDelta < 0 && velocity > 0) velocity = 0
999
1066
 
1000
1067
  // If snap is enabled, always use it (GSAP-style: snap modifies inertia target)
1001
1068
  // Otherwise use old logic: inertia if velocity, or resume crawl
@@ -1028,10 +1095,10 @@ function horizontalLoop(app, items, config) {
1028
1095
  const velocityMultiplier = reducedMotion ? config.throwVelocityMultiplier * 0.3 : config.throwVelocityMultiplier
1029
1096
  const motionVelocity = -velocity * velocityMultiplier
1030
1097
 
1031
- // Calculate estimated target based on inertia physics
1098
+ // Estimate target for Motion.js (it recomputes internally for type: 'inertia',
1099
+ // but we pass a reasonable target to avoid a zero-distance animation)
1032
1100
  const power = config.throwPower
1033
- const timeConstant = config.throwResistance / 1000 // Convert to seconds
1034
- const estimatedDistance = motionVelocity * timeConstant * 0.5
1101
+ const estimatedDistance = power * motionVelocity
1035
1102
  const targetPos = currentPos + estimatedDistance
1036
1103
 
1037
1104
  // Animate position motionValue with inertia
@@ -1294,6 +1361,7 @@ function horizontalLoop(app, items, config) {
1294
1361
  dragState = {
1295
1362
  cleanup: () => {
1296
1363
  container.removeEventListener('pointerdown', onPointerDown)
1364
+ container.removeEventListener('click', swallowClick, { capture: true })
1297
1365
  window.removeEventListener('pointermove', onPointerMove)
1298
1366
  window.removeEventListener('pointerup', onPointerUp)
1299
1367
  window.removeEventListener('pointercancel', onPointerUp)
@@ -1813,6 +1881,7 @@ export default class Looper {
1813
1881
  snapDuration: this.opts.snapDuration,
1814
1882
  snapBounce: this.opts.snapBounce,
1815
1883
  minimumMovement: this.opts.minimumMovement,
1884
+ touchMinimumMovement: this.opts.touchMinimumMovement,
1816
1885
  },
1817
1886
  })
1818
1887
  })
@@ -1866,7 +1935,14 @@ export default class Looper {
1866
1935
 
1867
1936
  // Reveal lazyload images: immediately reveal off-screen items (no visible transition),
1868
1937
  // defer reveal of viewport items until after wrapper fade-in for a nice per-image fade
1869
- if (this.app?.lazyload && wrapper) {
1938
+ if (this.opts.onReveal) {
1939
+ // Custom reveal callback — caller handles animation and lazyload
1940
+ if (this.app?.lazyload && wrapper) {
1941
+ const pictures = Dom.all(wrapper, '[data-ll-srcset]')
1942
+ pictures.forEach(picture => this.app.lazyload.revealPicture(picture))
1943
+ }
1944
+ this.opts.onReveal(wrapper, loop)
1945
+ } else if (this.app?.lazyload && wrapper) {
1870
1946
  const wrapperRect = wrapper.getBoundingClientRect()
1871
1947
  const pictures = Dom.all(wrapper, '[data-ll-srcset]')
1872
1948
  const viewportPictures = []
@@ -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', '')