@brandocms/jupiter 5.0.0-beta.7 → 5.0.0-beta.9

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.7",
3
+ "version": "5.0.0-beta.9",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -36,6 +36,7 @@
36
36
  "playwright:dataloader": "playwright test e2e/dataloader.spec.js --project=chromium --reporter line",
37
37
  "playwright:dataloader-url-sync": "playwright test e2e/dataloader-url-sync.spec.js --project=chromium --reporter line",
38
38
  "playwright:parallax": "playwright test e2e/parallax.spec.js --project=chromium --reporter line",
39
+ "playwright:looper": "playwright test e2e/looper.spec.js --project=chromium --reporter line",
39
40
  "vite": "vite",
40
41
  "vite:build": "vite build",
41
42
  "vite:preview": "vite preview"
@@ -3,6 +3,91 @@ import _defaultsDeep from 'lodash.defaultsdeep'
3
3
  import * as Events from '../../events'
4
4
  import { set } from '../../utils/motion-helpers'
5
5
 
6
+ /**
7
+ * @module Cookies
8
+ *
9
+ * Cookie consent module with optional dialog and toggle button support.
10
+ *
11
+ * ## Consent dialog
12
+ *
13
+ * The traditional consent banner uses a fixed container at the bottom of the page:
14
+ *
15
+ * ```html
16
+ * <div class="cookie-container">
17
+ * <div class="cookie-container-inner">
18
+ * <div class="cookie-law-text">
19
+ * <p>We use cookies...</p>
20
+ * </div>
21
+ * <div class="cookie-law-buttons">
22
+ * <button class="dismiss-cookielaw">Accept</button>
23
+ * <button class="refuse-cookielaw">Decline</button>
24
+ * </div>
25
+ * </div>
26
+ * </div>
27
+ * ```
28
+ *
29
+ * ## Consent toggle button
30
+ *
31
+ * A toggle button can be placed anywhere on the page to let users change their
32
+ * consent at any time. It can also serve as the sole consent mechanism (no
33
+ * dialog required).
34
+ *
35
+ * ```html
36
+ * <button data-cookie-consent
37
+ * data-cookie-consent-accept="Accept cookies"
38
+ * data-cookie-consent-refuse="Refuse cookies">
39
+ * </button>
40
+ * ```
41
+ *
42
+ * ### Data attributes
43
+ *
44
+ * | Attribute | Description |
45
+ * |---|---|
46
+ * | `data-cookie-consent` | Marks the element as a consent toggle |
47
+ * | `data-cookie-consent-accept` | Label shown when user can accept (currently refused/unset) |
48
+ * | `data-cookie-consent-refuse` | Label shown when user can retract (currently accepted) |
49
+ * | `data-cookie-consent-status` | Set by the module: `"accepted"` or `"refused"` |
50
+ * | `data-cookie-consent-icon` | Set on the injected icon `<span>` |
51
+ * | `data-cookie-consent-label` | Set on the injected label `<span>` |
52
+ *
53
+ * ### CSS styling
54
+ *
55
+ * ```css
56
+ * [data-cookie-consent-status="accepted"] [data-cookie-consent-icon] { color: green; }
57
+ * [data-cookie-consent-status="refused"] [data-cookie-consent-icon] { color: red; }
58
+ * ```
59
+ *
60
+ * ### Gettext / translation
61
+ *
62
+ * ```html
63
+ * <button data-cookie-consent
64
+ * data-cookie-consent-accept="{{ _('Accept cookies') }}"
65
+ * data-cookie-consent-refuse="{{ _('Refuse cookies') }}">
66
+ * </button>
67
+ * ```
68
+ *
69
+ * ## Usage examples
70
+ *
71
+ * Dialog only (default):
72
+ * ```js
73
+ * new Cookies(app)
74
+ * ```
75
+ *
76
+ * Toggle only (no dialog HTML needed):
77
+ * ```js
78
+ * new Cookies(app, { setCookies: (c) => { ... } })
79
+ * ```
80
+ *
81
+ * Both dialog and toggle:
82
+ * ```js
83
+ * new Cookies(app, {
84
+ * onConsentChanged: (c) => {
85
+ * console.log(c.getCookie('COOKIES_CONSENT_STATUS'))
86
+ * }
87
+ * })
88
+ * ```
89
+ */
90
+
6
91
  /**
7
92
  * @typedef {Object} CookiesOptions
8
93
  * @property {Function} [onAccept] - Called when cookies are accepted
@@ -11,6 +96,7 @@ import { set } from '../../utils/motion-helpers'
11
96
  * @property {Function} [alreadyRefused] - Called if user has already refused cookies
12
97
  * @property {Function} [setCookies] - Custom function to set cookies
13
98
  * @property {Function} [showCC] - Custom function to display cookie consent dialog
99
+ * @property {Function} [onConsentChanged] - Called after consent is toggled via the toggle button
14
100
  */
15
101
 
16
102
  /** @type {CookiesOptions} */
@@ -21,6 +107,7 @@ const DEFAULT_OPTIONS = {
21
107
 
22
108
  c.setCookie('COOKIES_CONSENT_STATUS', 1, oneYearFromNow, '/')
23
109
  c.opts.setCookies(c)
110
+ c.updateConsentToggles()
24
111
 
25
112
  const timeline = [
26
113
  [c.cc, { y: '120%' }, { duration: 0.35, ease: 'easeIn', at: 0 }],
@@ -37,6 +124,7 @@ const DEFAULT_OPTIONS = {
37
124
  oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1)
38
125
 
39
126
  c.setCookie('COOKIES_CONSENT_STATUS', 0, oneYearFromNow, '/')
127
+ c.updateConsentToggles()
40
128
 
41
129
  const timeline = [
42
130
  [c.cc, { y: '120%' }, { duration: 0.35, ease: 'easeIn', at: 0 }],
@@ -58,6 +146,8 @@ const DEFAULT_OPTIONS = {
58
146
 
59
147
  setCookies: (c) => {},
60
148
 
149
+ onConsentChanged: (c) => {},
150
+
61
151
  showCC: (c) => {
62
152
  if (c.hasCookie('COOKIES_CONSENT_STATUS')) {
63
153
  if (c.getCookie('COOKIES_CONSENT_STATUS') === '1') {
@@ -107,22 +197,114 @@ export default class Cookies {
107
197
  this.btn = document.querySelector('.dismiss-cookielaw')
108
198
  this.btnRefuse = document.querySelector('.refuse-cookielaw')
109
199
 
110
- if (!this.btn) {
200
+ this.setupConsentToggles()
201
+
202
+ if (!this.btn && this.consentToggles.length === 0) {
111
203
  return
112
204
  }
113
205
 
114
- this.app.registerCallback(Events.APPLICATION_REVEALED, () => {
115
- this.opts.showCC(this)
206
+ if (this.btn) {
207
+ this.app.registerCallback(Events.APPLICATION_REVEALED, () => {
208
+ this.opts.showCC(this)
209
+ })
210
+
211
+ this.btn.addEventListener('click', () => {
212
+ this.opts.onAccept(this)
213
+ })
214
+ if (this.btnRefuse) {
215
+ this.btnRefuse.addEventListener('click', () => {
216
+ this.opts.onRefuse(this)
217
+ })
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Find all `[data-cookie-consent]` elements and wire them up.
224
+ */
225
+ setupConsentToggles() {
226
+ this.consentToggles = [...document.querySelectorAll('[data-cookie-consent]')]
227
+
228
+ this.consentToggles.forEach(el => {
229
+ const icon = document.createElement('span')
230
+ icon.setAttribute('data-cookie-consent-icon', '')
231
+
232
+ const label = document.createElement('span')
233
+ label.setAttribute('data-cookie-consent-label', '')
234
+
235
+ el.appendChild(icon)
236
+ el.appendChild(label)
237
+
238
+ this.updateConsentToggle(el)
239
+
240
+ el.addEventListener('click', () => {
241
+ this.handleConsentToggle()
242
+ })
116
243
  })
244
+ }
117
245
 
118
- this.btn.addEventListener('click', () => {
119
- this.opts.onAccept(this)
246
+ /**
247
+ * Update a single consent toggle element to reflect current cookie state.
248
+ * @param {Element} el - The toggle element
249
+ */
250
+ updateConsentToggle(el) {
251
+ const accepted = this.getCookie('COOKIES_CONSENT_STATUS') === '1'
252
+ const acceptText = el.getAttribute('data-cookie-consent-accept') || 'Accept cookies'
253
+ const refuseText = el.getAttribute('data-cookie-consent-refuse') || 'Refuse cookies'
254
+
255
+ const icon = el.querySelector('[data-cookie-consent-icon]')
256
+ const label = el.querySelector('[data-cookie-consent-label]')
257
+
258
+ if (accepted) {
259
+ el.setAttribute('data-cookie-consent-status', 'accepted')
260
+ icon.textContent = '\u2713'
261
+ label.textContent = refuseText
262
+ } else {
263
+ el.setAttribute('data-cookie-consent-status', 'refused')
264
+ icon.textContent = '\u2715'
265
+ label.textContent = acceptText
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Update all consent toggle elements.
271
+ */
272
+ updateConsentToggles() {
273
+ this.consentToggles.forEach(el => {
274
+ this.updateConsentToggle(el)
120
275
  })
121
- if (this.btnRefuse) {
122
- this.btnRefuse.addEventListener('click', () => {
123
- this.opts.onRefuse(this)
276
+ }
277
+
278
+ /**
279
+ * Handle a click on a consent toggle button.
280
+ */
281
+ handleConsentToggle() {
282
+ const oneYearFromNow = new Date()
283
+ oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1)
284
+
285
+ const accepted = this.getCookie('COOKIES_CONSENT_STATUS') === '1'
286
+
287
+ if (accepted) {
288
+ this.setCookie('COOKIES_CONSENT_STATUS', 0, oneYearFromNow, '/')
289
+ } else {
290
+ this.setCookie('COOKIES_CONSENT_STATUS', 1, oneYearFromNow, '/')
291
+ this.opts.setCookies(this)
292
+ }
293
+
294
+ this.updateConsentToggles()
295
+
296
+ if (this.cc && this.cc.style.display !== 'none') {
297
+ const timeline = [
298
+ [this.cc, { y: '120%' }, { duration: 0.35, ease: 'easeIn', at: 0 }],
299
+ [this.inner, { opacity: 0 }, { duration: 0.3, ease: 'easeIn', at: 0 }]
300
+ ]
301
+
302
+ animate(timeline).finished.then(() => {
303
+ this.cc.style.display = 'none'
124
304
  })
125
305
  }
306
+
307
+ this.opts.onConsentChanged(this)
126
308
  }
127
309
 
128
310
  /**
@@ -209,7 +209,10 @@ export default class Lazyload {
209
209
  images.forEach(img => this.swapImage(img))
210
210
 
211
211
  const pictures = Dom.all($container, '[data-ll-srcset]')
212
- pictures.forEach(picture => this.revealPicture(picture))
212
+ pictures.forEach(picture => {
213
+ this.loadPicture(picture)
214
+ this.revealPicture(picture)
215
+ })
213
216
  }
214
217
 
215
218
  initializeResizeObserver() {
@@ -77,9 +77,7 @@ function horizontalLoop(app, items, config) {
77
77
  let originalItemsWidth = 0 // Width of ONLY original items (for wrapping)
78
78
  let pixelsPerSecond = (config.speed || 1) * 100
79
79
  let animation = null
80
- let position = motionValue(0) // Source of truth for position
81
- let boundedPos = motionValue(0) // Bounded position (0 to originalItemsWidth)
82
- let lastBoundedValue = 0 // Track last value to detect wraps
80
+ let position = motionValue(0) // Source of truth for position (raw, unbounded)
83
81
  let originalItemCount = 0 // Track count of ORIGINAL items (before clones)
84
82
  let maxScrollPosition = 0 // For non-looping: max scroll where last item is at right edge
85
83
 
@@ -174,9 +172,9 @@ function horizontalLoop(app, items, config) {
174
172
  let count = 0
175
173
  let previousTotalWidth = totalWidth
176
174
 
177
- // Always create at least one set of clones - the wrapping logic depends on clones existing
175
+ // Always create at least TWO sets of clones - needed for starting at first clone position
178
176
  // Then continue until we have enough width for seamless looping
179
- while ((count === 0 || totalWidth < minRequiredWidth) && count < maxReplications) {
177
+ while ((count < 2 || totalWidth < minRequiredWidth) && count < maxReplications) {
180
178
  // Clone ONLY original items
181
179
  for (let i = 0; i < originalItemCount; i++) {
182
180
  const clone = items[i].cloneNode(true)
@@ -184,28 +182,9 @@ function horizontalLoop(app, items, config) {
184
182
  container.appendChild(clone)
185
183
  items.push(clone)
186
184
 
187
- // Register cloned lazyload elements with Lazyload module if available
188
- if (app?.lazyload?.observe) {
189
- // Clear data-ll-idx from unloaded [data-ll-image] images
190
- clone.querySelectorAll('[data-ll-image]:not([data-ll-loaded])').forEach(img => {
191
- img.removeAttribute('data-ll-idx')
192
- })
193
-
194
- // Clear attributes from unloaded [data-ll-srcset] pictures so they can be re-observed
195
- clone.querySelectorAll('[data-ll-srcset]:not([data-ll-srcset-ready])').forEach(picture => {
196
- picture.removeAttribute('data-ll-srcset-initialized')
197
- // Clear ready state from sources and img so they can be re-processed
198
- picture.querySelectorAll('source').forEach(source => {
199
- source.removeAttribute('data-ll-ready')
200
- })
201
- picture.querySelectorAll('img').forEach(img => {
202
- img.removeAttribute('data-ll-idx')
203
- img.removeAttribute('data-ll-loaded')
204
- img.removeAttribute('data-ll-ready')
205
- })
206
- })
207
-
208
- app.lazyload.observe(clone)
185
+ // Force-load cloned lazyload elements immediately to prevent flash on wrap
186
+ if (app?.lazyload?.forceLoad) {
187
+ app.lazyload.forceLoad(clone)
209
188
  }
210
189
  }
211
190
 
@@ -358,84 +337,48 @@ function horizontalLoop(app, items, config) {
358
337
 
359
338
  /**
360
339
  * Check item positions and wrap when needed
361
- * Container is animated DIRECTLY (not updated here!)
362
- * This function only reads position to determine wrapping
363
- * @param {number} pos - Current position value (can grow infinitely)
340
+ * Container uses RAW position - items wrap individually when far off-screen
341
+ * @param {number} rawPos - Current raw position value (unbounded)
364
342
  */
365
- function updateItemPositions(pos) {
366
- const containerElement = items[0].parentElement
367
-
368
- if (!shouldLoop) {
369
- // Non-looping: we'll handle this with direct animation
370
- return
371
- }
372
-
373
- // Calculate bounded position for checking item wrap points
374
- // Use same wrapping formula as frame.render to handle negative positions (reversed mode)
375
- const boundedPos = ((pos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
343
+ function updateItemPositions(rawPos) {
344
+ if (!shouldLoop) return
376
345
 
377
346
  // Initialize wrap offsets cache if needed
378
347
  if (itemWrapOffsets.length === 0) {
379
348
  itemWrapOffsets = new Array(items.length).fill(0)
380
349
  }
381
350
 
382
- // TICKER PATTERN: ONLY move ORIGINAL items to the back, NEVER touch clones!
383
- // This massively reduces DOM manipulation and style recalculation
384
- items.forEach((item, i) => {
385
- // Skip clones - they stay in natural flow! (use cached value for performance)
386
- if (isCloneCache[i]) {
387
- return
388
- }
351
+ // Items wrap by the full cycle distance (totalWidth) to maintain relative positions
352
+ // This keeps all items within viewing distance as position grows/shrinks
353
+ const cycleDistance = totalWidth
389
354
 
390
- // Calculate where this ORIGINAL item is on screen (relative to bounded container)
391
- const itemLeft = offsetLefts[i] - boundedPos
392
-
393
- // Original items only ever have -totalWidth, 0, or +totalWidth offset
394
- // This positions them AFTER all clones (not just after wrapping area)
395
- let newOffset = 0
396
-
397
- // Ticker boundary pattern: Check if item should be at the END or at the START
398
- // When container cycles (boundedPos wraps from ~originalItemsWidth to ~0),
399
- // items with large offsets get reset back to 0
400
-
401
- // Wrap distance includes the trailing gap for seamless cycling
402
- const wrapOffset = totalWidth + gap
403
-
404
- // Check if we're in the "reset zone" near wrap boundaries
405
- const nearForwardWrap = boundedPos > originalItemsWidth - gap
406
- const nearReverseWrap = boundedPos < gap
407
-
408
- // RESET: When in reset zone, reset items and SKIP wrap checks to avoid fighting
409
- if (nearForwardWrap && itemWrapOffsets[i] === wrapOffset) {
410
- // Container about to wrap (forward), reset items at END back to START
411
- newOffset = 0
412
- } else if (nearReverseWrap && itemWrapOffsets[i] === -wrapOffset) {
413
- // Container about to wrap (reverse), reset items at START back to END
414
- newOffset = 0
415
- } else if (nearForwardWrap || nearReverseWrap) {
416
- // In reset zone but item doesn't need reset → keep current offset
417
- newOffset = itemWrapOffsets[i]
418
- } else if (itemLeft < -(widths[i] + containerWidth * 0.5)) {
419
- // Item exited LEFT edge - only wrap during forward scroll
420
- // During reverse scroll (scrollDirection < 0), items off-screen left
421
- // will naturally scroll back into view - don't wrap them
422
- const isForwardScroll = scrollDirection >= 0
423
- newOffset = (isForwardScroll && boundedPos < originalItemsWidth / 2) ? wrapOffset : 0
424
- } else if (itemLeft > containerWidth + containerWidth * 0.5) {
425
- // Item exited RIGHT edge
426
- // This shouldn't happen much, but handle it
427
- newOffset = 0
428
- } else {
429
- // Keep current offset
430
- newOffset = itemWrapOffsets[i]
355
+ // Wrap threshold: when an item is more than half a cycle from view, wrap it
356
+ const wrapThreshold = cycleDistance / 2
357
+
358
+ for (let i = 0; i < items.length; i++) {
359
+ // Calculate this item's effective position (DOM position + wrap offset)
360
+ const effectivePos = offsetLefts[i] + itemWrapOffsets[i]
361
+
362
+ // Distance from current view position
363
+ // Positive = item is ahead (to the right), Negative = item is behind (to the left)
364
+ const distanceFromView = effectivePos - rawPos
365
+
366
+ let newOffset = itemWrapOffsets[i]
367
+
368
+ if (distanceFromView < -wrapThreshold) {
369
+ // Item is too far left (behind), wrap it forward (to the right)
370
+ newOffset = itemWrapOffsets[i] + cycleDistance
371
+ } else if (distanceFromView > wrapThreshold + containerWidth) {
372
+ // Item is too far right (ahead), wrap it backward (to the left)
373
+ newOffset = itemWrapOffsets[i] - cycleDistance
431
374
  }
432
375
 
433
- // ONLY update transform if the offset has changed!
376
+ // Only update DOM if offset changed
434
377
  if (newOffset !== itemWrapOffsets[i]) {
435
- item.style.transform = newOffset !== 0 ? `translateX(${newOffset}px)` : 'none'
378
+ items[i].style.transform = newOffset !== 0 ? `translateX(${newOffset}px)` : 'none'
436
379
  itemWrapOffsets[i] = newOffset
437
380
  }
438
- })
381
+ }
439
382
  }
440
383
 
441
384
  /**
@@ -443,32 +386,58 @@ function horizontalLoop(app, items, config) {
443
386
  * @param {boolean} deep - Whether to rebuild animation (on resize)
444
387
  */
445
388
  function refresh(deep = false) {
446
- // Save progress to preserve position
447
- const progress = animation ? animation.time / animation.duration : 0
448
- const currentPos = position.get()
449
-
450
389
  // Pause animation if running
451
390
  const wasPlaying = animation && animation.speed !== 0
452
391
  if (animation) {
453
392
  animation.pause()
454
393
  }
455
394
 
395
+ if (deep && shouldLoop) {
396
+ // DEEP REFRESH: Reset everything for new dimensions
397
+ // Clear all item wrap offsets and transforms
398
+ for (let i = 0; i < items.length; i++) {
399
+ items[i].style.transform = 'none'
400
+ if (itemWrapOffsets[i] !== undefined) {
401
+ itemWrapOffsets[i] = 0
402
+ }
403
+ }
404
+ itemWrapOffsets = []
405
+ }
406
+
456
407
  // Remeasure everything
457
408
  populateWidths()
458
409
 
459
410
  if (deep) {
460
411
  // Check if we need to replicate more items
461
- const containerWidth = container.offsetWidth
412
+ const currentContainerWidth = container.offsetWidth
462
413
  const currentTotalWidth = getTotalWidthOfItems()
463
414
 
464
415
  // Use same 2.5x buffer as replication logic
465
- if (shouldLoop && currentTotalWidth < containerWidth * 2.5) {
416
+ if (shouldLoop && currentTotalWidth < currentContainerWidth * 2.5) {
466
417
  replicateItemsIfNeeded()
418
+ // Re-cache clone status for any new items
419
+ isCloneCache = items.map((item, i) => i >= originalItemCount)
467
420
  populateWidths()
468
421
  }
469
422
 
470
423
  populateSnapTimes()
471
424
 
425
+ // Reset position to start at first clone (like initial state)
426
+ if (shouldLoop && !config.centerSlide) {
427
+ position.set(originalItemsWidth)
428
+ lastPositionForDirection = originalItemsWidth
429
+ items[0].parentElement.style.transform = `translateX(${-originalItemsWidth}px)`
430
+ } else if (shouldLoop && config.centerSlide) {
431
+ // For center mode, go to middle slide
432
+ const middleIndex = Math.floor(originalItemCount / 2)
433
+ const targetTime = times[middleIndex]
434
+ const initialPos = targetTime * pixelsPerSecond
435
+ position.set(initialPos)
436
+ lastPositionForDirection = initialPos
437
+ items[0].parentElement.style.transform = `translateX(${-initialPos}px)`
438
+ curIndex = middleIndex
439
+ }
440
+
472
441
  // Recreate animation with new measurements
473
442
  if (shouldLoop && config.crawl) {
474
443
  // Stop old animation
@@ -476,7 +445,7 @@ function horizontalLoop(app, items, config) {
476
445
  animation.stop()
477
446
  }
478
447
 
479
- // Use startLoopAnimation for bounded position (no repeat: Infinity!)
448
+ // Recreate loop animation with new measurements
480
449
  animation = startLoopAnimation()
481
450
 
482
451
  // Restore playback state
@@ -499,19 +468,20 @@ function horizontalLoop(app, items, config) {
499
468
  })
500
469
 
501
470
  if (wasPlaying) {
502
- animation.time = progress * animation.duration
503
471
  animation.play()
504
472
  } else {
505
473
  animation.pause()
506
474
  }
507
475
  }
476
+
477
+ // Update index display
478
+ updateIndexDisplay()
508
479
  } else {
509
480
  // Light refresh - just update measurements
510
481
  populateSnapTimes()
482
+ // Update positions based on current scroll
483
+ updateItemPositions(position.get())
511
484
  }
512
-
513
- // Update positions based on current scroll
514
- updateItemPositions(currentPos)
515
485
  }
516
486
 
517
487
  /**
@@ -541,7 +511,7 @@ function horizontalLoop(app, items, config) {
541
511
  : currentPos + originalItemsWidth
542
512
 
543
513
  // Animate the position motionValue
544
- // frame.render loop will apply bounded position to DOM
514
+ // frame.render loop will apply raw position to container and wrap items
545
515
  animation = animate(position, target, {
546
516
  duration,
547
517
  repeat: Infinity,
@@ -551,6 +521,21 @@ function horizontalLoop(app, items, config) {
551
521
  return animation
552
522
  }
553
523
 
524
+ /**
525
+ * Stop the frame.render loop and cleanup listeners
526
+ * Called from init() and destroy()
527
+ */
528
+ function stopRenderLoop() {
529
+ if (renderUnsubscribe) {
530
+ // Unsubscribe from motionValue listener
531
+ if (renderUnsubscribe.positionUnsubscribe) {
532
+ renderUnsubscribe.positionUnsubscribe()
533
+ }
534
+ cancelFrame(renderUnsubscribe)
535
+ renderUnsubscribe = null
536
+ }
537
+ }
538
+
554
539
  /**
555
540
  * Initialize the loop animation
556
541
  */
@@ -571,82 +556,45 @@ function horizontalLoop(app, items, config) {
571
556
  // Set initial container position
572
557
  const containerElement = items[0].parentElement
573
558
  containerElement.style.willChange = 'transform'
574
- containerElement.style.transform = 'translateX(0px)'
575
559
 
576
- // Set up RAF loop to check item positions for wrapping
577
- // Frame.render loop to apply bounded position to DOM
560
+ // For looping (non-center mode): start viewing first CLONE, not originals
561
+ // This positions originals OFF-SCREEN LEFT so backward scroll reveals them smoothly
562
+ if (shouldLoop && !config.centerSlide) {
563
+ position.set(originalItemsWidth)
564
+ lastPositionForDirection = originalItemsWidth
565
+ containerElement.style.transform = `translateX(${-originalItemsWidth}px)`
566
+ } else {
567
+ containerElement.style.transform = 'translateX(0px)'
568
+ }
569
+
570
+ // Set up RAF loop to update container position and wrap items
571
+ // Uses RAW position (no modulo) for container transform
578
572
  // This is Motion's optimized render loop - prevents layout thrashing
579
573
  function startRenderLoop() {
580
574
  if (renderUnsubscribe) return // Already running
581
575
 
582
576
  const containerElement = items[0].parentElement
583
577
 
584
- // Set up boundedPos motionValue to automatically sync with position
585
- // This calculates the bounded position (0 to originalItemsWidth)
578
+ // Track scroll direction for wrap logic
586
579
  const positionUnsubscribe = position.on('change', latest => {
587
- // Track scroll direction for wrap logic
588
580
  const delta = latest - lastPositionForDirection
589
581
  if (Math.abs(delta) > 1) {
590
582
  scrollDirection = delta > 0 ? 1 : -1
591
583
  }
592
584
  lastPositionForDirection = latest
593
-
594
- const bounded = ((latest % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
595
- boundedPos.set(bounded)
596
- })
597
-
598
- // Detect when boundedPos wraps (makes large jump) and reset all items
599
- // This prevents stuck items during fast drags in either direction
600
- const boundedPosUnsubscribe = boundedPos.on('change', latest => {
601
- const delta = Math.abs(latest - lastBoundedValue)
602
-
603
- // If boundedPos jumped by more than 40% of the width, it wrapped
604
- // Using 40% instead of 50% to catch edge cases
605
- const didWrap = delta > originalItemsWidth * 0.4
606
-
607
- if (didWrap) {
608
- // NOTE: We intentionally do NOT reset item transforms here anymore.
609
- // Resetting here caused flash because it happens between render frames.
610
- // The updateItemPositions() in frame.render handles wrapping correctly
611
- // when called with the new boundedPos.
612
-
613
- // Sync position to bounded value (only when not dragging/animating)
614
- if (!snapAnimation && !navAnimation && !isDragging) {
615
- position.set(latest)
616
- }
617
- }
618
-
619
- lastBoundedValue = latest
620
585
  })
621
586
 
622
587
  renderUnsubscribe = frame.render(() => {
623
- // Read bounded position from motionValue
624
- const currentBoundedPos = boundedPos.get()
625
-
626
- // Apply bounded transform to container
627
- containerElement.style.transform = `translateX(${-currentBoundedPos}px)`
588
+ // Use RAW position (no bounded) for container
589
+ const currentPos = position.get()
590
+ containerElement.style.transform = `translateX(${-currentPos}px)`
628
591
 
629
- // Wrap items based on bounded position
630
- updateItemPositions(currentBoundedPos)
592
+ // Wrap items based on raw position
593
+ updateItemPositions(currentPos)
631
594
  }, true) // true = keep alive
632
595
 
633
- // Store unsubscribe functions for cleanup
596
+ // Store unsubscribe function for cleanup
634
597
  renderUnsubscribe.positionUnsubscribe = positionUnsubscribe
635
- renderUnsubscribe.boundedPosUnsubscribe = boundedPosUnsubscribe
636
- }
637
-
638
- function stopRenderLoop() {
639
- if (renderUnsubscribe) {
640
- // Unsubscribe from motionValue listeners
641
- if (renderUnsubscribe.positionUnsubscribe) {
642
- renderUnsubscribe.positionUnsubscribe()
643
- }
644
- if (renderUnsubscribe.boundedPosUnsubscribe) {
645
- renderUnsubscribe.boundedPosUnsubscribe()
646
- }
647
- cancelFrame(renderUnsubscribe)
648
- renderUnsubscribe = null
649
- }
650
598
  }
651
599
 
652
600
  // Start the frame.render loop
@@ -734,7 +682,7 @@ function horizontalLoop(app, items, config) {
734
682
  // Update display in real-time as position changes
735
683
  let lastDisplayedIndex = -1
736
684
  const updateIndexOnChange = () => {
737
- // Find closest slide to current bounded position
685
+ // Find closest slide to current position
738
686
  const closest = closestIndex(false)
739
687
 
740
688
  // Only update DOM if index changed (avoid thrashing)
@@ -747,12 +695,8 @@ function horizontalLoop(app, items, config) {
747
695
  }
748
696
  }
749
697
 
750
- // For looping, use boundedPos; for non-looping, use position directly
751
- if (shouldLoop) {
752
- boundedPos.on('change', updateIndexOnChange)
753
- } else {
754
- position.on('change', updateIndexOnChange)
755
- }
698
+ // Update index display whenever position changes (closestIndex normalizes internally)
699
+ position.on('change', updateIndexOnChange)
756
700
  }
757
701
  }
758
702
 
@@ -922,9 +866,9 @@ function horizontalLoop(app, items, config) {
922
866
  const newPosition = startPosition + deltaX
923
867
 
924
868
  // Update position motionValue
925
- // frame.render loop will apply bounded transform to DOM
869
+ // frame.render loop applies raw position to container
926
870
  if (shouldLoop) {
927
- // For looping, allow unbounded position (frame.render will bound it)
871
+ // For looping, position grows freely - items wrap as groups
928
872
  position.set(newPosition)
929
873
  } else {
930
874
  // For non-looping, clamp position to maxScrollPosition (last item at right edge)
@@ -1513,13 +1457,18 @@ function horizontalLoop(app, items, config) {
1513
1457
  closestIndex(true)
1514
1458
  let nextIndex = curIndex + 1
1515
1459
 
1516
- // Non-looping: reset to start when reaching the end
1460
+ // Non-looping: clamp at boundaries
1517
1461
  if (!shouldLoop) {
1518
1462
  const currentPos = position.get()
1519
1463
  const atEnd = currentPos >= maxScrollPosition - 1
1520
1464
 
1521
1465
  if (nextIndex >= originalItemCount || atEnd) {
1522
- nextIndex = 0
1466
+ // Autoplay resets to start, user navigation clamps
1467
+ if (options.autoplay) {
1468
+ nextIndex = 0
1469
+ } else {
1470
+ return // Clamp - do nothing at boundary
1471
+ }
1523
1472
  }
1524
1473
  }
1525
1474
 
@@ -1535,9 +1484,14 @@ function horizontalLoop(app, items, config) {
1535
1484
  closestIndex(true)
1536
1485
  let prevIndex = curIndex - 1
1537
1486
 
1538
- // Non-looping: reset to end when at the start
1487
+ // Non-looping: clamp at boundaries
1539
1488
  if (!shouldLoop && prevIndex < 0) {
1540
- prevIndex = originalItemCount - 1
1489
+ // Autoplay resets to end, user navigation clamps
1490
+ if (options.autoplay) {
1491
+ prevIndex = originalItemCount - 1
1492
+ } else {
1493
+ return // Clamp - do nothing at boundary
1494
+ }
1541
1495
  }
1542
1496
 
1543
1497
  return toIndex(prevIndex, vars)
package/types/index.d.ts CHANGED
@@ -3,4 +3,4 @@ import imagesAreLoaded from './utils/imagesAreLoaded';
3
3
  import loadScript from './utils/loadScript';
4
4
  import prefersReducedMotion from './utils/prefersReducedMotion';
5
5
  import rafCallback from './utils/rafCallback';
6
- export { Application, Breakpoints, Cookies, CoverOverlay, Dataloader, Dom, DoubleHeader, Draggable, Dropdown, EqualHeightElements, EqualHeightImages, Events, FixedHeader, FooterReveal, Parallax, HeroSlider, HeroVideo, Lazyload, Lightbox, Links, Looper, Marquee, MobileMenu, Moonwalk, Popover, Popup, ScrollSpy, StackedBoxes, StickyHeader, Toggler, Typography, imageIsLoaded, imagesAreLoaded, loadScript, prefersReducedMotion, rafCallback, _defaultsDeep };
6
+ export { Application, Breakpoints, Cookies, CoverOverlay, Dataloader, Dom, DoubleHeader, Dropdown, EqualHeightElements, EqualHeightImages, Events, FixedHeader, FooterReveal, Parallax, HeroSlider, HeroVideo, Lazyload, Lightbox, Links, Looper, Marquee, MobileMenu, Moonwalk, Popover, Popup, ScrollSpy, StackedBoxes, StickyHeader, Toggler, Typography, imageIsLoaded, imagesAreLoaded, loadScript, prefersReducedMotion, rafCallback, _defaultsDeep, animate, scroll, stagger, motionValue };
@@ -16,6 +16,24 @@ export default class Cookies {
16
16
  btns: Element;
17
17
  btn: Element;
18
18
  btnRefuse: Element;
19
+ /**
20
+ * Find all `[data-cookie-consent]` elements and wire them up.
21
+ */
22
+ setupConsentToggles(): void;
23
+ consentToggles: Element[];
24
+ /**
25
+ * Update a single consent toggle element to reflect current cookie state.
26
+ * @param {Element} el - The toggle element
27
+ */
28
+ updateConsentToggle(el: Element): void;
29
+ /**
30
+ * Update all consent toggle elements.
31
+ */
32
+ updateConsentToggles(): void;
33
+ /**
34
+ * Handle a click on a consent toggle button.
35
+ */
36
+ handleConsentToggle(): void;
19
37
  /**
20
38
  * Get a cookie value by key
21
39
  * @param {string} sKey - Cookie key
@@ -78,4 +96,8 @@ export type CookiesOptions = {
78
96
  * - Custom function to display cookie consent dialog
79
97
  */
80
98
  showCC?: Function;
99
+ /**
100
+ * - Called after consent is toggled via the toggle button
101
+ */
102
+ onConsentChanged?: Function;
81
103
  };
@@ -20,6 +20,7 @@ export default class DoubleHeader {
20
20
  mobileMenuOpen: boolean;
21
21
  timer: any;
22
22
  resetResizeTimer: any;
23
+ scrollSettleTimeout: NodeJS.Timeout;
23
24
  firstReveal: boolean;
24
25
  initialize(): void;
25
26
  setupObserver(): void;
@@ -5,9 +5,9 @@ export default class Dropdown {
5
5
  elements: {};
6
6
  open: boolean;
7
7
  element: any;
8
- timeline: any;
9
8
  handleDocumentClick(event: any): void;
10
9
  initialize(): void;
10
+ positionMenu(): void;
11
11
  onClick(event: any): Promise<void>;
12
12
  openMenu(): Promise<void>;
13
13
  closeMenu(): Promise<void>;
@@ -30,6 +30,7 @@ export default class FixedHeader {
30
30
  mobileMenuOpen: boolean;
31
31
  timer: any;
32
32
  resetResizeTimer: any;
33
+ scrollSettleTimeout: NodeJS.Timeout;
33
34
  intersectingElements: any;
34
35
  initialize(): void;
35
36
  pageIsScrolledOnReady: boolean;
@@ -19,10 +19,20 @@ export default class HeroSlider {
19
19
  * Switches between slides
20
20
  */
21
21
  slide(type: any): void;
22
+ _currentAnimation: any;
22
23
  /**
23
24
  * Add a window resize handler that resizes slide widths
24
25
  */
25
26
  _addResizeHandler(): void;
26
27
  observer: IntersectionObserver;
27
28
  _resizeSlides(): void;
29
+ resizeAnimation: any;
30
+ /**
31
+ * Add a visibility change handler to restart animations when tab becomes visible
32
+ */
33
+ _addVisibilityHandler(): void;
34
+ /**
35
+ * Reset slide states and restart the animation cycle
36
+ */
37
+ _resetAndRestart(): void;
28
38
  }
@@ -15,6 +15,13 @@ export default class Lazyload {
15
15
  rafId: number;
16
16
  srcsetReadyObserver: MutationObserver;
17
17
  watch(): void;
18
+ /**
19
+ * Observe new lazyload elements within a container
20
+ * Handles both [data-ll-image] and [data-ll-srcset] elements
21
+ * Useful for dynamically added content (e.g., Looper clones)
22
+ * @param {HTMLElement|HTMLElement[]|NodeList} elements - Container element(s) or lazyload element(s) to observe
23
+ */
24
+ observe(elements: HTMLElement | HTMLElement[] | NodeList): void;
18
25
  initialize(): void;
19
26
  lazyPictures: any;
20
27
  loadObserver: IntersectionObserver;
@@ -19,8 +19,8 @@ export default class Lightbox {
19
19
  firstTransition: boolean;
20
20
  previousCaption: any;
21
21
  timelines: {
22
- caption: any;
23
- image: any;
22
+ caption: PausedTimeline;
23
+ image: PausedTimeline;
24
24
  };
25
25
  showBox(section: any, index: any): void;
26
26
  buildBox(section: any, index: any): void;
@@ -121,3 +121,4 @@ export type LightboxOptions = {
121
121
  */
122
122
  onClose?: Function;
123
123
  };
124
+ import { PausedTimeline } from '../../utils/motion-helpers';
@@ -1,127 +1,14 @@
1
1
  /**
2
- * Options for configuring the Looper module
3
- */
4
- export interface LooperOptions {
5
- /** Center the loop container. Can be true, false, or a selector string */
6
- center?: boolean | string;
7
- /** Enable snap-to-item behavior. Can be true, false, or a number for custom snap distance */
8
- snap?: boolean | number;
9
- /** Enable continuous auto-scrolling */
10
- crawl?: boolean;
11
- /** Enable infinite looping. If false, creates linear scrolling */
12
- loop?: boolean;
13
- /** Enable drag interaction with mouse/touch */
14
- draggable?: boolean;
15
- /** Speed multipliers for different breakpoints */
16
- speed?: {
17
- sm?: number;
18
- lg?: number;
19
- };
20
- /** Easing configuration for hover interactions */
21
- ease?: {
22
- mouseOver?: {
23
- speed?: number;
24
- duration?: number;
25
- };
26
- mouseOut?: {
27
- speed?: number;
28
- duration?: number;
29
- };
30
- };
31
- /** CSS selector for looper elements */
32
- selector?: string;
33
- /** Extra padding on the right side in pixels */
34
- paddingRight?: number | string;
35
- /** Start in reversed direction */
36
- reversed?: boolean;
37
- }
38
-
39
- /**
40
- * Loop controller returned by horizontalLoop function
41
- */
42
- export interface LoopController {
43
- /** Motion value tracking the current position */
44
- position: any;
45
- /** Main animation instance */
46
- animation: any;
47
- /** Array of item elements */
48
- items: HTMLElement[];
49
- /** Array of snap time positions */
50
- times: number[];
51
- /** Whether the loop is reversed */
52
- isReversed: boolean;
53
- /** Whether the loop is in looping mode */
54
- isLooping: boolean;
55
-
56
- /** Start/resume the loop animation */
57
- play(): void;
58
-
59
- /** Pause the loop animation */
60
- pause(): void;
61
-
62
- /** Get the current active item index */
63
- current(): number;
64
-
65
- /** Find the closest item index to current position */
66
- closestIndex(setCurrent?: boolean): number;
67
-
68
- /** Navigate to the next item */
69
- next(vars?: { duration?: number; easing?: string }): any;
70
-
71
- /** Navigate to the previous item */
72
- previous(vars?: { duration?: number; easing?: string }): any;
73
-
74
- /** Navigate to a specific item index */
75
- toIndex(index: number, vars?: { duration?: number; easing?: string }): any;
76
-
77
- /** Refresh measurements and recalculate (useful after resize) */
78
- refresh(deep?: boolean): void;
79
-
80
- /** Clean up and destroy the loop */
81
- destroy(): void;
82
- }
83
-
84
- /**
85
- * Looper Module
86
- * Creates seamless horizontal infinite scrolling carousels with drag interaction,
87
- * momentum/inertia, auto-crawl, snap-to-item, and responsive resize handling
2
+ * Looper Module Class
88
3
  */
89
4
  export default class Looper {
90
- /**
91
- * Create a new Looper instance
92
- * @param app - Jupiter application instance
93
- * @param opts - Configuration options
94
- */
95
- constructor(app: any, opts?: Partial<LooperOptions>);
96
-
97
- /** Configuration options */
98
- opts: LooperOptions;
99
-
100
- /** Jupiter application instance */
101
- app: any;
102
-
103
- /** Array of active loop controllers */
104
- loopers: LoopController[];
105
-
106
- /** Array of pending loopers waiting for initialization */
107
- pendingLoopers: any[];
108
-
109
- /** DOM elements matching the selector */
110
- looperElements: HTMLElement[];
111
-
112
- /**
113
- * Initialize the module and find all looper elements
114
- */
115
- init(): void;
116
-
117
- /**
118
- * Finalize loopers after APPLICATION:REVEALED event
119
- * This is when actual measurements and animations are set up
120
- */
121
- finalizeLoopers(): void;
122
-
123
- /**
124
- * Destroy all loopers and clean up
125
- */
126
- destroy(): void;
5
+ constructor(app: any, opts?: {});
6
+ app: any;
7
+ opts: any;
8
+ loopers: any[];
9
+ pendingLoopers: any[];
10
+ init(): void;
11
+ looperElements: any;
12
+ finalizeLoopers(): void;
13
+ destroy(): void;
127
14
  }
@@ -11,6 +11,7 @@ export default class Marquee {
11
11
  duration: number;
12
12
  clearHolders(): void;
13
13
  killTweens(): void;
14
+ speedAnimation: any;
14
15
  initializeTween(): void;
15
16
  play(rampUp?: boolean): void;
16
17
  playing: boolean;
@@ -19,5 +20,6 @@ export default class Marquee {
19
20
  speedUp(): void;
20
21
  setupObserver(): void;
21
22
  fillText(): void;
23
+ measuredHeight: any;
22
24
  setHeight(): void;
23
25
  }
@@ -15,7 +15,11 @@ export default class Moonwalk {
15
15
  id: string;
16
16
  el: any;
17
17
  name: any;
18
- timeline: any;
18
+ animation: {
19
+ lastDelay: number;
20
+ lastDuration: number;
21
+ lastStartTime: any;
22
+ };
19
23
  observer: any;
20
24
  stage: {
21
25
  name: any;
@@ -91,7 +95,11 @@ export default class Moonwalk {
91
95
  id: string;
92
96
  el: any;
93
97
  name: any;
94
- timeline: any;
98
+ animation: {
99
+ lastDelay: number;
100
+ lastDuration: number;
101
+ lastStartTime: any;
102
+ };
95
103
  observer: any;
96
104
  stage: {
97
105
  name: any;
@@ -104,7 +112,11 @@ export default class Moonwalk {
104
112
  id: string;
105
113
  el: any;
106
114
  name: any;
107
- timeline: any;
115
+ animation: {
116
+ lastDelay: number;
117
+ lastDuration: number;
118
+ lastStartTime: any;
119
+ };
108
120
  observer: any;
109
121
  stage: {
110
122
  name: any;
@@ -153,6 +165,24 @@ export default class Moonwalk {
153
165
  * @param {*} children
154
166
  */
155
167
  orderChildren(children: any): any[];
168
+ /**
169
+ * Calculate the delay for the next animation in the section.
170
+ * This replaces GSAP's timeline.recent() logic.
171
+ *
172
+ * @param {*} section - The section object
173
+ * @param {*} duration - Duration of the animation to add
174
+ * @param {*} overlap - How much the animations should overlap
175
+ * @returns {number} The delay in seconds
176
+ */
177
+ calculateDelay(section: any, duration: any, overlap: any): number;
178
+ /**
179
+ * Update the animation state after adding an animation.
180
+ *
181
+ * @param {*} section - The section object
182
+ * @param {*} delay - The delay that was used
183
+ * @param {*} duration - The duration of the animation
184
+ */
185
+ updateAnimationState(section: any, delay: any, duration: any): void;
156
186
  onReady(): void;
157
187
  /**
158
188
  * Called on `APPLICATION_READY` event, if `config.fireOnReady`.
@@ -16,7 +16,7 @@ export default class Popover {
16
16
  handleClick(e: any): void;
17
17
  get isVisible(): boolean;
18
18
  show(): void;
19
- updatePosition(animate?: boolean): void;
19
+ updatePosition(shouldAnimate?: boolean): void;
20
20
  hide(): void;
21
21
  toggle(): void;
22
22
  addDocumentClickHandler(): void;
@@ -31,6 +31,7 @@ export default class StickyHeader {
31
31
  mobileMenuOpen: boolean;
32
32
  timer: any;
33
33
  resetResizeTimer: any;
34
+ scrollSettleTimeout: NodeJS.Timeout;
34
35
  intersectingElements: any;
35
36
  initialize(): void;
36
37
  pageIsScrolledOnReady: boolean;
@@ -2,6 +2,22 @@
2
2
  * Toggler component for show/hide functionality
3
3
  * Uses [data-toggle-trigger] for the toggle button and [data-toggle-content] for toggleable content
4
4
  * Can be grouped using [data-toggle-group] to create accordion-like behavior
5
+ *
6
+ * IMPORTANT: For smooth animations, avoid padding/margins on [data-toggle-content].
7
+ * Instead, wrap content in a child element with padding/margins:
8
+ *
9
+ * @example
10
+ * // ❌ DON'T: Padding/margins directly on toggle content
11
+ * <div data-toggle-content style="padding: 20px; margin-top: 10px">
12
+ * Content here
13
+ * </div>
14
+ *
15
+ * // ✅ DO: Wrap content in child element
16
+ * <div data-toggle-content>
17
+ * <div style="padding: 20px; margin-top: 10px">
18
+ * Content here
19
+ * </div>
20
+ * </div>
5
21
  */
6
22
  export default class Toggler {
7
23
  /**
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Set properties immediately (like gsap.set)
3
+ * Uses direct DOM manipulation for synchronous style application
4
+ *
5
+ * @param {Element|string|NodeList|Array} target - Element(s) or selector
6
+ * @param {Object} values - Properties to set
7
+ */
8
+ export function set(target: Element | string | NodeList | any[], values: any): void;
9
+ /**
10
+ * Animate autoAlpha (opacity + visibility)
11
+ * Mimics GSAP's autoAlpha property
12
+ *
13
+ * @param {Element|string} target - Element or selector
14
+ * @param {number} value - Target alpha value (0 or 1)
15
+ * @param {Object} options - Animation options
16
+ * @returns {Object} Animation object
17
+ */
18
+ export function animateAutoAlpha(target: Element | string, value: number, options?: any): any;
19
+ /**
20
+ * Clear inline styles
21
+ * Mimics GSAP's clearProps
22
+ *
23
+ * @param {Element|string|NodeList|Array} target - Element(s) or selector
24
+ * @param {string|Array} props - Properties to clear or 'all'
25
+ */
26
+ export function clearProps(target: Element | string | NodeList | any[], props?: string | any[]): void;
27
+ /**
28
+ * Delayed call helper
29
+ * Mimics gsap.delayedCall
30
+ * Uses Motion's delay function (locked to animation frame loop for better sync)
31
+ *
32
+ * @param {number} duration - Delay in seconds
33
+ * @param {Function} callback - Callback function
34
+ * @returns {Promise} Promise that resolves after delay
35
+ */
36
+ export function delayedCall(duration: number, callback: Function): Promise<any>;
37
+ /**
38
+ * Convert GSAP easing strings to Motion.js compatible easings
39
+ * Handles common GSAP easing types and returns valid Motion.js easing
40
+ *
41
+ * @param {string|Array} easing - GSAP easing string or bezier array
42
+ * @returns {string|Array} Motion.js compatible easing
43
+ *
44
+ * Valid Motion.js easings:
45
+ * - "linear"
46
+ * - "easeIn"
47
+ * - "easeInOut"
48
+ * - "easeOut"
49
+ * - "circIn"
50
+ * - "circInOut"
51
+ * - "circOut"
52
+ * - "backIn"
53
+ * - "backInOut"
54
+ * - "backOut"
55
+ * - "anticipate"
56
+ * - Bezier arrays: [x1, y1, x2, y2]
57
+ */
58
+ export function convertEasing(easing: string | any[]): string | any[];
59
+ /**
60
+ * Paused Timeline helper
61
+ * Mimics GSAP's paused timeline pattern for building sequences
62
+ * Used by modules like Lightbox that build animations before playing them
63
+ */
64
+ export class PausedTimeline {
65
+ sequence: any[];
66
+ /**
67
+ * Add animation to timeline
68
+ * @param {Element|string} target - Element or selector
69
+ * @param {Object} values - Properties to animate
70
+ * @param {Object} options - Animation options
71
+ * @returns {PausedTimeline} this for chaining
72
+ */
73
+ to(target: Element | string, values: any, options?: any): PausedTimeline;
74
+ /**
75
+ * Add callback to timeline
76
+ * @param {Function} callback - Function to call
77
+ * @returns {PausedTimeline} this for chaining
78
+ */
79
+ call(callback: Function): PausedTimeline;
80
+ /**
81
+ * Clear timeline sequence
82
+ * @returns {PausedTimeline} this for chaining
83
+ */
84
+ clear(): PausedTimeline;
85
+ /**
86
+ * Play timeline sequence
87
+ * Executes all animations and callbacks in order
88
+ * @returns {Promise} Promise that resolves when sequence completes
89
+ */
90
+ play(): Promise<any>;
91
+ }