1document.addEventListener('DOMContentLoaded', function () { 2 const hero = document.querySelector('.js-tt-home-hero'); 3 const heroVisual = hero 4 ? hero.querySelector('.tt-home-hero__visual-wrap') 5 : null; 6 const textFillSections = document.querySelectorAll('.js-tt-text-fill'); 7 8 const benefitsViewport = document.querySelector('.js-key-benefits-viewport'); 9 const benefitsTrack = benefitsViewport 10 ? benefitsViewport.querySelector('.tt-home-key-benefits__track') 11 : null; 12 const prevButton = document.querySelector('.js-key-benefits-prev'); 13 const nextButton = document.querySelector('.js-key-benefits-next'); 14 15 const useCasesViewport = document.querySelector('.js-use-cases-viewport'); 16 const useCasesTrack = useCasesViewport 17 ? useCasesViewport.querySelector('.tt-home-use-cases__track') 18 : null; 19 const useCasesPrevButton = document.querySelector('.js-use-cases-prev'); 20 const useCasesNextButton = document.querySelector('.js-use-cases-next'); 21 22 const featureStackSection = document.querySelector('.js-tt-feature-stack'); 23 const featureScrollArea = featureStackSection 24 ? featureStackSection.querySelector('.tt-home-features__scroll') 25 : null; 26 const featureSticky = featureStackSection 27 ? featureStackSection.querySelector('.tt-home-features__sticky') 28 : null; 29 const featureStack = featureStackSection 30 ? featureStackSection.querySelector('.tt-home-features__stack') 31 : null; 32 const featureCards = featureStackSection 33 ? Array.from(featureStackSection.querySelectorAll('.js-tt-feature-card')) 34 : []; 35 36 const userStoriesCarousel = document.querySelector('.js-user-stories-carousel'); 37 const userStoriesTrack = document.querySelector('.js-user-stories-track'); 38 39 let animTicking = false; 40 let cachedFeatureStackSettings = null; 41 let cachedHeroAnimationDistance = 1; 42 let cachedViewportHeight = 0; 43 let cachedHeroPreScrollWidth = 1260; 44 let cachedHeroPreScrollHeight = 680; 45 let cachedHeroSettledWidth = 1260; 46 let cachedHeroSettledHeight = 680; 47 let cachedFeatureAvailableScroll = 1; 48 let activeFeatureCardIndex = 0; 49 let featureStackStatus = null; 50 51 function clamp(value, min, max) { 52 return Math.max(min, Math.min(max, value)); 53 } 54 55 function easeInOutCubic(t) { 56 return t < 0.5 57 ? 4 * t * t * t 58 : 1 - Math.pow(-2 * t + 2, 3) / 2; 59 } 60 61 const carouselDragClickThreshold = 6; 62 const carouselReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); 63 64 const getStartedSections = document.querySelectorAll('.js-tt-get-started-video'); 65 66 if (getStartedSections.length) { 67 getStartedSections.forEach(function (section) { 68 const video = section.querySelector('.js-tt-get-started-video-player'); 69 const steps = Array.from(section.querySelectorAll('.js-tt-get-started-step')); 70 71 if (!video || !steps.length) { 72 return; 73 } 74 75 let progressFrameId = null; 76 let sectionIsVisible = false; 77 let currentActiveStepIndex = null; 78 let stepSwitchTimeout = null; 79 const stepSwitchDuration = 220; 80 const stepsContainer = section.querySelector('.tt-home-get-started__steps'); 81 82 83 video.muted = true; 84 video.loop = true; 85 video.setAttribute('muted', ''); 86 video.setAttribute('loop', ''); 87 video.setAttribute('playsinline', ''); 88 89 function getStepMarker(step) { 90 const marker = parseFloat(step.getAttribute('data-video-marker')); 91 92 if (Number.isNaN(marker)) { 93 return 0; 94 } 95 96 return marker; 97 } 98 99 function getVideoDuration() { 100 return Number.isFinite(video.duration) && video.duration > 0 101 ? video.duration 102 : 0; 103 } 104 105 function markerToRatio(marker) { 106 const duration = getVideoDuration(); 107 108 if (marker <= 1) { 109 return clamp(marker, 0, 1); 110 } 111 112 if (!duration) { 113 return 0; 114 } 115 116 return clamp(marker / duration, 0, 1); 117 } 118 119 function markerToTime(marker) { 120 const duration = getVideoDuration(); 121 122 if (marker <= 1) { 123 return duration ? marker * duration : 0; 124 } 125 126 return marker; 127 } 128 129 function getStepRatios() { 130 return steps.map(function (step) { 131 return markerToRatio(getStepMarker(step)); 132 }); 133 } 134 135 function getCurrentRatio() { 136 const duration = getVideoDuration(); 137 138 if (!duration) { 139 return 0; 140 } 141 142 return clamp(video.currentTime / duration, 0, 1); 143 } 144 145 function getActiveStepIndex(currentRatio, stepRatios) { 146 let activeIndex = 0; 147 148 stepRatios.forEach(function (ratio, index) { 149 if (currentRatio >= ratio - 0.001) { 150 activeIndex = index; 151 } 152 }); 153 154 return activeIndex; 155 } 156 157function markStepSwitch(nextActiveIndex) { 158 if (currentActiveStepIndex === null) { 159 currentActiveStepIndex = nextActiveIndex; 160 return; 161 } 162 163 if (currentActiveStepIndex === nextActiveIndex) { 164 return; 165 } 166 167 currentActiveStepIndex = nextActiveIndex; 168 169 if (!stepsContainer) { 170 return; 171 } 172 173 stepsContainer.classList.add('is-switching'); 174 175 if (stepSwitchTimeout) { 176 window.clearTimeout(stepSwitchTimeout); 177 } 178 179 stepSwitchTimeout = window.setTimeout(function () { 180 stepsContainer.classList.remove('is-switching'); 181 updateGetStartedSteps(); 182 }, stepSwitchDuration); 183} 184 185function updateGetStartedSteps() { 186 const currentRatio = getCurrentRatio(); 187 const stepRatios = getStepRatios(); 188 const activeIndex = getActiveStepIndex(currentRatio, stepRatios); 189 190 markStepSwitch(activeIndex); 191 192 steps.forEach(function (step, index) { 193 const startRatio = stepRatios[index] || 0; 194 const endRatio = stepRatios[index + 1] !== undefined 195 ? stepRatios[index + 1] 196 : 1; 197 198 let stepProgress = 0; 199 200 if (index < activeIndex) { 201 stepProgress = 1; 202 } else if (index === activeIndex) { 203 const segmentLength = Math.max(0.001, endRatio - startRatio); 204 stepProgress = clamp((currentRatio - startRatio) / segmentLength, 0, 1); 205 } 206 207 step.style.setProperty('--step-progress', stepProgress.toFixed(4)); 208 step.classList.toggle('tt-home-get-started__step--active', index === activeIndex); 209 step.classList.toggle('is-complete', index < activeIndex); 210 211 if (index === activeIndex) { 212 step.setAttribute('aria-current', 'step'); 213 } else { 214 step.removeAttribute('aria-current'); 215 } 216 }); 217 } 218 219 function stopProgressLoop() { 220 if (progressFrameId) { 221 window.cancelAnimationFrame(progressFrameId); 222 progressFrameId = null; 223 } 224 } 225 226 function startProgressLoop() { 227 stopProgressLoop(); 228 229 function tick() { 230 updateGetStartedSteps(); 231 232 if (!video.paused && !video.ended) { 233 progressFrameId = window.requestAnimationFrame(tick); 234 } 235 } 236 237 progressFrameId = window.requestAnimationFrame(tick); 238 } 239 240 function playGetStartedVideo() {
241 const playPromise = video.play(); 242 243 if (playPromise && typeof playPromise.catch === 'function') { 244 playPromise.catch(function () { 245 updateGetStartedSteps(); 246 }); 247 } 248 } 249 250 function pauseGetStartedVideo() { 251 video.pause(); 252 updateGetStartedSteps(); 253 } 254 255 function seekToStep(stepIndex) { 256 const step = steps[stepIndex]; 257 258 if (!step) { 259 return; 260 } 261 262 const duration = getVideoDuration(); 263 264 if (!duration) { 265 video.addEventListener('loadedmetadata', function handleLoadedMetadata() { 266 seekToStep(stepIndex); 267 }, { once: true }); 268 269 video.load(); 270 return; 271 } 272 273 const marker = getStepMarker(step); 274 const targetTime = clamp(markerToTime(marker), 0, duration); 275 276 video.currentTime = targetTime; 277 updateGetStartedSteps(); 278 playGetStartedVideo(); 279 } 280 281 function isSectionInView() { 282 const rect = section.getBoundingClientRect(); 283 const windowHeight = window.innerHeight || document.documentElement.clientHeight; 284 const visibleAmount = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0); 285 const visibleRatio = visibleAmount / Math.max(1, rect.height); 286 287 return visibleRatio >= 0.35; 288 } 289 290 function updateSectionPlayback() { 291 const isVisible = isSectionInView(); 292 293 if (isVisible === sectionIsVisible) { 294 return; 295 } 296 297 sectionIsVisible = isVisible; 298 299 if (sectionIsVisible) { 300 playGetStartedVideo(); 301 } else { 302 pauseGetStartedVideo(); 303 } 304 } 305 306 steps.forEach(function (step, index) { 307 step.addEventListener('click', function () { 308 seekToStep(index); 309 }); 310 }); 311 312 video.addEventListener('loadedmetadata', updateGetStartedSteps); 313 video.addEventListener('durationchange', updateGetStartedSteps); 314 video.addEventListener('timeupdate', updateGetStartedSteps); 315 video.addEventListener('seeking', updateGetStartedSteps); 316 video.addEventListener('seeked', updateGetStartedSteps); 317 video.addEventListener('play', startProgressLoop); 318 video.addEventListener('pause', function () { 319 stopProgressLoop(); 320 updateGetStartedSteps(); 321 }); 322 video.addEventListener('ended', function () { 323 stopProgressLoop(); 324 updateGetStartedSteps(); 325 }); 326 327 if ('IntersectionObserver' in window) { 328 const sectionObserver = new IntersectionObserver(function (entries) { 329 entries.forEach(function (entry) { 330 sectionIsVisible = entry.isIntersecting; 331 332 if (sectionIsVisible) { 333 playGetStartedVideo(); 334 } else { 335 pauseGetStartedVideo(); 336 } 337 }); 338 }, { 339 threshold: 0.35 340 }); 341 342 sectionObserver.observe(section); 343 } else { 344 window.addEventListener('scroll', updateSectionPlayback, { passive: true }); 345 window.addEventListener('resize', updateSectionPlayback); 346 updateSectionPlayback(); 347 } 348 349 updateGetStartedSteps(); 350 }); 351 } 352 353 function updateScrollAnimations() { 354 // Phase 1: do layout reads before DOM writes 355 356 const isMobileHero = hero && window.matchMedia('(max-width: 959px)').matches; 357 const heroRect = hero ? hero.getBoundingClientRect() : null; 358 359 const textFillReads = []; 360 textFillSections.forEach(function (section) { 361 const textBlock = section.querySelector('.tt-home-text-fill__text'); 362 const words = section.querySelectorAll('.js-tt-text-fill-word'); 363 if (!textBlock || !words.length) { return; } 364 textFillReads.push({ words: words, rect: textBlock.getBoundingClientRect() }); 365 }); 366 367 const featureRect = (featureScrollArea && shouldUseFeatureStack()) 368 ? featureScrollArea.getBoundingClientRect() 369 : null; 370 371 // Phase 2: apply DOM writes after layout reads 372 373 if (hero) { 374 if (isMobileHero) { 375 hero.style.setProperty('--hero-progress', '0'); 376 hero.style.setProperty('--hero-settle-progress', '1'); 377 hero.style.setProperty('--hero-visual-width', cachedHeroSettledWidth.toFixed(2) + 'px'); 378 hero.style.setProperty('--hero-visual-height', cachedHeroSettledHeight.toFixed(2) + 'px'); 379 hero.style.setProperty('--hero-video-blur', '0px'); 380 hero.style.setProperty('--hero-video-scale', '1'); 381 } else if (heroRect) {
382 const progress = clamp((-heroRect.top) / cachedHeroAnimationDistance, 0, 1); 383 const settleProgress = progress; 384 const visualWidth = cachedHeroPreScrollWidth 385 + ((cachedHeroSettledWidth - cachedHeroPreScrollWidth) * settleProgress); 386 const visualHeight = cachedHeroPreScrollHeight 387 + ((cachedHeroSettledHeight - cachedHeroPreScrollHeight) * settleProgress); 388 const blurAmount = 16 * (1 - settleProgress); 389 const videoScale = 1 + (0.025 * (1 - settleProgress)); 390 hero.style.setProperty('--hero-progress', progress.toFixed(4)); 391 hero.style.setProperty('--hero-settle-progress', settleProgress.toFixed(4)); 392 hero.style.setProperty('--hero-visual-width', visualWidth.toFixed(2) + 'px'); 393 hero.style.setProperty('--hero-visual-height', visualHeight.toFixed(2) + 'px'); 394 hero.style.setProperty('--hero-video-blur', blurAmount.toFixed(2) + 'px'); 395 hero.style.setProperty('--hero-video-scale', videoScale.toFixed(4)); 396 } 397 } 398 399 const tfStart = cachedViewportHeight * 0.88; 400 const tfEnd = cachedViewportHeight * 0.22; 401 textFillReads.forEach(function (data) { 402 const words = data.words; 403 let progress = (tfStart - data.rect.top) / (tfStart - tfEnd); 404 progress = Math.max(0, Math.min(1, progress)); 405 const filledWords = Math.floor(progress * words.length); 406 words.forEach(function (word, index) { 407 const shouldFill = index < filledWords; 408 const shouldNext = index === filledWords && filledWords < words.length; 409 if (shouldFill !== word.classList.contains('is-filled')) { 410 word.classList.toggle('is-filled', shouldFill); 411 } 412 if (shouldNext !== word.classList.contains('is-next')) { 413 word.classList.toggle('is-next', shouldNext); 414 } 415 }); 416 }); 417 418 if (featureRect && featureCards.length) { 419 const settings = getFeatureStackSettings(); 420 const totalCompression = settings.totalCompression; 421 const overallProgress = clamp((-featureRect.top) / cachedFeatureAvailableScroll, 0, 1); 422 const currentCompression = totalCompression * overallProgress; 423 424 updateFeatureStackAccessibility( 425 getFeatureActiveCardIndex(currentCompression, settings.compressionStops), 426 document.activeElement === featureStack 427 ); 428 429 featureCards.forEach(function (card, index) { 430 const naturalY = settings.naturalPositions[index]; 431 const compressedY = settings.compressedPositions[index]; 432 const translateY = Math.max(compressedY, naturalY - currentCompression); 433 const depthFromTop = getFeatureCardDepth(index, currentCompression, settings.compressionStops); 434 const scale = 1 - Math.min(depthFromTop * 0.015, 0.06); 435 let shadowOpacity = 0; 436 if (index === 0) { 437 shadowOpacity = 0.10 * (1 - overallProgress); 438 } else { 439 const startCompress = settings.compressionStops[index - 1]; 440 const endCompress = settings.compressionStops[index]; 441 const localProgress = clamp( 442 (currentCompression - startCompress) / Math.max(1, endCompress - startCompress), 443 0, 444 1 445 ); 446 shadowOpacity = 0.16 * (1 - localProgress); 447 } 448 card.style.transform = 'translate3d(0, ' + translateY.toFixed(2) + 'px, 0) scale(' + scale + ')'; 449 card.style.boxShadow = '0 16px 40px rgba(24, 35, 47, ' + shadowOpacity.toFixed(4) + ')'; 450 }); 451 } 452 453 animTicking = false; 454 } 455 456 function requestAnimTick() { 457 if (!animTicking) { 458 window.requestAnimationFrame(updateScrollAnimations); 459 animTicking = true; 460 } 461 } 462 463 if (benefitsViewport && benefitsTrack && prevButton && nextButton) { 464 const benefitsCards = Array.from( 465 benefitsTrack.querySelectorAll('.tt-home-key-benefits-card') 466 ); 467 468 let currentBenefitsIndex = 0; 469 let currentTranslate = 0; 470 let benefitsAnimating = false; 471 let dragStartX = 0; 472 let dragStartTranslate = 0; 473 let touchStartX = 0; 474 let touchStartY = 0; 475 let touchStartTranslate = 0; 476 let isTouchDragging = false; 477 let isDragging = false; 478 let suppressBenefitsClick = false; 479 480 function clampBenefitsIndex(index) { 481 return Math.max(0, Math.min(index, benefitsCards.length - 1)); 482 } 483 484 function getSnapPoints() { 485 return benefitsCards.map(function (card) { 486 return Math.round(card.offsetLeft); 487 }); 488 } 489 490 function getMaxTranslate() {
491 return Math.max(0, benefitsTrack.scrollWidth - benefitsViewport.clientWidth); 492 } 493 494 function setTrackTranslate(value) { 495 const maxTranslate = getMaxTranslate(); 496 currentTranslate = Math.max(0, Math.min(value, maxTranslate)); 497 benefitsTrack.style.transform = 'translate3d(' + (-currentTranslate) + 'px, 0, 0)'; 498 } 499 500 function updateBenefitsButtons() { 501 prevButton.disabled = currentBenefitsIndex <= 0; 502 nextButton.disabled = currentBenefitsIndex >= benefitsCards.length - 1; 503 } 504 505 function animateToSnapIndex(index) { 506 const snapPoints = getSnapPoints(); 507 const safeIndex = clampBenefitsIndex(index); 508 const targetTranslate = snapPoints[safeIndex]; 509 const startTranslate = currentTranslate; 510 const distance = targetTranslate - startTranslate; 511 const duration = carouselReducedMotion.matches ? 0 : 520; 512 513 benefitsMomentum.cancel(); 514 515 if (benefitsAnimating) { 516 return; 517 } 518 519 currentBenefitsIndex = safeIndex; 520 updateBenefitsButtons(); 521 522 if (Math.abs(distance) < 1 || duration === 0) { 523 setTrackTranslate(targetTranslate); 524 return; 525 } 526 527 benefitsAnimating = true; 528 const startTime = performance.now(); 529 530 function step(now) { 531 const elapsed = now - startTime; 532 const progress = Math.min(elapsed / duration, 1); 533 const eased = easeInOutCubic(progress); 534 535 setTrackTranslate(startTranslate + (distance * eased)); 536 537 if (progress < 1) { 538 requestAnimationFrame(step); 539 } else { 540 setTrackTranslate(targetTranslate); 541 benefitsAnimating = false; 542 } 543 } 544 545 requestAnimationFrame(step); 546 } 547 548 function getNearestSnapIndex() { 549 const snapPoints = getSnapPoints(); 550 const maxTranslate = getMaxTranslate(); 551 552 if (currentTranslate <= 1) { 553 return 0; 554 } 555 556 if (currentTranslate >= maxTranslate - 1) { 557 return benefitsCards.length - 1; 558 } 559 560 let nearestIndex = 0; 561 let nearestDistance = Infinity; 562 563 snapPoints.forEach(function (point, index) { 564 const distance = Math.abs(point - currentTranslate); 565 566 if (distance < nearestDistance) { 567 nearestDistance = distance; 568 nearestIndex = index; 569 } 570 }); 571 572 return nearestIndex; 573 } 574 575 function syncBenefitsState() { 576 currentBenefitsIndex = clampBenefitsIndex(getNearestSnapIndex()); 577 updateBenefitsButtons(); 578 } 579 580 const benefitsMomentum = window.TTCarouselMomentum.create({ 581 getMin: function () { 582 return 0; 583 }, 584 getMax: getMaxTranslate, 585 setPosition: setTrackTranslate, 586 onSettle: syncBenefitsState 587 }); 588 589 prevButton.addEventListener('click', function () { 590 if (benefitsAnimating) { 591 return; 592 } 593 benefitsMomentum.cancel(); 594 syncBenefitsState(); 595 animateToSnapIndex(currentBenefitsIndex - 1); 596 }); 597 598 nextButton.addEventListener('click', function () { 599 if (benefitsAnimating) { 600 return; 601 } 602 benefitsMomentum.cancel(); 603 syncBenefitsState(); 604 animateToSnapIndex(currentBenefitsIndex + 1); 605 }); 606 607 benefitsViewport.setAttribute('tabindex', '0'); 608 benefitsViewport.setAttribute('aria-label', 'Key benefits carousel'); 609 benefitsViewport.addEventListener('keydown', function (event) { 610 if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { 611 return; 612 } 613 614 event.preventDefault(); 615 benefitsMomentum.cancel(); 616 syncBenefitsState(); 617 animateToSnapIndex(currentBenefitsIndex + (event.key === 'ArrowRight' ? 1 : -1)); 618 }); 619 620 benefitsViewport.addEventListener('pointerdown', function (event) { 621 if (benefitsAnimating || event.pointerType === 'touch') { 622 return; 623 } 624 625 isDragging = true; 626 benefitsMomentum.cancel(); 627 dragStartX = event.clientX; 628 dragStartTranslate = currentTranslate; 629 benefitsMomentum.begin(currentTranslate); 630 suppressBenefitsClick = false; 631 benefitsViewport.style.cursor = 'grabbing'; 632 }); 633 634 let wheelStateTimer; 635 function onWheel(event) { 636 const horizontal = Math.abs(event.deltaX); 637 const vertical = Math.abs(event.deltaY); 638 639 if (horizontal <= vertical) { 640 return; // mostly vertical, allow page scroll 641 } 642 643 event.preventDefault(); 644 645 benefitsMomentum.cancel(); 646 setTrackTranslate(currentTranslate + event.deltaX); 647 648 clearTimeout(wheelStateTimer); 649 650 wheelStateTimer = setTimeout(() => { 651 syncBenefitsState(); 652 }, 120); 653 } 654 655 benefitsViewport.addEventListener( 656 'wheel', 657 onWheel, 658 { passive: false } 659 ); 660 661 window.addEventListener('pointermove', function (event) { 662 if (!isDragging || benefitsAnimating) { 663 return; 664 } 665 666 const deltaX = event.clientX - dragStartX; 667 668 if (Math.abs(deltaX) > carouselDragClickThreshold) { 669 suppressBenefitsClick = true; 670 } 671 672 setTrackTranslate(dragStartTranslate - deltaX);
673 benefitsMomentum.record(currentTranslate); 674 }); 675 676 window.addEventListener('pointerup', function () { 677 if (!isDragging) { 678 return; 679 } 680 681 isDragging = false; 682 benefitsViewport.style.cursor = ''; 683 684 if (suppressBenefitsClick) { 685 benefitsMomentum.release(currentTranslate); 686 } else { 687 benefitsMomentum.cancel(); 688 syncBenefitsState(); 689 } 690 }); 691 692 benefitsViewport.addEventListener('touchstart', function (event) { 693 if (benefitsAnimating || !event.touches.length) { 694 return; 695 } 696 697 isTouchDragging = false; 698 benefitsMomentum.cancel(); 699 touchStartX = event.touches[0].clientX; 700 touchStartY = event.touches[0].clientY; 701 touchStartTranslate = currentTranslate; 702 benefitsMomentum.begin(currentTranslate); 703 suppressBenefitsClick = false; 704 }, { passive: true }); 705 706 benefitsViewport.addEventListener('touchmove', function (event) { 707 if (benefitsAnimating || !event.touches.length) { 708 return; 709 } 710 711 const deltaX = event.touches[0].clientX - touchStartX; 712 const deltaY = event.touches[0].clientY - touchStartY; 713 714 if (!isTouchDragging && Math.abs(deltaX) <= Math.abs(deltaY)) { 715 return; 716 } 717 718 if (Math.abs(deltaX) > carouselDragClickThreshold) { 719 isTouchDragging = true; 720 suppressBenefitsClick = true; 721 } 722 723 if (!isTouchDragging) { 724 return; 725 } 726 727 setTrackTranslate(touchStartTranslate - deltaX);
728 benefitsMomentum.record(currentTranslate); 729 event.preventDefault(); 730 }, { passive: false }); 731 732 benefitsViewport.addEventListener('touchend', function () { 733 if (!isTouchDragging || benefitsAnimating) { 734 benefitsMomentum.cancel(); 735 return; 736 } 737 738 isTouchDragging = false; 739 benefitsMomentum.release(currentTranslate); 740 }); 741 742 benefitsViewport.addEventListener('touchcancel', function () { 743 isTouchDragging = false; 744 benefitsMomentum.cancel(); 745 syncBenefitsState(); 746 }); 747 748 benefitsCards.forEach(function (card, index) { 749 card.addEventListener('click', function () { 750 if (suppressBenefitsClick) { 751 suppressBenefitsClick = false; 752 return; 753 } 754 755 animateToSnapIndex(index); 756 }); 757 }); 758 759 let benefitsResizeTimer = null; 760 let benefitsLastViewportWidth = window.innerWidth; 761 762 window.addEventListener('resize', function () { 763 const nextViewportWidth = window.innerWidth; 764 const widthChanged = Math.abs(nextViewportWidth - benefitsLastViewportWidth) > 1; 765 766 benefitsLastViewportWidth = nextViewportWidth; 767 window.clearTimeout(benefitsResizeTimer); 768 769 if (!widthChanged) { 770 return; 771 } 772 773 benefitsResizeTimer = window.setTimeout(function () { 774 const snapPoints = getSnapPoints(); 775 776 benefitsMomentum.cancel(); 777 currentBenefitsIndex = clampBenefitsIndex(currentBenefitsIndex); 778 setTrackTranslate(snapPoints[currentBenefitsIndex] || 0); 779 updateBenefitsButtons(); 780 }, 150); 781 }); 782 783 setTrackTranslate(0); 784 updateBenefitsButtons(); 785 } 786 787 if (useCasesViewport && useCasesTrack && useCasesPrevButton && useCasesNextButton) { 788 const useCasesCards = Array.from( 789 useCasesTrack.querySelectorAll('.tt-home-use-cases-card') 790 ); 791 792 let currentUseCasesIndex = 0; 793 let currentUseCasesTranslate = 0; 794 let useCasesAnimating = false; 795 let useCasesDragStartX = 0; 796 let useCasesDragStartTranslate = 0; 797 let useCasesTouchStartX = 0; 798 let useCasesTouchStartY = 0; 799 let useCasesTouchStartTranslate = 0; 800 let useCasesIsTouchDragging = false; 801 let useCasesIsDragging = false; 802 let suppressUseCasesClick = false; 803 804 function clampUseCasesIndex(index) { 805 return Math.max(0, Math.min(index, useCasesCards.length - 1)); 806 } 807 808 function getUseCasesSnapPoints() { 809 return useCasesCards.map(function (card) { 810 return Math.round(card.offsetLeft); 811 }); 812 } 813 814 function getUseCasesMaxTranslate() { 815 return Math.max(0, useCasesTrack.scrollWidth - useCasesViewport.clientWidth); 816 } 817 818 function setUseCasesTrackTranslate(value) { 819 const maxTranslate = getUseCasesMaxTranslate(); 820 currentUseCasesTranslate = Math.max(0, Math.min(value, maxTranslate)); 821 useCasesTrack.style.transform = 'translate3d(' + (-currentUseCasesTranslate) + 'px, 0, 0)'; 822 } 823 824 function updateUseCasesButtons() { 825 useCasesPrevButton.disabled = currentUseCasesIndex <= 0; 826 useCasesNextButton.disabled = currentUseCasesIndex >= useCasesCards.length - 1; 827 } 828 829 function animateToUseCasesSnapIndex(index) { 830 const snapPoints = getUseCasesSnapPoints(); 831 const safeIndex = clampUseCasesIndex(index); 832 const targetTranslate = snapPoints[safeIndex]; 833 const startTranslate = currentUseCasesTranslate; 834 const distance = targetTranslate - startTranslate; 835 const duration = carouselReducedMotion.matches ? 0 : 520; 836 837 useCasesMomentum.cancel(); 838 839 if (useCasesAnimating) { 840 return; 841 } 842 843 currentUseCasesIndex = safeIndex; 844 updateUseCasesButtons(); 845 846 if (Math.abs(distance) < 1 || duration === 0) { 847 setUseCasesTrackTranslate(targetTranslate); 848 return; 849 } 850 851 useCasesAnimating = true; 852 const startTime = performance.now(); 853 854 function step(now) { 855 const elapsed = now - startTime; 856 const progress = Math.min(elapsed / duration, 1); 857 const eased = easeInOutCubic(progress); 858 859 setUseCasesTrackTranslate(startTranslate + (distance * eased)); 860 861 if (progress < 1) { 862 requestAnimationFrame(step); 863 } else { 864 setUseCasesTrackTranslate(targetTranslate); 865 useCasesAnimating = false; 866 } 867 } 868 869 requestAnimationFrame(step); 870 } 871 872 function getNearestUseCasesSnapIndex() { 873 const snapPoints = getUseCasesSnapPoints(); 874 const maxTranslate = getUseCasesMaxTranslate(); 875 876 if (currentUseCasesTranslate <= 1) { 877 return 0; 878 } 879 880 if (currentUseCasesTranslate >= maxTranslate - 1) { 881 return useCasesCards.length - 1; 882 } 883 884 let nearestIndex = 0; 885 let nearestDistance = Infinity; 886
887 snapPoints.forEach(function (point, index) { 888 const distance = Math.abs(point - currentUseCasesTranslate); 889 890 if (distance < nearestDistance) { 891 nearestDistance = distance; 892 nearestIndex = index; 893 } 894 }); 895 896 return nearestIndex; 897 } 898 899 function syncUseCasesState() { 900 currentUseCasesIndex = clampUseCasesIndex(getNearestUseCasesSnapIndex()); 901 updateUseCasesButtons(); 902 } 903 904 const useCasesMomentum = window.TTCarouselMomentum.create({ 905 getMin: function () { 906 return 0; 907 }, 908 getMax: getUseCasesMaxTranslate, 909 setPosition: setUseCasesTrackTranslate, 910 onSettle: syncUseCasesState 911 }); 912 913 useCasesPrevButton.addEventListener('click', function () { 914 if (useCasesAnimating) { 915 return; 916 } 917 useCasesMomentum.cancel(); 918 syncUseCasesState(); 919 animateToUseCasesSnapIndex(currentUseCasesIndex - 1); 920 }); 921 922 useCasesNextButton.addEventListener('click', function () { 923 if (useCasesAnimating) { 924 return; 925 } 926 useCasesMomentum.cancel(); 927 syncUseCasesState(); 928 animateToUseCasesSnapIndex(currentUseCasesIndex + 1); 929 }); 930 931 useCasesViewport.setAttribute('aria-label', 'Use cases carousel'); 932 useCasesViewport.addEventListener('keydown', function (event) { 933 if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { 934 return; 935 } 936 937 event.preventDefault(); 938 useCasesMomentum.cancel(); 939 syncUseCasesState(); 940 animateToUseCasesSnapIndex(currentUseCasesIndex + (event.key === 'ArrowRight' ? 1 : -1)); 941 }); 942 943 useCasesViewport.addEventListener('pointerdown', function (event) { 944 if (useCasesAnimating || event.pointerType === 'touch') { 945 return; 946 } 947 948 useCasesIsDragging = true; 949 useCasesMomentum.cancel(); 950 useCasesDragStartX = event.clientX; 951 useCasesDragStartTranslate = currentUseCasesTranslate; 952 useCasesMomentum.begin(currentUseCasesTranslate); 953 suppressUseCasesClick = false; 954 useCasesViewport.style.cursor = 'grabbing'; 955 }); 956 957 window.addEventListener('pointermove', function (event) { 958 if (!useCasesIsDragging || useCasesAnimating) { 959 return; 960 } 961 962 const deltaX = event.clientX - useCasesDragStartX; 963 964 if (Math.abs(deltaX) > carouselDragClickThreshold) { 965 suppressUseCasesClick = true; 966 } 967 968 setUseCasesTrackTranslate(useCasesDragStartTranslate - deltaX); 969 useCasesMomentum.record(currentUseCasesTranslate); 970 }); 971 972 window.addEventListener('pointerup', function () { 973 if (!useCasesIsDragging) { 974 return; 975 } 976 977 useCasesIsDragging = false; 978 useCasesViewport.style.cursor = ''; 979 980 if (suppressUseCasesClick) { 981 useCasesMomentum.release(currentUseCasesTranslate); 982 } else { 983 useCasesMomentum.cancel(); 984 syncUseCasesState(); 985 } 986 }); 987 988 useCasesViewport.addEventListener('touchstart', function (event) { 989 if (useCasesAnimating || !event.touches.length) { 990 return; 991 } 992 993 useCasesIsTouchDragging = false; 994 useCasesMomentum.cancel(); 995 useCasesTouchStartX = event.touches[0].clientX; 996 useCasesTouchStartY = event.touches[0].clientY; 997 useCasesTouchStartTranslate = currentUseCasesTranslate; 998 useCasesMomentum.begin(currentUseCasesTranslate); 999 suppressUseCasesClick = false; 1000 }, { passive: true }); 1001 1002 useCasesViewport.addEventListener('touchmove', function (event) { 1003 if (useCasesAnimating || !event.touches.length) { 1004 return; 1005 } 1006 1007 const deltaX = event.touches[0].clientX - useCasesTouchStartX; 1008 const deltaY = event.touches[0].clientY - useCasesTouchStartY; 1009 1010 if (!useCasesIsTouchDragging && Math.abs(deltaX) <= Math.abs(deltaY)) { 1011 return; 1012 } 1013 1014 if (Math.abs(deltaX) > carouselDragClickThreshold) { 1015 useCasesIsTouchDragging = true; 1016 suppressUseCasesClick = true; 1017 } 1018 1019 if (!useCasesIsTouchDragging) { 1020 return; 1021 } 1022 1023 setUseCasesTrackTranslate(useCasesTouchStartTranslate - deltaX); 1024 useCasesMomentum.record(currentUseCasesTranslate); 1025 event.preventDefault(); 1026 }, { passive: false }); 1027 1028 useCasesViewport.addEventListener('touchend', function () { 1029 if (!useCasesIsTouchDragging || useCasesAnimating) { 1030 useCasesMomentum.cancel(); 1031 return; 1032 } 1033 1034 useCasesIsTouchDragging = false; 1035 useCasesMomentum.release(currentUseCasesTranslate); 1036 }); 1037 1038 useCasesViewport.addEventListener('touchcancel', function () { 1039 useCasesIsTouchDragging = false;
1040 useCasesMomentum.cancel(); 1041 syncUseCasesState(); 1042 }); 1043 1044 useCasesCards.forEach(function (card, index) { 1045 card.addEventListener('click', function () { 1046 if (suppressUseCasesClick) { 1047 suppressUseCasesClick = false; 1048 return; 1049 } 1050 1051 animateToUseCasesSnapIndex(index); 1052 }); 1053 }); 1054 1055 let useCasesResizeTimer = null; 1056 let useCasesLastViewportWidth = window.innerWidth; 1057 1058 window.addEventListener('resize', function () { 1059 const nextViewportWidth = window.innerWidth; 1060 const widthChanged = Math.abs(nextViewportWidth - useCasesLastViewportWidth) > 1; 1061 1062 useCasesLastViewportWidth = nextViewportWidth; 1063 window.clearTimeout(useCasesResizeTimer); 1064 1065 if (!widthChanged) { 1066 return; 1067 } 1068 1069 useCasesResizeTimer = window.setTimeout(function () { 1070 const snapPoints = getUseCasesSnapPoints(); 1071 1072 useCasesMomentum.cancel(); 1073 currentUseCasesIndex = clampUseCasesIndex(currentUseCasesIndex); 1074 setUseCasesTrackTranslate(snapPoints[currentUseCasesIndex] || 0); 1075 updateUseCasesButtons(); 1076 }, 150); 1077 }); 1078 1079 setUseCasesTrackTranslate(0); 1080 updateUseCasesButtons(); 1081 } 1082 1083 if (userStoriesCarousel && userStoriesTrack) { 1084 userStoriesCarousel.addEventListener('dragstart', function (event) { 1085 if (event.target.closest('.tt-home-user-stories-card')) { 1086 event.preventDefault(); 1087 } 1088 }); 1089 1090 const originalCards = Array.from(userStoriesTrack.children); 1091 const duplicateCards = originalCards.map(function (card) { 1092 return card.cloneNode(true); 1093 }); 1094 1095 duplicateCards.forEach(function (card) { 1096 userStoriesTrack.appendChild(card); 1097 }); 1098 1099 let storyPosition = 0; 1100 let isHovered = false; 1101 let isDragging = false; 1102 let isTouchDragging = false; 1103 let dragStartX = 0; 1104 let dragStartTranslate = 0; 1105 let suppressStoryClick = false; 1106 let touchStartX = 0; 1107 let touchStartY = 0; 1108 let touchStartTranslate = 0; 1109 let animationFrameId = null; 1110 const speed = 0.45; 1111 1112 function getLoopWidth() { 1113 const cards = Array.from(userStoriesTrack.children); 1114 let total = 0; 1115 1116 for (let i = 0; i < cards.length / 2; i += 1) { 1117 total += cards[i].offsetWidth; 1118 if (i < (cards.length / 2) - 1) { 1119 total += 24; 1120 } 1121 } 1122 1123 return total; 1124 } 1125 1126 function setUserStoriesTranslate(value, shouldWrap) { 1127 const loopWidth = getLoopWidth(); 1128 let displayPosition = value; 1129 1130 storyPosition = value; 1131 1132 if (loopWidth > 0 && shouldWrap !== false) { 1133 displayPosition %= loopWidth; 1134 1135 if (displayPosition > 0) { 1136 displayPosition -= loopWidth; 1137 } 1138 } 1139 1140 userStoriesTrack.style.transform = 'translate3d(' + displayPosition + 'px, 0, 0)'; 1141 } 1142 1143 const storyMomentum = window.TTCarouselMomentum.create({ 1144 setPosition: setUserStoriesTranslate 1145 }); 1146 1147 function tickUserStories() { 1148 if (!isHovered && !isDragging && !isTouchDragging && !storyMomentum.isRunning()) { 1149 setUserStoriesTranslate(storyPosition - speed); 1150 } 1151 1152 animationFrameId = window.requestAnimationFrame(tickUserStories); 1153 } 1154 1155 userStoriesCarousel.addEventListener('mouseenter', function () { 1156 isHovered = true; 1157 }); 1158 1159 userStoriesCarousel.addEventListener('mouseleave', function () { 1160 isHovered = false; 1161 }); 1162 1163 userStoriesCarousel.addEventListener('pointerdown', function (event) { 1164 if (event.pointerType === 'touch') { 1165 return; 1166 } 1167 1168 storyMomentum.cancel(); 1169 isDragging = true; 1170 suppressStoryClick = false; 1171 dragStartX = event.clientX; 1172 dragStartTranslate = storyPosition; 1173 storyMomentum.begin(storyPosition); 1174 userStoriesCarousel.style.cursor = 'grabbing'; 1175 }); 1176 1177 window.addEventListener('pointermove', function (event) { 1178 if (!isDragging) { 1179 return; 1180 } 1181 1182 const deltaX = event.clientX - dragStartX; 1183 1184 if (Math.abs(deltaX) > 6) { 1185 suppressStoryClick = true; 1186 } 1187 1188 setUserStoriesTranslate(dragStartTranslate + deltaX, false); 1189 storyMomentum.record(storyPosition); 1190 }); 1191 1192 window.addEventListener('pointerup', function () { 1193 if (!isDragging) { 1194 return; 1195 } 1196 1197 isDragging = false; 1198 userStoriesCarousel.style.cursor = ''; 1199 1200 if (suppressStoryClick) { 1201 storyMomentum.release(storyPosition); 1202 } else { 1203 storyMomentum.cancel(); 1204 } 1205 }); 1206
1207 userStoriesCarousel.addEventListener('touchstart', function (event) { 1208 if (!event.touches.length) { 1209 return; 1210 } 1211 1212 storyMomentum.cancel(); 1213 isTouchDragging = false; 1214 suppressStoryClick = false; 1215 touchStartX = event.touches[0].clientX; 1216 touchStartY = event.touches[0].clientY; 1217 touchStartTranslate = storyPosition; 1218 storyMomentum.begin(storyPosition); 1219 }, { passive: true }); 1220 1221 userStoriesCarousel.addEventListener('touchmove', function (event) { 1222 if (!event.touches.length) { 1223 return; 1224 } 1225 1226 const deltaX = event.touches[0].clientX - touchStartX; 1227 const deltaY = event.touches[0].clientY - touchStartY; 1228 1229 if (!isTouchDragging && Math.abs(deltaX) <= Math.abs(deltaY)) { 1230 return; 1231 } 1232 1233 if (Math.abs(deltaX) <= carouselDragClickThreshold) { 1234 return; 1235 } 1236 1237 isTouchDragging = true; 1238 suppressStoryClick = true; 1239 setUserStoriesTranslate(touchStartTranslate + deltaX, false); 1240 storyMomentum.record(storyPosition); 1241 event.preventDefault(); 1242 }, { passive: false }); 1243 1244 userStoriesCarousel.addEventListener('touchend', function () { 1245 if (isTouchDragging) { 1246 storyMomentum.release(storyPosition); 1247 } else { 1248 storyMomentum.cancel(); 1249 } 1250 1251 isTouchDragging = false; 1252 }); 1253 1254 userStoriesCarousel.addEventListener('touchcancel', function () { 1255 isTouchDragging = false; 1256 storyMomentum.cancel(); 1257 }); 1258 1259 userStoriesCarousel.addEventListener('click', function (event) { 1260 if (!suppressStoryClick) { 1261 return; 1262 } 1263 1264 event.preventDefault(); 1265 suppressStoryClick = false; 1266 }, true); 1267 1268 window.addEventListener('blur', function () { 1269 isDragging = false; 1270 isTouchDragging = false; 1271 storyMomentum.cancel(); 1272 userStoriesCarousel.style.cursor = ''; 1273 }); 1274 1275 animationFrameId = window.requestAnimationFrame(tickUserStories); 1276 } 1277 1278const audienceCards = document.querySelector('.tt-home-audience-cards'); 1279const audiencePrev = document.querySelector('.js-audience-prev'); 1280const audienceNext = document.querySelector('.js-audience-next'); 1281 1282if (audienceCards && audiencePrev && audienceNext) { 1283 let currentAudienceIndex = 0; 1284 let audienceScrollTimer; 1285 let audienceDragStartX = NaN; 1286 let audienceDragStartScrollLeft = 0; 1287 let audiencePointerId = null; 1288 let isAudienceDragging = false; 1289 let suppressAudienceClick = false; 1290 const audienceItems = Array.from(audienceCards.querySelectorAll('a')); 1291 1292 function shouldUseAudienceCarousel() { 1293 return window.matchMedia('(max-width: 599px)').matches; 1294 } 1295 1296 function getAudienceMaxScrollLeft() { 1297 return Math.max(0, audienceCards.scrollWidth - audienceCards.clientWidth); 1298 } 1299 1300 function getAudienceItemLeft(index) { 1301 const item = audienceItems[index]; 1302 1303 if (!item) { 1304 return 0; 1305 } 1306 1307 return Math.max(0, Math.min(item.offsetLeft, getAudienceMaxScrollLeft())); 1308 } 1309 1310 function getClosestAudienceIndex() { 1311 const maxScrollLeft = getAudienceMaxScrollLeft(); 1312 1313 if (audienceCards.scrollLeft <= 1) { 1314 return 0; 1315 } 1316 1317 if (audienceCards.scrollLeft >= maxScrollLeft - 1) { 1318 return audienceItems.length - 1; 1319 } 1320 1321 let closestIndex = 0; 1322 let closestDistance = Infinity; 1323 1324 audienceItems.forEach(function (item, index) { 1325 const distance = Math.abs(audienceCards.scrollLeft - getAudienceItemLeft(index)); 1326 1327 if (distance < closestDistance) { 1328 closestDistance = distance; 1329 closestIndex = index; 1330 } 1331 }); 1332 1333 return closestIndex; 1334 } 1335 1336 function syncAudienceState() { 1337 currentAudienceIndex = getClosestAudienceIndex(); 1338 updateAudienceButtons(); 1339 } 1340 1341 const audienceMomentum = window.TTCarouselMomentum.create({ 1342 getMin: function () { 1343 return 0; 1344 }, 1345 getMax: getAudienceMaxScrollLeft, 1346 setPosition: function (value) { 1347 audienceCards.scrollLeft = value; 1348 }, 1349 onSettle: syncAudienceState 1350 }); 1351 1352 function goToAudienceCard(index, behavior) { 1353 behavior = behavior || (carouselReducedMotion.matches ? 'auto' : 'smooth'); 1354 audienceMomentum.cancel(); 1355 1356 currentAudienceIndex = Math.max( 1357 0, 1358 Math.min(index, audienceItems.length - 1) 1359 ); 1360 1361 audienceCards.scrollTo({ 1362 left: getAudienceItemLeft(currentAudienceIndex), 1363 behavior: behavior 1364 }); 1365 1366 updateAudienceButtons(); 1367 } 1368 1369 audiencePrev.addEventListener('click', function () { 1370 syncAudienceState(); 1371 goToAudienceCard(currentAudienceIndex - 1); 1372 }); 1373
1374 audienceNext.addEventListener('click', function () { 1375 syncAudienceState(); 1376 goToAudienceCard(currentAudienceIndex + 1); 1377 }); 1378 1379 audienceCards.addEventListener('scroll', function () { 1380 if (audienceCards.scrollLeft > 1) { 1381 audienceCards.classList.add('audience-scrolled-right'); 1382 } else { 1383 audienceCards.classList.remove('audience-scrolled-right'); 1384 } 1385 1386 window.clearTimeout(audienceScrollTimer); 1387 audienceScrollTimer = window.setTimeout(function () { 1388 currentAudienceIndex = getClosestAudienceIndex(); 1389 updateAudienceButtons(); 1390 }, 80); 1391 }); 1392 1393 function updateAudienceButtons() { 1394 const maxScrollLeft = getAudienceMaxScrollLeft(); 1395 1396 currentAudienceIndex = getClosestAudienceIndex(); 1397 1398 audiencePrev.disabled = maxScrollLeft <= 1 || audienceCards.scrollLeft <= 1; 1399 audienceNext.disabled = maxScrollLeft <= 1 || audienceCards.scrollLeft >= maxScrollLeft - 1; 1400 } 1401 1402 audienceItems.forEach(function (item, index) { 1403 item.addEventListener('dragstart', function (event) { 1404 if (shouldUseAudienceCarousel()) { 1405 event.preventDefault(); 1406 } 1407 }); 1408 1409 item.addEventListener('click', function (event) { 1410 if (suppressAudienceClick) { 1411 event.preventDefault(); 1412 suppressAudienceClick = false; 1413 return; 1414 } 1415 1416 if (!shouldUseAudienceCarousel()) { 1417 return; 1418 } 1419 1420 const activeIndex = getClosestAudienceIndex(); 1421 1422 // If card isn't active, scroll to it 1423 if (index !== activeIndex) { 1424 event.preventDefault(); 1425 goToAudienceCard(index); 1426 } 1427 }); 1428 }); 1429 1430 audienceCards.addEventListener('pointerdown', function (event) { 1431 if ( 1432 !shouldUseAudienceCarousel() || 1433 event.pointerType !== 'mouse' || 1434 event.button !== 0 1435 ) { 1436 return; 1437 } 1438 1439 audiencePointerId = event.pointerId; 1440 audienceDragStartX = event.clientX; 1441 audienceDragStartScrollLeft = audienceCards.scrollLeft; 1442 isAudienceDragging = false; 1443 suppressAudienceClick = false; 1444 audienceMomentum.cancel(); 1445 audienceMomentum.begin(audienceCards.scrollLeft); 1446 1447 if (audienceCards.setPointerCapture) { 1448 audienceCards.setPointerCapture(audiencePointerId); 1449 } 1450 }); 1451 1452 audienceCards.addEventListener('pointermove', function (event) { 1453 if ( 1454 !shouldUseAudienceCarousel() || 1455 !Number.isFinite(audienceDragStartX) ||
1456 event.pointerId !== audiencePointerId 1457 ) { 1458 return; 1459 } 1460 1461 const deltaX = event.clientX - audienceDragStartX; 1462 1463 if (Math.abs(deltaX) > carouselDragClickThreshold) { 1464 isAudienceDragging = true; 1465 suppressAudienceClick = true; 1466 audienceCards.classList.add('is-dragging'); 1467 event.preventDefault(); 1468 } 1469 1470 if (isAudienceDragging) { 1471 audienceCards.scrollLeft = audienceDragStartScrollLeft - deltaX; 1472 audienceMomentum.record(audienceCards.scrollLeft); 1473 } 1474 }); 1475 1476 function stopAudienceDrag(event) { 1477 if (event.pointerId !== audiencePointerId) { 1478 return; 1479 } 1480 1481 const shouldGlide = isAudienceDragging; 1482 1483 audienceDragStartX = NaN; 1484 audiencePointerId = null; 1485 isAudienceDragging = false; 1486 audienceCards.classList.remove('is-dragging'); 1487 1488 if (audienceCards.hasPointerCapture && audienceCards.hasPointerCapture(event.pointerId)) { 1489 audienceCards.releasePointerCapture(event.pointerId); 1490 } 1491 1492 if (shouldGlide && event.type !== 'pointercancel') { 1493 audienceMomentum.release(audienceCards.scrollLeft); 1494 } else { 1495 audienceMomentum.cancel(); 1496 syncAudienceState(); 1497 } 1498 } 1499 1500 audienceCards.addEventListener('pointerup', stopAudienceDrag); 1501 audienceCards.addEventListener('pointercancel', stopAudienceDrag); 1502 1503 let audienceResizeTimer = null; 1504 let audienceLastViewportWidth = window.innerWidth; 1505 1506 window.addEventListener('resize', function () { 1507 const nextViewportWidth = window.innerWidth; 1508 const widthChanged = Math.abs(nextViewportWidth - audienceLastViewportWidth) > 1; 1509 1510 audienceLastViewportWidth = nextViewportWidth; 1511 window.clearTimeout(audienceResizeTimer); 1512 1513 if (!widthChanged) { 1514 return; 1515 } 1516 1517 audienceResizeTimer = window.setTimeout(function () { 1518 audienceMomentum.cancel(); 1519 currentAudienceIndex = getClosestAudienceIndex(); 1520 1521 audienceCards.scrollTo({ 1522 left: getAudienceItemLeft(currentAudienceIndex), 1523 behavior: 'auto' 1524 }); 1525 1526 updateAudienceButtons(); 1527 }, 150); 1528 }); 1529 1530 updateAudienceButtons(); 1531} 1532 1533 function shouldUseFeatureStack() { 1534 return true; 1535 } 1536 1537 function getFeatureActiveCardIndex(currentCompression, compressionStops) { 1538 let activeIndex = 0; 1539 1540 for (let index = 1; index < compressionStops.length; index++) { 1541 const previousStop = compressionStops[index - 1]; 1542 const currentStop = compressionStops[index]; 1543 const activationPoint = previousStop + ((currentStop - previousStop) / 2); 1544 1545 if (currentCompression >= activationPoint) { 1546 activeIndex = index; 1547 } 1548 } 1549 1550 return activeIndex; 1551 } 1552 1553 function getCurrentFeatureCardIndex() { 1554 if (!featureScrollArea || !featureCards.length) { 1555 return 0; 1556 } 1557 1558 const settings = getFeatureStackSettings(); 1559 const rect = featureScrollArea.getBoundingClientRect(); 1560 const overallProgress = clamp((-rect.top) / cachedFeatureAvailableScroll, 0, 1); 1561 const currentCompression = settings.totalCompression * overallProgress; 1562 1563 return getFeatureActiveCardIndex(currentCompression, settings.compressionStops); 1564 } 1565 1566 function updateFeatureStackAccessibility(index, announce) { 1567 if (!featureCards.length) { 1568 return; 1569 } 1570 1571 const safeIndex = clamp(index, 0, featureCards.length - 1); 1572 const activeCard = featureCards[safeIndex]; 1573 const heading = activeCard.querySelector('.tt-home-feature-card__heading, h3'); 1574 const headingText = heading ? heading.textContent.trim() : 'Card ' + (safeIndex + 1); 1575 1576 activeFeatureCardIndex = safeIndex; 1577 1578 featureCards.forEach(function (card, cardIndex) { 1579 if (cardIndex === safeIndex) { 1580 card.setAttribute('aria-current', 'step'); 1581 } else { 1582 card.removeAttribute('aria-current'); 1583 } 1584 }); 1585 1586 if (featureStackStatus) { 1587 const statusText = 'Current card: ' + (safeIndex + 1) + ' of ' 1588 + featureCards.length + ', ' + headingText + '.'; 1589 1590 featureStackStatus.setAttribute('aria-live', announce ? 'polite' : 'off'); 1591 1592 if (featureStackStatus.textContent !== statusText) { 1593 featureStackStatus.textContent = statusText; 1594 } 1595 } 1596 } 1597 1598 function scrollToFeatureCard(index) { 1599 if (!featureScrollArea || !featureCards.length) { 1600 return; 1601 } 1602 1603 const settings = getFeatureStackSettings(); 1604 const safeIndex = clamp(index, 0, featureCards.length - 1); 1605 const targetTop = featureScrollArea.getBoundingClientRect().top 1606 + window.scrollY 1607 + settings.compressionStops[safeIndex]; 1608 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 1609 1610 updateFeatureStackAccessibility(safeIndex, true); 1611 1612 window.scrollTo({ 1613 top: Math.max(0, targetTop), 1614 behavior: prefersReducedMotion ? 'auto' : 'smooth' 1615 }); 1616 } 1617 1618 function setupFeatureStackAccessibility() { 1619 if (!featureStackSection || !featureStack || !featureCards.length) { 1620 return; 1621 } 1622 1623 const sectionHeading = featureStackSection.querySelector('.tt-home-features__title, h2'); 1624 const instructions = document.createElement('span'); 1625 featureStackStatus = document.createElement('span'); 1626 1627 if (sectionHeading && !sectionHeading.id) { 1628 sectionHeading.id = 'tt-feature-stack-heading'; 1629 } 1630 1631 instructions.id = 'tt-feature-stack-instructions'; 1632 instructions.className = 'tt-home-features__keyboard-instructions'; 1633 instructions.textContent = 'Use the arrow keys to move through the cards. Press Home for the first card or End for the last card.'; 1634 1635 featureStackStatus.id = 'tt-feature-stack-status'; 1636 featureStackStatus.className = 'tt-home-features__keyboard-instructions'; 1637 featureStackStatus.setAttribute('aria-atomic', 'true'); 1638
1639 featureStack.appendChild(instructions); 1640 featureStack.appendChild(featureStackStatus); 1641 featureStack.setAttribute('tabindex', '0'); 1642 featureStack.setAttribute('role', 'group'); 1643 featureStack.setAttribute('aria-describedby', instructions.id + ' ' + featureStackStatus.id); 1644 1645 if (sectionHeading) { 1646 featureStack.setAttribute('aria-labelledby', sectionHeading.id); 1647 } else { 1648 featureStack.setAttribute('aria-label', 'Stacked cards'); 1649 } 1650 1651 featureStack.addEventListener('focus', function () { 1652 updateFeatureStackAccessibility(getCurrentFeatureCardIndex(), false); 1653 }); 1654 1655 featureStack.addEventListener('keydown', function (event) { 1656 if (event.target !== featureStack) { 1657 return; 1658 } 1659 1660 let nextIndex = activeFeatureCardIndex; 1661 1662 switch (event.key) { 1663 case 'ArrowDown': 1664 case 'ArrowRight': 1665 nextIndex += 1; 1666 break; 1667 case 'ArrowUp': 1668 case 'ArrowLeft': 1669 nextIndex -= 1; 1670 break; 1671 case 'Home': 1672 nextIndex = 0; 1673 break; 1674 case 'End': 1675 nextIndex = featureCards.length - 1; 1676 break; 1677 default: 1678 return; 1679 } 1680 1681 event.preventDefault(); 1682 scrollToFeatureCard(nextIndex); 1683 }); 1684 1685 updateFeatureStackAccessibility(0, false); 1686 } 1687 1688 function getFeatureCardDepth(cardIndex, currentCompression, compressionStops) { 1689 let depth = 0; 1690 1691 for (let nextCardIndex = cardIndex + 1; nextCardIndex < compressionStops.length; nextCardIndex++) { 1692 const stageStart = compressionStops[nextCardIndex - 1]; 1693 const stageEnd = compressionStops[nextCardIndex]; 1694 const stageProgress = clamp( 1695 (currentCompression - stageStart) / Math.max(1, stageEnd - stageStart), 1696 0, 1697 1 1698 ); 1699 1700 depth += stageProgress; 1701 } 1702 1703 return depth; 1704 } 1705 1706 function getFeatureStackSettings() { 1707 if (cachedFeatureStackSettings) { 1708 return cachedFeatureStackSettings; 1709 } 1710 1711 const styles = featureStackSection 1712 ? window.getComputedStyle(featureStackSection) 1713 : null; 1714 1715 const fallbackCardHeight = styles 1716 ? parseFloat(styles.getPropertyValue('--feature-card-height')) || 532 1717 : 532; 1718 const cardGap = styles 1719 ? parseFloat(styles.getPropertyValue('--feature-card-gap')) || 24 1720 : 24; 1721 const lipSize = styles 1722 ? parseFloat(styles.getPropertyValue('--feature-lip-size')) || 20 1723 : 20; 1724 const cardHeights = featureCards.map(function (card) { 1725 return card.offsetHeight || fallbackCardHeight; 1726 }); 1727 const naturalPositions = []; 1728 const compressedPositions = []; 1729 const compressionStops = []; 1730 let nextNaturalPosition = 0; 1731 1732 cardHeights.forEach(function (cardHeight, index) { 1733 naturalPositions.push(nextNaturalPosition); 1734 compressedPositions.push(index * lipSize); 1735 compressionStops.push(nextNaturalPosition - (index * lipSize)); 1736 nextNaturalPosition += cardHeight + cardGap; 1737 }); 1738 1739 const totalCompression = compressionStops.length 1740 ? compressionStops[compressionStops.length - 1] 1741 : 0; 1742 const packedStackHeight = cardHeights.reduce(function (height, cardHeight, index) { 1743 return Math.max(height, compressedPositions[index] + cardHeight); 1744 }, 0); 1745 1746 cachedFeatureStackSettings = { 1747 cardHeights: cardHeights, 1748 naturalPositions: naturalPositions, 1749 compressedPositions: compressedPositions, 1750 compressionStops: compressionStops, 1751 totalCompression: totalCompression, 1752 packedStackHeight: packedStackHeight, 1753 stickyTop: styles 1754 ? parseFloat(styles.getPropertyValue('--feature-sticky-top')) || 64 1755 : 64 1756 }; 1757 1758 return cachedFeatureStackSettings; 1759 } 1760 1761 function resetFeatureStackInlineStyles() { 1762 if (!featureScrollArea || !featureStack || !featureSticky || !featureCards.length) { 1763 return; 1764 } 1765 1766 featureScrollArea.style.height = ''; 1767 featureStack.style.height = ''; 1768 featureSticky.style.top = ''; 1769 featureSticky.style.height = ''; 1770 1771 featureCards.forEach(function (card) { 1772 card.style.transform = ''; 1773 card.style.zIndex = ''; 1774 card.style.boxShadow = ''; 1775 }); 1776 } 1777 1778 function setupFeatureStackLayout() { 1779 if (!featureScrollArea || !featureStack || !featureSticky || !featureCards.length) { 1780 return; 1781 } 1782 1783 if (!shouldUseFeatureStack()) { 1784 resetFeatureStackInlineStyles(); 1785 return; 1786 } 1787 1788 const settings = getFeatureStackSettings(); 1789 1790 const stickyTop = settings.stickyTop; 1791 const compressionDistance = settings.totalCompression; 1792 const packedStackHeight = settings.packedStackHeight; 1793 1794 featureStack.style.height = packedStackHeight + 'px'; 1795 featureSticky.style.top = stickyTop + 'px'; 1796 featureSticky.style.height = packedStackHeight + 'px'; 1797 featureScrollArea.style.height = (packedStackHeight + compressionDistance) + 'px'; 1798 1799 cachedFeatureAvailableScroll = Math.max(1, compressionDistance); 1800 1801 featureCards.forEach(function (card, index) { 1802 card.style.zIndex = String(100 + index); 1803 }); 1804 } 1805
1806 function getStableViewportHeight() { 1807 return document.documentElement.clientHeight || window.innerHeight; 1808 } 1809 1810 function refreshHeroMeasurements() { 1811 if (!hero) { 1812 return; 1813 } 1814 1815 const heroStyles = window.getComputedStyle(hero); 1816 const animationDistanceRatio = parseFloat(heroStyles.getPropertyValue('--hero-animation-distance')); 1817 const settledWidth = parseFloat(heroStyles.getPropertyValue('--hero-settled-width')); 1818 const settledHeight = parseFloat(heroStyles.getPropertyValue('--hero-settled-height')); 1819 const visualTop = heroVisual ? heroVisual.offsetTop : 0; 1820 const preScrollHeight = Math.max(680, cachedViewportHeight - visualTop); 1821 1822 cachedHeroSettledWidth = Number.isFinite(settledWidth) ? settledWidth : 1260; 1823 cachedHeroSettledHeight = Number.isFinite(settledHeight) ? settledHeight : 680; 1824 cachedHeroPreScrollWidth = heroVisual 1825 ? heroVisual.getBoundingClientRect().width 1826 : window.innerWidth; 1827 cachedHeroPreScrollHeight = preScrollHeight; 1828 cachedHeroAnimationDistance = cachedViewportHeight 1829 * (Number.isFinite(animationDistanceRatio) ? animationDistanceRatio : 0.54); 1830 } 1831 1832 function refreshScrollAnimationLayout() { 1833 cachedFeatureStackSettings = null; 1834 cachedFeatureAvailableScroll = 1; 1835 cachedViewportHeight = getStableViewportHeight(); 1836 1837 refreshHeroMeasurements(); 1838 1839 setupFeatureStackLayout(); 1840 updateScrollAnimations(); 1841 } 1842 1843 let scrollAnimationResizeTimer = null; 1844 let scrollAnimationLastViewportWidth = window.innerWidth; 1845 1846 setupFeatureStackAccessibility(); 1847 refreshScrollAnimationLayout(); 1848 1849 window.addEventListener('scroll', requestAnimTick, { passive: true }); 1850 1851 window.addEventListener('resize', function () { 1852 const nextViewportWidth = window.innerWidth; 1853 const widthChanged = Math.abs(nextViewportWidth - scrollAnimationLastViewportWidth) > 1; 1854 1855 scrollAnimationLastViewportWidth = nextViewportWidth; 1856 window.clearTimeout(scrollAnimationResizeTimer); 1857 1858 if (!widthChanged) { 1859 scrollAnimationResizeTimer = window.setTimeout(function () { 1860 cachedViewportHeight = getStableViewportHeight(); 1861 refreshHeroMeasurements(); 1862 updateScrollAnimations(); 1863 }, 220); 1864 1865 return; 1866 } 1867 1868 scrollAnimationResizeTimer = window.setTimeout(refreshScrollAnimationLayout, 150); 1869 }); 1870 1871 window.addEventListener('orientationchange', function () { 1872 window.clearTimeout(scrollAnimationResizeTimer); 1873 scrollAnimationResizeTimer = window.setTimeout(refreshScrollAnimationLayout, 350); 1874 }); 1875});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.