1(function () { 2 'use strict'; 3 4 var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 5 6 /* Nav scrolled state + scroll-direction reveal (timeline.com style): 7 the bulletin scrolls away with the page; the sticky nav hides while 8 scrolling down and slides back in as soon as the user scrolls up. */ 9 var nav = document.querySelector('.nv-nav'); 10 var ticking = false; 11 var lastSt = window.scrollY || document.documentElement.scrollTop; 12 /* Hysteresis state: trackpads/scroll anchoring emit small alternating 13 deltas, so per-frame direction checks thrash the hidden class (visible 14 as nav flicker in Chromium). Instead, track where the current 15 direction run started and only act on accumulated travel. */ 16 var dirAnchor = lastSt; 17 var lastDir = 0; 18 19 function onScroll() { 20 var st = window.scrollY || document.documentElement.scrollTop; 21 if (nav) { 22 /* Solid shell past ~58% viewport, back to glass below ~50% â the 23 gap stops the background fading in/out around one pixel line. */ 24 var solidAt = window.innerHeight * (nav.classList.contains('scrolled') ? 0.5 : 0.58); 25 nav.classList.toggle('scrolled', st > solidAt); 26 if (st !== lastSt) { 27 var dir = st > lastSt ? 1 : -1; 28 if (dir !== lastDir) { 29 lastDir = dir; 30 dirAnchor = lastSt; 31 } 32 } 33 if (st <= nav.offsetHeight + 40) { 34 nav.classList.remove('nv-nav--hidden'); 35 } else if (lastDir === 1 && st - dirAnchor > 72) { 36 nav.classList.add('nv-nav--hidden'); 37 } else if (lastDir === -1 && dirAnchor - st > 12) { 38 nav.classList.remove('nv-nav--hidden'); 39 } 40 } 41 lastSt = st; 42 ticking = false; 43 } 44 45 window.addEventListener('scroll', function () { 46 if (!ticking) { 47 requestAnimationFrame(onScroll); 48 ticking = true; 49 } 50 }, { passive: true }); 51 52 /* Mobile menu (#nvNavMobile): left drill-in drawer. Open/close chrome 53 (backdrop, slide, ESC, outside click, scroll lock) is css-drawer via 54 the delegated [data-nv-drawer-open/close] wiring in drawer.js; this 55 block maintains an N-level panel stack (push on drill, pop on back) 56 with transform-only swaps + inert/focus sync. */ 57 var mnav = document.getElementById('nvNavMobile'); 58 if (mnav) { 59 var mnavRoot = mnav.querySelector('[data-nv-mnav-panel="root"]'); 60 var mnavPanels = mnav.querySelectorAll('[data-nv-mnav-panel]'); 61 var mnavStack = [mnavRoot]; 62 var mnavOpeners = []; 63 64 var mnavSync = function () { 65 var active = mnavStack[mnavStack.length - 1]; 66 mnavPanels.forEach(function (p) { 67 var idx = mnavStack.indexOf(p); 68 var on = p === active; 69 p.classList.toggle('is-active', on); 70 p.classList.toggle('is-prev', idx !== -1 && !on); 71 if (on) { 72 p.removeAttribute('inert'); 73 p.scrollTop = 0; 74 } else { 75 p.setAttribute('inert', ''); 76 } 77 }); 78 }; 79 80 mnav.addEventListener('click', function (e) { 81 var drill = e.target.closest('[data-nv-mnav-open]'); 82 if (drill) { 83 var panel = mnav.querySelector('[data-nv-mnav-panel="' + drill.getAttribute('data-nv-mnav-open') + '"]'); 84 if (panel && mnavStack[mnavStack.length - 1] !== panel) { 85 mnavStack.push(panel); 86 mnavOpeners.push(drill); 87 mnavSync(); 88 var back = panel.querySelector('[data-nv-mnav-back]'); 89 if (back) { 90 back.focus({ preventScroll: true }); 91 } 92 } 93 return; 94 } 95 if (e.target.closest('[data-nv-mnav-back]')) { 96 if (mnavStack.length > 1) { 97 mnavStack.pop(); 98 var opener = mnavOpeners.pop(); 99 mnavSync(); 100 if (opener) { 101 opener.focus({ preventScroll: true }); 102 } 103 } 104 return; 105 } 106 /* Same-page anchors don't navigate, so close explicitly. */ 107 if (e.target.closest('a[href]')) { 108 mnav.close(); 109 } 110 }); 111 112 /* Reopening always starts on the root panel. */ 113 mnav.addEventListener('close', function () { 114 mnavStack = [mnavRoot]; 115 mnavOpeners = []; 116 mnavSync(); 117 }); 118 } 119 120 /* Mega panels: keep each dropdown bounded by the nav shell. Panels stay 121 centered under their item, then shift just enough that neither edge 122 escapes the pill (CSS consumes --nv-mega-shift in the transform). */ 123 var navShell = document.querySelector('.nv-nav__shell'); 124 if (navShell) { 125 var megas = navShell.querySelectorAll('.nv-nav__item .nv-mega'); 126 var clampMegas = function () { 127 var shell = navShell.getBoundingClientRect(); 128 megas.forEach(function (mega) { 129 var item = mega.parentElement.getBoundingClientRect(); 130 var w = mega.offsetWidth; 131 if (!w || !shell.width) { 132 return; 133 } 134 var desired = item.left + item.width / 2 - w / 2; 135 var clamped = Math.min(Math.max(desired, shell.left), shell.right - w); 136 mega.style.setProperty('--nv-mega-shift', Math.round(clamped - desired) + 'px'); 137 }); 138 }; 139 clampMegas(); 140 window.addEventListener('resize', clampMegas, { passive: true }); 141 if (document.fonts && document.fonts.ready) { 142 document.fonts.ready.then(clampMegas); 143 } 144 145 /* Click leaves focus on the trigger, so :focus-within keeps that mega 146 open after the pointer moves to another item. Blur the stale trigger 147 on pointerenter so only the hovered item stays open; keyboard Tab 148 still relies on :focus-within. Use activeElement (not :focus) so this
149 still works when the document itself isn't the OS focus target. */ 150 navShell.querySelectorAll('.nv-nav__item').forEach(function (item) { 151 item.addEventListener('pointerenter', function () { 152 var focused = document.activeElement; 153 if ( 154 focused 155 && focused.classList 156 && focused.classList.contains('nv-nav__link') 157 && navShell.contains(focused) 158 && !item.contains(focused) 159 ) { 160 focused.blur(); 161 } 162 }); 163 }); 164 } 165 166 /* Hero headline rotator */ 167 var words = document.querySelectorAll('.nv-rotator span'); 168 if (words.length && !reduce) { 169 var wi = 0; 170 setInterval(function () { 171 words[wi].classList.remove('is-active'); 172 wi = (wi + 1) % words.length; 173 words[wi].classList.add('is-active'); 174 }, 2200); 175 } 176 177 /* Hero video: load after idle so it never blocks first paint. 178 Source is picked per viewport (9:16 on mobile) so phones never 179 download the desktop file. */ 180 var video = document.getElementById('nvHeroVideo'); 181 if (video && !reduce) { 182 var playHero = function () { 183 var src = window.matchMedia('(max-width: 620px)').matches 184 ? video.getAttribute('data-src-mobile') 185 : video.getAttribute('data-src-desktop'); 186 if (!src) { 187 return; 188 } 189 video.addEventListener('playing', function () { 190 video.classList.add('is-playing'); 191 }, { once: true }); 192 video.src = src; 193 video.load(); 194 video.play().catch(function () {}); 195 }; 196 if ('requestIdleCallback' in window) { 197 requestIdleCallback(playHero, { timeout: 2500 }); 198 } else { 199 window.addEventListener('load', playHero); 200 } 201 } 202 203 /* Scroll reveals. Cards inside horizontal rails reveal with the rail 204 itself, not one by one: a card peeking a sliver past the viewport 205 edge never reaches the 13% self-intersection threshold, so the 206 scroll affordance stayed invisible on mobile until the first swipe. */ 207 var reveals = document.querySelectorAll('.nv-reveal'); 208 if (reveals.length) { 209 if (reduce || !('IntersectionObserver' in window)) { 210 reveals.forEach(function (el) { el.classList.add('in'); }); 211 } else { 212 var singles = []; 213 var railHosts = []; 214 reveals.forEach(function (el) { 215 var rail = el.closest('.nv-rail'); 216 if (rail) { 217 if (railHosts.indexOf(rail) === -1) { railHosts.push(rail); } 218 } else { 219 singles.push(el); 220 } 221 }); 222 var revealTarget = function (el) { 223 if (el.classList.contains('nv-reveal')) { 224 el.classList.add('in'); 225 } 226 if (el.classList.contains('nv-rail')) { 227 el.querySelectorAll('.nv-reveal').forEach(function (card) { card.classList.add('in'); }); 228 } 229 }; 230 /* Elements taller than most of the viewport can never reach the 231 13% self-intersection threshold (tall grids, full charts), so 232 any intersection reveals them; threshold 0 is in the list so 233 that entry actually fires. */ 234 var io = new IntersectionObserver(function (entries) { 235 entries.forEach(function (e) { 236 if (!e.isIntersecting) { return; } 237 var tall = e.boundingClientRect.height > window.innerHeight * 0.6; 238 if (e.intersectionRatio >= 0.12 || tall) { 239 io.unobserve(e.target); 240 revealTarget(e.target); 241 } 242 }); 243 }, { threshold: [0, 0.13], rootMargin: '0px 0px -8% 0px' }); 244 singles.forEach(function (el) { io.observe(el); }); 245 railHosts.forEach(function (rail) { io.observe(rail); }); 246 247 /* Anchor jumps (e.g. nav links to #study-4) land mid-page faster 248 than the reveal choreography was tuned for and left jumped-to 249 sections at opacity 0; reveal the target's section outright. */ 250 var revealHashTarget = function () { 251 if (!location.hash || location.hash.length < 2) { return; } 252 var target = null; 253 try { target = document.getElementById(decodeURIComponent(location.hash.slice(1))); } catch (err) { /* malformed hash */ } 254 if (!target) { return; } 255 var scope = target.closest('section, article') || target; 256 [scope, target].forEach(function (node) { 257 if (node.classList.contains('nv-reveal')) { node.classList.add('in'); } 258 node.querySelectorAll('.nv-reveal').forEach(function (el) { el.classList.add('in'); }); 259 }); 260 }; 261 revealHashTarget(); 262 window.addEventListener('hashchange', revealHashTarget); 263 } 264 } 265 266 /* Stat count-up */ 267 var counts = document.querySelectorAll('.nv-count'); 268 269 function animateCount(el) { 270 var target = parseInt(el.dataset.target || '0', 10); 271 if (reduce) {
272 el.textContent = target.toLocaleString(); 273 return; 274 } 275 var dur = 1500; 276 var start = null; 277 function step(ts) { 278 if (!start) { 279 start = ts; 280 } 281 var p = Math.min((ts - start) / dur, 1); 282 var eased = 1 - Math.pow(1 - p, 3); 283 el.textContent = Math.round(eased * target).toLocaleString(); 284 if (p < 1) { 285 requestAnimationFrame(step); 286 } 287 } 288 requestAnimationFrame(step); 289 } 290 291 if (counts.length) { 292 if (!('IntersectionObserver' in window)) { 293 counts.forEach(animateCount); 294 } else { 295 var co = new IntersectionObserver(function (entries) { 296 entries.forEach(function (e) { 297 if (e.isIntersecting) { 298 animateCount(e.target); 299 co.unobserve(e.target); 300 } 301 }); 302 }, { threshold: 0.55 }); 303 counts.forEach(function (el) { co.observe(el); }); 304 } 305 } 306 307 /* Intro lead: words light up as you scroll. Inline <em>/<strong> in the 308 pattern markup survives the split â those words keep an extra class. */ 309 var lead = document.getElementById('nvIntroLead'); 310 if (lead) { 311 var wordEls = []; 312 var splitWords = function (text, emphasized) { 313 text.trim().split(/\s+/).forEach(function (w) { 314 if (!w) { 315 return; 316 } 317 var s = document.createElement('span'); 318 s.className = emphasized ? 'w w--em' : 'w'; 319 s.textContent = w; 320 lead.appendChild(s); 321 lead.appendChild(document.createTextNode(' ')); 322 wordEls.push(s); 323 }); 324 }; 325 var srcNodes = Array.prototype.slice.call(lead.childNodes); 326 lead.textContent = ''; 327 srcNodes.forEach(function (node) { 328 splitWords(node.textContent, node.nodeType === 1); 329 }); 330 var paintWords = function () { 331 var r = lead.getBoundingClientRect(); 332 var vh = window.innerHeight; 333 var p = (vh * 0.82 - r.top) / (r.height + vh * 0.35); 334 p = Math.max(0, Math.min(1, p)); 335 var lit = Math.round(p * wordEls.length); 336 wordEls.forEach(function (s, i) { 337 s.classList.toggle('lit', i < lit); 338 }); 339 }; 340 if (reduce) { 341 wordEls.forEach(function (s) { s.classList.add('lit'); }); 342 } else { 343 window.addEventListener('scroll', paintWords, { passive: true }); 344 window.addEventListener('resize', paintWords, { passive: true }); 345 paintWords(); 346 } 347 } 348 349 /* Organic heading lines: place the S-bend just past the heading text. 350 Each .nv-orgline follows its preceding heading (or the heading inside 351 its preceding head block). Range rects give the true text extent, so 352 centered titles and balanced multi-line headings both measure right. 353 Without JS the CSS fallback keeps the bend at 38%. */ 354 var orglines = []; 355 document.querySelectorAll('.nv-orgline').forEach(function (line) { 356 var prev = line.previousElementSibling; 357 if (!prev) { 358 return; 359 } 360 var heading = /^H[1-6]$/.test(prev.tagName) ? prev : prev.querySelector('h1, h2, h3, h4'); 361 if (heading) { 362 orglines.push({ line: line, heading: heading }); 363 } 364 }); 365 366 function placeOrgline(item) { 367 var lineRect = item.line.getBoundingClientRect(); 368 if (!lineRect.width) { 369 return; 370 } 371 var range = document.createRange(); 372 range.selectNodeContents(item.heading); 373 var rects = range.getClientRects(); 374 var maxRight = -Infinity; 375 for (var i = 0; i < rects.length; i++) { 376 if (rects[i].width > 0 && rects[i].right > maxRight) { 377 maxRight = rects[i].right; 378 } 379 } 380 range.detach(); 381 if (maxRight === -Infinity) { 382 return; 383 } 384 var gap = lineRect.height * 0.5; 385 item.line.style.setProperty('--nv-bend-x', Math.round(maxRight - lineRect.left + gap) + 'px'); 386 } 387 388 if (orglines.length) { 389 var placeAllOrglines = function () { 390 orglines.forEach(placeOrgline); 391 }; 392 placeAllOrglines(); 393 if ('ResizeObserver' in window) { 394 var ro = new ResizeObserver(placeAllOrglines); 395 orglines.forEach(function (item) { ro.observe(item.line); }); 396 } else { 397 window.addEventListener('resize', placeAllOrglines, { passive: true }); 398 } 399 if (document.fonts && document.fonts.ready) { 400 document.fonts.ready.then(placeAllOrglines); 401 } 402 } 403 404 /* Help tooltips (.nv-tip, timeline.com style): the whole sentence is the 405 trigger button. The bubble is position: fixed so scroll boxes never 406 clip it; JS places it above the trigger when there's room (below 407 otherwise) and clamps it to the viewport. Hover/focus reveal is pure 408 CSS; click toggles for touch. One open at a time; Escape or an outside 409 click closes. */ 410 var tips = document.querySelectorAll('.nv-tip'); 411 if (tips.length) { 412 var liveTip = null; 413 414 var placeTip = function (tip) { 415 var btn = tip.querySelector('.nv-tip__btn'); 416 var bubble = tip.querySelector('.nv-tip__bubble'); 417 if (!btn || !bubble) { 418 return; 419 } 420 var pad = 16; 421 var gap = 10; 422 /* Park at the viewport origin first so the measured size is not 423 constrained by a stale offset near the right edge. */ 424 bubble.style.left = pad + 'px'; 425 bubble.style.top = '0px'; 426 var r = btn.getBoundingClientRect(); 427 var w = bubble.offsetWidth; 428 var h = bubble.offsetHeight; 429 var above = r.top - h - gap >= pad || r.top > window.innerHeight - r.bottom; 430 bubble.style.top = Math.round(above ? r.top - h - gap : r.bottom + gap) + 'px'; 431 bubble.style.left = Math.round(Math.max(pad, Math.min(r.left, window.innerWidth - w - pad))) + 'px'; 432 tip.classList.toggle('nv-tip--above', above); 433 }; 434 435 var closeTips = function (except) { 436 document.querySelectorAll('.nv-tip.is-open').forEach(function (tip) { 437 if (tip !== except) { 438 tip.classList.remove('is-open'); 439 tip.querySelector('.nv-tip__btn').setAttribute('aria-expanded', 'false'); 440 } 441 }); 442 }; 443 444 tips.forEach(function (tip) { 445 var btn = tip.querySelector('.nv-tip__btn'); 446 if (!btn) { 447 return; 448 } 449 tip.addEventListener('pointerenter', function () { 450 liveTip = tip; 451 placeTip(tip); 452 }); 453 btn.addEventListener('focus', function () { 454 liveTip = tip; 455 placeTip(tip); 456 }); 457 btn.addEventListener('click', function (e) { 458 e.stopPropagation(); 459 liveTip = tip; 460 placeTip(tip); 461 var open = tip.classList.toggle('is-open'); 462 btn.setAttribute('aria-expanded', String(open)); 463 closeTips(tip); 464 }); 465 }); 466 467 /* Keep a visible bubble anchored while the page scrolls or resizes. */ 468 var tipTick = false;
469 var trackTip = function () { 470 if (tipTick || !liveTip) { 471 return; 472 } 473 tipTick = true; 474 requestAnimationFrame(function () { 475 tipTick = false; 476 if (liveTip && (liveTip.classList.contains('is-open') || liveTip.matches(':hover') || liveTip.contains(document.activeElement))) { 477 placeTip(liveTip); 478 } 479 }); 480 }; 481 /* capture: true also catches scrolls inside the compare tables' 482 horizontal scroll boxes, which move the trigger too. */ 483 window.addEventListener('scroll', trackTip, { passive: true, capture: true }); 484 window.addEventListener('resize', trackTip, { passive: true }); 485 486 document.addEventListener('click', function () { 487 closeTips(null); 488 }); 489 document.addEventListener('keydown', function (e) { 490 if (e.key === 'Escape') { 491 closeTips(null); 492 } 493 }); 494 } 495 496 /* Inline video tiles: lazy-start when visible, toggle button pauses/resumes. 497 A `data-src` tile keeps its file off the wire until the first play, so a 498 below-fold clip never competes with above-the-fold assets. Under reduced 499 motion nothing autoplays and the file is only fetched if the visitor asks 500 for it via the toggle. */ 501 document.querySelectorAll('[data-nv-video]').forEach(function (vid) { 502 var wrap = vid.parentElement; 503 var toggle = wrap ? wrap.querySelector('[data-nv-video-toggle]') : null; 504 505 var play = function () { 506 var src = vid.getAttribute('data-src'); 507 if (src && !vid.getAttribute('src')) { 508 vid.src = src; 509 vid.load(); 510 } 511 vid.play().catch(function () {}); 512 }; 513 514 var syncToggle = function () { 515 if (!toggle) { 516 return; 517 } 518 toggle.classList.toggle('is-playing', !vid.paused); 519 toggle.setAttribute('aria-label', vid.paused ? 'Play video' : 'Pause video'); 520 }; 521 vid.addEventListener('playing', syncToggle); 522 vid.addEventListener('pause', syncToggle); 523 524 if (toggle) { 525 toggle.addEventListener('click', function () { 526 if (vid.paused) { 527 play(); 528 } else { 529 vid.pause(); 530 } 531 }); 532 } 533 534 if (!reduce && 'IntersectionObserver' in window) { 535 var vo = new IntersectionObserver(function (entries) { 536 entries.forEach(function (e) { 537 if (e.isIntersecting) { 538 play(); 539 vo.unobserve(vid); 540 } 541 }); 542 }, { threshold: 0.35 }); 543 vo.observe(vid); 544 } 545 }); 546 547 /* Customer Spotlight video links: the image keeps the story URL as a 548 no-JavaScript fallback, while JavaScript opens the matching YouTube 549 interview in a native <dialog> instead of navigating away. The dialog 550 and [data-nv-spotlight-*] hooks live in patterns/spotlight.php. */ 551 var spotlightModal = document.querySelector('[data-nv-spotlight-modal]'); 552 if (spotlightModal && typeof spotlightModal.showModal === 'function') { 553 var spotlightFrame = spotlightModal.querySelector('[data-nv-spotlight-frame]'); 554 var spotlightTitle = spotlightModal.querySelector('[data-nv-spotlight-modal-title]'); 555 var spotlightClose = spotlightModal.querySelector('[data-nv-spotlight-close]'); 556 557 var closeSpotlightVideo = function () { 558 spotlightModal.close(); 559 if (spotlightFrame) { 560 spotlightFrame.removeAttribute('src'); 561 } 562 }; 563 564 document.querySelectorAll('[data-nv-spotlight-video]').forEach(function (trigger) { 565 trigger.addEventListener('click', function (event) { 566 var videoId = trigger.getAttribute('data-nv-spotlight-video'); 567 if (!videoId || !/^[A-Za-z0-9_-]{6,}$/.test(videoId) || !spotlightFrame) { 568 return; 569 } 570 571 event.preventDefault(); 572 var title = trigger.getAttribute('data-nv-spotlight-title') || 'Customer story'; 573 if (spotlightTitle) { 574 spotlightTitle.textContent = title; 575 } 576 spotlightFrame.title = title; 577 spotlightFrame.src = 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(videoId) + '?autoplay=1&rel=0'; 578 spotlightModal.showModal(); 579 if (spotlightClose) { 580 spotlightClose.focus(); 581 } 582 }); 583 }); 584 585 if (spotlightClose) { 586 spotlightClose.addEventListener('click', closeSpotlightVideo); 587 } 588 spotlightModal.addEventListener('click', function (event) { 589 if (event.target === spotlightModal) { 590 closeSpotlightVideo(); 591 } 592 }); 593 spotlightModal.addEventListener('close', function () { 594 if (spotlightFrame) { 595 spotlightFrame.removeAttribute('src'); 596 } 597 }); 598 } 599
600 /* Card carousels (.nv-cardrail): native scroll-snap tracks with shared 601 behavior â prev/next arrows step one card, arrow state syncs to scroll 602 position, and mouse users get click-and-drag. No autonomous motion: 603 the rail only moves when the user moves it. */ 604 document.querySelectorAll('[data-nv-carousel-prev], [data-nv-carousel-next]').forEach(function (btn) { 605 var track = document.getElementById(btn.getAttribute('aria-controls') || ''); 606 if (!track) { 607 return; 608 } 609 var dir = btn.hasAttribute('data-nv-carousel-next') ? 1 : -1; 610 btn.addEventListener('click', function () { 611 var card = track.firstElementChild; 612 var gap = parseFloat(getComputedStyle(track).columnGap) || 16; 613 /* data-nv-carousel-page rails (small items, e.g. filter pills) 614 step by most of a viewport instead of one item. */ 615 var step = card && !track.hasAttribute('data-nv-carousel-page') ? card.getBoundingClientRect().width + gap : track.clientWidth * 0.8; 616 track.scrollBy({ left: dir * step, behavior: reduce ? 'auto' : 'smooth' }); 617 }); 618 }); 619 620 document.querySelectorAll('.nv-cardrail').forEach(function (track) { 621 if (!track.id) { 622 return; 623 } 624 var btns = document.querySelectorAll('.nv-carousel-btn[aria-controls="' + track.id + '"]'); 625 if (btns.length) { 626 var syncCarouselBtns = function () { 627 var max = track.scrollWidth - track.clientWidth - 1; 628 btns.forEach(function (b) { 629 var atEnd = b.hasAttribute('data-nv-carousel-next') ? track.scrollLeft >= max : track.scrollLeft <= 0; 630 b.toggleAttribute('disabled', atEnd); 631 }); 632 }; 633 track.addEventListener('scroll', syncCarouselBtns, { passive: true }); 634 window.addEventListener('resize', syncCarouselBtns, { passive: true }); 635 syncCarouselBtns(); 636 } 637 638 /* Click-and-drag scrolling (mouse). Touch already pans natively; 639 scroll-snap is suspended during the drag and restored after. */ 640 var dragStartX = 0; 641 var dragStartScroll = 0; 642 var dragging = false; 643 var dragged = false; 644 645 track.addEventListener('pointerdown', function (e) { 646 if (e.pointerType !== 'mouse' || e.button !== 0) { 647 return; 648 } 649 dragging = true; 650 dragged = false; 651 dragStartX = e.clientX; 652 dragStartScroll = track.scrollLeft; 653 }); 654 655 track.addEventListener('pointermove', function (e) { 656 if (!dragging) { 657 return; 658 } 659 var dx = e.clientX - dragStartX; 660 if (!dragged && Math.abs(dx) > 4) { 661 dragged = true; 662 track.classList.add('is-dragging'); 663 track.setPointerCapture(e.pointerId); 664 } 665 if (dragged) { 666 track.scrollLeft = dragStartScroll - dx; 667 } 668 }); 669 670 var endDrag = function (e) { 671 if (!dragging) { 672 return; 673 } 674 dragging = false; 675 if (dragged) { 676 track.classList.remove('is-dragging'); 677 if (track.hasPointerCapture && track.hasPointerCapture(e.pointerId)) { 678 track.releasePointerCapture(e.pointerId); 679 } 680 /* dragged stays true until the trailing click is swallowed. */ 681 setTimeout(function () { 682 dragged = false; 683 }, 0); 684 } 685 }; 686 track.addEventListener('pointerup', endDrag); 687 track.addEventListener('pointercancel', endDrag); 688 689 /* Images/links would otherwise start a native HTML drag mid-swipe. */ 690 track.addEventListener('dragstart', function (e) { 691 e.preventDefault(); 692 }); 693 694 /* Swallow the click that ends a drag so card links don't fire. */ 695 track.addEventListener('click', function (e) { 696 if (dragged) { 697 e.preventDefault(); 698 e.stopPropagation(); 699 } 700 }, true); 701 }); 702 703 /* Shop-card chip tickers: the card rail itself pans on scroll-x, so an 704 inner scrollable chip row is unreachable mid-drag â chip rows that 705 overflow their card auto-scroll marquee-style instead. Reuses the 706 .nv-ticker container (overflow + fade mask) and keyframes; the track 707 is its own class because .nv-ticker__track's span styling targets 708 serif wordmarks, not chips. Duration scales with content so speed 709 stays constant. Rows that fit stay static; reduced-motion keeps the 710 plain overflow row. Runs after webfonts settle so the overflow 711 measurement uses final chip widths. Also targets .nv-product__chips 712 on the shared product-rail. */ 713 var buildChipTickers = function () { 714 document.querySelectorAll('.nv-product__chips, .nv-shop-card__chips').forEach(function (chips) { 715 var forceMarquee = chips.hasAttribute('data-nv-chip-marquee'); 716 if (chips.classList.contains('nv-ticker') || (!forceMarquee && chips.scrollWidth <= chips.clientWidth + 1)) { 717 return; 718 } 719 var items = Array.prototype.slice.call(chips.children); 720 var chipTrack = document.createElement('div'); 721 chipTrack.className = chips.classList.contains('nv-shop-card__chips') 722 ? 'nv-shop-card__chips-track' 723 : 'nv-product__chips-track';
724 items.forEach(function (item) { 725 chipTrack.appendChild(item); 726 }); 727 items.forEach(function (item) { 728 var dup = item.cloneNode(true); 729 dup.setAttribute('aria-hidden', 'true'); 730 chipTrack.appendChild(dup); 731 }); 732 chips.classList.add('nv-ticker'); 733 chips.appendChild(chipTrack); 734 chipTrack.style.animationDuration = Math.max(9, Math.round(chipTrack.scrollWidth / 2 / 28)) + 's'; 735 }); 736 }; 737 if (!reduce) { 738 if (document.fonts && document.fonts.ready) { 739 document.fonts.ready.then(buildChipTickers); 740 } else { 741 buildChipTickers(); 742 } 743 } 744 745 /* PDP reviews ([data-nv-reviews]): topic-tab filtering + REST-paginated 746 "Load more" against novos/v1/reviews (public read, no nonce). The 747 server renders the cards; JS only swaps/appends the returned HTML. 748 Responses are cached per category+page (promises, so concurrent 749 identical requests dedupe) and every tab's first page is prefetched 750 once the browser is idle, so tab switches are instant after the 751 first paint. A newer click supersedes an in-flight request instead 752 of being dropped. Also reveals the post-submission thanks banner 753 after the WPForms redirect back (?review=thanks). */ 754 var reviewsRoot = document.querySelector('[data-nv-reviews]'); 755 if (reviewsRoot && 'fetch' in window) { 756 var rvList = reviewsRoot.querySelector('[data-nv-reviews-list]'); 757 var rvMore = reviewsRoot.querySelector('[data-nv-reviews-more]'); 758 var rvTabs = reviewsRoot.querySelectorAll('[data-nv-reviews-tab]'); 759 var rvCache = {}; 760 var rvState = { 761 page: parseInt(reviewsRoot.dataset.page || '1', 10), 762 maxPages: parseInt(reviewsRoot.dataset.maxPages || '1', 10), 763 perPage: parseInt(reviewsRoot.dataset.perPage || '3', 10), 764 category: '', 765 seq: 0 766 }; 767 768 var syncMore = function () { 769 if (rvMore) { 770 rvMore.hidden = rvState.page >= rvState.maxPages; 771 } 772 }; 773 774 /* CSS clamps card text to 3 lines and reserves that height; append 775 Read more only where the text actually overflows. */ 776 var clampReviews = function () { 777 if (!rvList) { 778 return; 779 } 780 rvList.querySelectorAll('.nv-review-card__text').forEach(function (text) { 781 if (text.dataset.nvClamped || text.scrollHeight - text.clientHeight <= 1) { 782 return; 783 } 784 text.dataset.nvClamped = '1'; 785 var toggle = document.createElement('button'); 786 toggle.type = 'button'; 787 toggle.className = 'nv-review-card__toggle'; 788 toggle.textContent = 'Read more'; 789 toggle.setAttribute('aria-expanded', 'false'); 790 toggle.addEventListener('click', function () { 791 var expanded = text.classList.toggle('is-expanded'); 792 toggle.textContent = expanded ? 'Read less' : 'Read more'; 793 toggle.setAttribute('aria-expanded', String(expanded)); 794 }); 795 text.after(toggle); 796 }); 797 }; 798 799 /* Fetch (or reuse) a page of cards. Caches the promise, not the 800 payload, so concurrent identical requests share one round-trip; 801 failed requests are evicted so they can retry. */ 802 var requestReviews = function (category, page) { 803 var key = category + '|' + page; 804 if (!rvCache[key]) { 805 var url = new URL(reviewsRoot.dataset.endpoint, window.location.origin); 806 url.searchParams.set('product_id', reviewsRoot.dataset.productId || '0'); 807 url.searchParams.set('page', String(page)); 808 url.searchParams.set('per_page', String(rvState.perPage)); 809 if (category) { 810 url.searchParams.set('category', category); 811 } 812 rvCache[key] = fetch(url.toString()).then(function (res) { return res.json(); }); 813 rvCache[key].catch(function () { 814 delete rvCache[key]; 815 }); 816 } 817 return rvCache[key]; 818 }; 819 820 var fetchReviews = function (page, replace) { 821 if (!rvList) { 822 return; 823 } 824 var seq = ++rvState.seq; 825 rvList.classList.add('is-loading'); 826 if (rvMore) { 827 rvMore.classList.add('is-loading'); 828 } 829 requestReviews(rvState.category, page) 830 .then(function (data) { 831 if (seq !== rvState.seq || typeof data.items_html !== 'string') { 832 return; 833 } 834 if (replace) { 835 rvList.innerHTML = data.items_html; 836 } else { 837 rvList.insertAdjacentHTML('beforeend', data.items_html); 838 } 839 rvState.page = data.page || page; 840 rvState.maxPages = data.max_pages || 0; 841 clampReviews(); 842 syncMore(); 843 }) 844 .catch(function () {}) 845 .then(function () { 846 if (seq !== rvState.seq) { 847 return; 848 } 849 rvList.classList.remove('is-loading'); 850 if (rvMore) { 851 rvMore.classList.remove('is-loading'); 852 } 853 }); 854 }; 855
856 rvTabs.forEach(function (tab) { 857 tab.addEventListener('click', function () { 858 var category = tab.getAttribute('data-nv-reviews-tab') || ''; 859 if (category === rvState.category) { 860 return; 861 } 862 rvState.category = category; 863 rvTabs.forEach(function (t) { 864 var on = t === tab; 865 t.classList.toggle('is-active', on); 866 t.setAttribute('aria-pressed', String(on)); 867 }); 868 fetchReviews(1, true); 869 }); 870 }); 871 872 if (rvMore) { 873 rvMore.addEventListener('click', function () { 874 fetchReviews(rvState.page + 1, false); 875 }); 876 syncMore(); 877 } 878 879 // Warm the cache for every tab's first page while the browser is 880 // idle, so the first click on any topic swaps instantly. 881 if (rvTabs.length) { 882 var rvPrefetch = function () { 883 rvTabs.forEach(function (tab) { 884 requestReviews(tab.getAttribute('data-nv-reviews-tab') || '', 1).catch(function () {}); 885 }); 886 }; 887 if ('requestIdleCallback' in window) { 888 window.requestIdleCallback(rvPrefetch, { timeout: 4000 }); 889 } else { 890 window.setTimeout(rvPrefetch, 2500); 891 } 892 } 893 894 clampReviews(); 895 if (document.fonts && document.fonts.ready) { 896 // Re-measure once webfonts land; metrics shift can change line count. 897 document.fonts.ready.then(clampReviews); 898 } 899 } 900 901 /* /ingredients/ product filter (patterns/page-ingredients.php): the 902 [data-nv-ing-filter] pills toggle `hidden` on every [data-nv-ing-product] 903 element â the ingredient articles and both nav copies' groups. A 904 #novos-{core|boost|vital|bar} hash on load (legacy PDP deep links) 905 preselects that product. */ 906 var ingFilterBtns = document.querySelectorAll('[data-nv-ing-filter]'); 907 if (ingFilterBtns.length) { 908 var ingProducts = document.querySelectorAll('[data-nv-ing-product]'); 909 910 var applyIngFilter = function (product) { 911 ingProducts.forEach(function (el) { 912 el.hidden = product !== 'all' && el.getAttribute('data-nv-ing-product') !== product; 913 }); 914 ingFilterBtns.forEach(function (btn) { 915 var selected = btn.getAttribute('data-nv-ing-filter') === product; 916 btn.classList.toggle('is-selected', selected); 917 btn.setAttribute('aria-pressed', selected ? 'true' : 'false'); 918 }); 919 }; 920 921 ingFilterBtns.forEach(function (btn) { 922 btn.addEventListener('click', function () { 923 applyIngFilter(btn.getAttribute('data-nv-ing-filter')); 924 }); 925 }); 926 927 var ingHash = (window.location.hash || '').replace('#novos-', ''); 928 if (ingHash && document.querySelector('[data-nv-ing-filter="' + ingHash + '"]')) { 929 applyIngFilter(ingHash); 930 } 931 } 932 933 /* Shop collections: goal filtering. Goal cards (data-nv-shop-goal) 934 toggle cards carrying data-nv-goals; a section (data-nv-shop-section) 935 hides when none of its cards match, and the bundles/samples goals show 936 their whole section instead. Filter pills (data-nv-collection) switch 937 collections in place: the goal filter applies instantly, the URL moves 938 to /collections/{slug}/ via history.pushState, and the heading, 939 description and document title swap from the server-rendered JSON map 940 (#nvCollectionsData, inc/collections.php). Direct loads of collection 941 URLs stay fully server-rendered; the active goal arrives via 942 data-nv-collection-filter and is applied on load. */ 943 var shopCards = document.querySelectorAll('[data-nv-goals]'); 944 if (shopCards.length) { 945 var shopGoalCards = document.querySelectorAll('[data-nv-shop-goal]'); 946 var shopSections = document.querySelectorAll('[data-nv-shop-section]'); 947 var shopPills = document.querySelectorAll('.nv-filter-pill[data-nv-collection]'); 948 949 var applyShopFilter = function (goal) { 950 var sectionGoal = goal === 'bundles' || goal === 'samples'; 951 shopCards.forEach(function (card) { 952 var hide; 953 if (goal === 'all') { 954 hide = false;
955 } else if (sectionGoal) { 956 hide = !card.closest('[data-nv-shop-section="' + goal + '"]'); 957 } else { 958 hide = (card.getAttribute('data-nv-goals') || '').split(/\s+/).indexOf(goal) === -1; 959 } 960 card.classList.toggle('nv-shop-hidden', hide); 961 }); 962 shopSections.forEach(function (sec) { 963 var visible = sec.querySelector('[data-nv-goals]:not(.nv-shop-hidden)'); 964 sec.classList.toggle('nv-shop-hidden', !visible); 965 }); 966 shopGoalCards.forEach(function (card) { 967 card.classList.toggle('is-active', (card.getAttribute('data-nv-shop-goal') || '') === goal); 968 }); 969 }; 970 971 shopGoalCards.forEach(function (card) { 972 card.addEventListener('click', function () { 973 var wasActive = card.classList.contains('is-active'); 974 applyShopFilter(wasActive ? 'all' : (card.getAttribute('data-nv-shop-goal') || 'all')); 975 if (!wasActive) { 976 var firstMatch = document.querySelector('[data-nv-shop-section]:not(.nv-shop-hidden)'); 977 if (firstMatch) { 978 firstMatch.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' }); 979 } 980 } 981 }); 982 }); 983 984 var shopCollections = null; 985 var shopCollectionsEl = document.getElementById('nvCollectionsData'); 986 if (shopCollectionsEl) { 987 try { 988 shopCollections = JSON.parse(shopCollectionsEl.textContent); 989 } catch (err) { 990 shopCollections = null; 991 } 992 } 993 var shopHeading = document.querySelector('[data-nv-collection-heading]'); 994 var shopDesc = document.querySelector('[data-nv-collection-desc]'); 995 var shopPillRail = document.getElementById('nvShopFiltersRail'); 996 997 /* Keep the active pill visible when the rail overflows (deep-linked 998 collections can activate a pill far to the right). */ 999 var revealActivePill = function (smooth) { 1000 var active = shopPillRail && shopPillRail.querySelector('.nv-filter-pill.is-active'); 1001 if (!active || shopPillRail.scrollWidth <= shopPillRail.clientWidth) { 1002 return; 1003 } 1004 var delta = active.getBoundingClientRect().left - shopPillRail.getBoundingClientRect().left; 1005 shopPillRail.scrollTo({ 1006 left: shopPillRail.scrollLeft + delta - (shopPillRail.clientWidth - active.offsetWidth) / 2, 1007 behavior: smooth && !reduce ? 'smooth' : 'auto' 1008 }); 1009 }; 1010 1011 var showCollection = function (slug) { 1012 var data = shopCollections && shopCollections[slug]; 1013 if (!data) { 1014 return false; 1015 } 1016 applyShopFilter(data.filter); 1017 shopPills.forEach(function (pill) { 1018 var active = pill.getAttribute('data-nv-collection') === slug; 1019 pill.classList.toggle('is-active', active); 1020 if (active) { 1021 pill.setAttribute('aria-current', 'page'); 1022 } else { 1023 pill.removeAttribute('aria-current'); 1024 } 1025 }); 1026 if (shopHeading) { 1027 shopHeading.textContent = data.heading; 1028 } 1029 if (shopDesc) { 1030 shopDesc.innerHTML = data.description; 1031 } 1032 if (data.title) { 1033 document.title = data.title; 1034 } 1035 revealActivePill(true); 1036 return true; 1037 }; 1038 1039 shopPills.forEach(function (pill) { 1040 pill.addEventListener('click', function (event) { 1041 if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { 1042 return; 1043 } 1044 var slug = pill.getAttribute('data-nv-collection') || ''; 1045 if (showCollection(slug)) { 1046 event.preventDefault(); 1047 window.history.pushState({ nvCollection: slug }, '', pill.href); 1048 } 1049 }); 1050 }); 1051 1052 if (shopPills.length && shopCollections) { 1053 window.addEventListener('popstate', function () { 1054 var match = window.location.pathname.match(/^\/collections\/([^/]+)\/?$/); 1055 showCollection(match ? match[1] : 'products'); 1056 }); 1057 } 1058 1059 var shopUpper = document.querySelector('[data-nv-collection-filter]'); 1060 var initialGoal = shopUpper ? shopUpper.getAttribute('data-nv-collection-filter') : ''; 1061 if (initialGoal && initialGoal !== 'all') { 1062 applyShopFilter(initialGoal); 1063 } 1064 revealActivePill(false); 1065 } 1066 1067 if (/[?&]review=thanks/.test(window.location.search)) { 1068 var rvThanks = document.querySelector('[data-nv-reviews-thanks]'); 1069 if (rvThanks) { 1070 rvThanks.hidden = false; 1071 } 1072 } 1073 1074 // ---------- Header search modal ---------- 1075 var searchTrigger = document.getElementById('nvSearchTrigger'); 1076 var searchModal = document.getElementById('nvSearchModal'); 1077 if (searchTrigger && searchModal && typeof searchModal.showModal === 'function') { 1078 var searchInput = searchModal.querySelector('input[type="search"]'); 1079 1080 /* Jetpack Instant Search (staging/prod) owns search when present: the 1081 trigger carries .jetpack-instant-search__open-overlay-button, which 1082 Jetpack binds with a capture-phase click handler that opens its 1083 overlay. The theme dialog must then stay closed â a native <dialog> 1084 sits in the top layer and would block clicks on Jetpack's results. */ 1085 var jetpackSearchOverlay = function () { 1086 return document.querySelector('.jetpack-instant-search__overlay'); 1087 }; 1088 1089 var openSearch = function () { 1090 if (jetpackSearchOverlay()) { 1091 return; 1092 } 1093 searchModal.showModal(); 1094 searchTrigger.setAttribute('aria-expanded', 'true'); 1095 if (searchInput) { 1096 searchInput.focus(); 1097 } 1098 }; 1099 1100 var closeSearch = function () { 1101 searchModal.close(); 1102 }; 1103 1104 searchTrigger.addEventListener('click', openSearch); 1105 1106 /* Safety net: if the theme dialog opened before Jetpack finished 1107 mounting, Jetpack still watches this input[name="s"] and opens its 1108 overlay behind the dialog on the first keystroke. Yield to it. */ 1109 if (searchInput) { 1110 searchInput.addEventListener('input', function () { 1111 window.setTimeout(function () {
1112 var overlay = jetpackSearchOverlay(); 1113 if (overlay && !overlay.classList.contains('is-hidden') && searchModal.open) { 1114 searchModal.close(); 1115 } 1116 }, 120); 1117 }); 1118 } 1119 1120 searchModal.addEventListener('close', function () { 1121 searchTrigger.setAttribute('aria-expanded', 'false'); 1122 }); 1123 1124 searchModal.querySelectorAll('[data-nv-search-close]').forEach(function (btn) { 1125 btn.addEventListener('click', closeSearch); 1126 }); 1127 1128 // Backdrop click: the dialog itself is the click target only when 1129 // the click lands outside the panel. 1130 searchModal.addEventListener('click', function (e) { 1131 if (e.target === searchModal) { 1132 closeSearch(); 1133 } 1134 }); 1135 1136 // Cmd/Ctrl+K opens search from anywhere. Clicking the trigger (rather 1137 // than calling openSearch) lets Jetpack's own handler take over when 1138 // Instant Search is active. 1139 document.addEventListener('keydown', function (e) { 1140 if ((e.metaKey || e.ctrlKey) && e.key === 'k') { 1141 e.preventDefault(); 1142 if (searchModal.open) { 1143 closeSearch(); 1144 } else { 1145 searchTrigger.click(); 1146 } 1147 } 1148 }); 1149 } 1150 1151 /* Study drawer marker comparison ([data-nv-markers]): the server renders 1152 every tab panel and bar row (rows past the collapsed count carry 1153 [data-nv-markers-extra][hidden]); JS only swaps the visible panel and 1154 toggles the extra rows. The "Show all" buttons ship [hidden] and are 1155 revealed here so the collapsed list stays clean without JS. */ 1156 document.querySelectorAll('[data-nv-markers]').forEach(function (root) { 1157 var tabs = root.querySelectorAll('[data-nv-markers-tab]'); 1158 var panels = root.querySelectorAll('[data-nv-markers-panel]'); 1159 1160 var collapsePanel = function (panel) { 1161 panel.querySelectorAll('[data-nv-markers-extra]').forEach(function (row) { 1162 row.hidden = true; 1163 }); 1164 var more = panel.querySelector('[data-nv-markers-more]'); 1165 if (more) { 1166 more.hidden = false; 1167 more.setAttribute('aria-expanded', 'false'); 1168 more.textContent = 'Show all ' + panel.querySelectorAll('.nv-sci-bar').length; 1169 } 1170 }; 1171 1172 panels.forEach(function (panel) { 1173 collapsePanel(panel); 1174 var more = panel.querySelector('[data-nv-markers-more]'); 1175 if (more) { 1176 more.addEventListener('click', function () { 1177 var expand = more.getAttribute('aria-expanded') !== 'true'; 1178 panel.querySelectorAll('[data-nv-markers-extra]').forEach(function (row) { 1179 row.hidden = !expand; 1180 }); 1181 more.setAttribute('aria-expanded', String(expand)); 1182 more.textContent = expand ? 'Show fewer' : 'Show all ' + panel.querySelectorAll('.nv-sci-bar').length; 1183 }); 1184 } 1185 }); 1186
1187 tabs.forEach(function (tab) { 1188 tab.addEventListener('click', function () { 1189 var key = tab.getAttribute('data-nv-markers-tab'); 1190 tabs.forEach(function (t) { 1191 t.setAttribute('aria-selected', String(t === tab)); 1192 }); 1193 panels.forEach(function (panel) { 1194 var on = panel.getAttribute('data-nv-markers-panel') === key; 1195 panel.hidden = !on; 1196 if (on) { 1197 collapsePanel(panel); 1198 } 1199 }); 1200 }); 1201 }); 1202 }); 1203 1204 /* Blog "More Articles" load-more: progressive enhancement over the core 1205 query-pagination inside `.nv-loadmore` queries. The numbered pagination 1206 stays in the markup (SEO + no-JS fallback); with JS it is replaced by a 1207 button that fetches the next paginated URL, imports its cards into the 1208 current grid, and follows that page's own next-link until exhausted. */ 1209 document.querySelectorAll('.nv-loadmore').forEach(function (queryBlock) { 1210 var pagination = queryBlock.querySelector('.wp-block-query-pagination'); 1211 var grid = queryBlock.querySelector('.wp-block-post-template'); 1212 if (!pagination || !grid) { 1213 return; 1214 } 1215 1216 var nextLink = pagination.querySelector('.wp-block-query-pagination-next'); 1217 if (!nextLink) { 1218 pagination.remove(); 1219 return; 1220 } 1221 1222 var nextUrl = nextLink.href; 1223 var button = document.createElement('button'); 1224 button.type = 'button'; 1225 button.className = 'nv-btn nv-btn--ghost nv-loadmore__btn'; 1226 button.innerHTML = '<span>More Articles</span><span class="nv-loadmore__spinner" aria-hidden="true"></span>'; 1227 pagination.replaceWith(button); 1228 1229 button.addEventListener('click', function () { 1230 if (button.classList.contains('is-loading')) { 1231 return; 1232 } 1233 button.classList.add('is-loading'); 1234 button.disabled = true; 1235 1236 fetch(nextUrl, { credentials: 'same-origin' }) 1237 .then(function (response) { 1238 if (!response.ok) { 1239 // e.g. non-canonical archive aliases 404 on /page/N; 1240 // nothing more to load, so retire the button. 1241 button.remove(); 1242 throw new Error('nv-loadmore-http'); 1243 } 1244 return response.text(); 1245 }) 1246 .then(function (html) { 1247 var doc = new DOMParser().parseFromString(html, 'text/html'); 1248 var incoming = doc.querySelector('.nv-loadmore .wp-block-post-template'); 1249 if (incoming) { 1250 incoming.querySelectorAll(':scope > li').forEach(function (item) { 1251 grid.appendChild(document.adoptNode(item)); 1252 }); 1253 } 1254 var next = doc.querySelector('.nv-loadmore .wp-block-query-pagination-next'); 1255 button.classList.remove('is-loading'); 1256 button.disabled = false; 1257 if (next) { 1258 nextUrl = new URL(next.getAttribute('href'), nextUrl).href; 1259 } else { 1260 button.remove(); 1261 } 1262 }) 1263 .catch(function (error) { 1264 if (error && 'nv-loadmore-http' === error.message) { 1265 return; 1266 } 1267 // Network failure: fall back to real pagination. 1268 window.location.href = nextUrl; 1269 }); 1270 }); 1271 }); 1272 1273 /* Single-post sidebar on narrow screens (<=980px): the left rail can't 1274 stay a rail, so its boxes are re-homed into the copy â takeaways ahead 1275 of the article, the product card around the halfway mark, the expert 1276 tip after the last block. Whatever subset the post has keeps those 1277 anchors, so one box lands at the top, two at top + middle, and so on. 1278 Boxes move back into the rail when the viewport grows again. 1279 Gated on body.single-post (classList is cheaper than a DOM walk) so 1280 every other page pays only one boolean check. */ 1281 if (document.body.classList.contains('single-post')) { 1282 var artSide = document.querySelector('.nv-article-side'); 1283 var artProse = artSide && document.querySelector('.nv-article-body__main .nv-prose'); 1284 if (artSide && artProse) { 1285 var artSideItems = Array.prototype.slice.call(artSide.children); 1286 var artNarrow = window.matchMedia('(max-width: 980px)'); 1287 1288 /* Blocks a box can sit between: re-homed boxes and the appended FDA 1289 disclaimer are not part of the article's own flow. */ 1290 var artProseBlocks = function () { 1291 return Array.prototype.filter.call(artProse.children, function (el) { 1292 return !el.classList.contains('nv-article-side__item') && 1293 !el.classList.contains('fda-disclaimer-wrap'); 1294 }); 1295 }; 1296 1297 /* Halfway block, nudged forward to a nearby heading so the box lands
1298 between two sections rather than inside one. Counting blocks rather 1299 than measuring pixels keeps this correct while images still load. */ 1300 var artMidTarget = function () { 1301 var blocks = artProseBlocks(); 1302 if (blocks.length < 4) { 1303 return null; 1304 } 1305 var index = Math.floor(blocks.length / 2); 1306 for (var i = index; i < Math.min(blocks.length, index + 3); i++) { 1307 if ('H2' === blocks[i].tagName || 'H3' === blocks[i].tagName) { 1308 return blocks[i]; 1309 } 1310 } 1311 return blocks[index]; 1312 }; 1313 1314 var artSideTarget = function (item) { 1315 if (item.classList.contains('nv-article-side__takeaways')) { 1316 return artProseBlocks()[0] || null; 1317 } 1318 if (item.classList.contains('nv-article-side__product')) { 1319 return artMidTarget(); 1320 } 1321 return artProse.querySelector(':scope > .fda-disclaimer-wrap'); 1322 }; 1323 1324 var artSideDistribute = function () { 1325 artSideItems.forEach(function (item) { 1326 var target = artSideTarget(item); 1327 item.classList.add('nv-article-side__item--inflow'); 1328 if (target) { 1329 artProse.insertBefore(item, target); 1330 } else { 1331 artProse.appendChild(item); 1332 } 1333 }); 1334 artSide.hidden = true; 1335 }; 1336 1337 var artSideRestore = function () { 1338 artSide.hidden = false; 1339 artSideItems.forEach(function (item) { 1340 item.classList.remove('nv-article-side__item--inflow'); 1341 artSide.appendChild(item); 1342 }); 1343 }; 1344 1345 var artSideSync = function () { 1346 if (artNarrow.matches) { 1347 if (!artSide.hidden) { 1348 artSideDistribute(); 1349 } 1350 } else if (artSide.hidden) { 1351 artSideRestore(); 1352 } 1353 }; 1354 1355 if (artNarrow.addEventListener) { 1356 artNarrow.addEventListener('change', artSideSync); 1357 } else if (artNarrow.addListener) { 1358 artNarrow.addListener(artSideSync); 1359 } 1360 1361 artSideSync(); 1362 } 1363 } 1364 1365 /* PDP sticky Buy Now: show once the ATF leaves the viewport so mid-page 1366 scrollers can jump to #buy without fighting the desktop sticky buy 1367 column (which stays "visible" under IntersectionObserver alone). */ 1368 var stickyCta = document.querySelector('[data-nv-sticky-cta]'); 1369 var stickySentinel = document.querySelector('.nv-pdp__top'); 1370 var stickyFooter = document.getElementById('footer') || document.querySelector('.nv-footer'); 1371 if (stickyCta && stickySentinel && 'IntersectionObserver' in window) { 1372 stickyCta.setAttribute('inert', ''); 1373 var stickyAtfVisible = true; 1374 var stickyFooterVisible = false; 1375 var stickySync = function () { 1376 var show = !stickyAtfVisible && !stickyFooterVisible; 1377 stickyCta.classList.toggle('is-visible', show); 1378 stickyCta.setAttribute('aria-hidden', show ? 'false' : 'true'); 1379 if (show) { 1380 stickyCta.removeAttribute('inert'); 1381 } else { 1382 stickyCta.setAttribute('inert', ''); 1383 } 1384 document.documentElement.classList.toggle('nv-pdp-sticky-cta-on', show); 1385 }; 1386 new IntersectionObserver(function (entries) { 1387 entries.forEach(function (entry) { 1388 stickyAtfVisible = entry.isIntersecting; 1389 }); 1390 stickySync(); 1391 }, { threshold: 0 }).observe(stickySentinel); 1392 if (stickyFooter) { 1393 new IntersectionObserver(function (entries) { 1394 entries.forEach(function (entry) { 1395 stickyFooterVisible = entry.isIntersecting; 1396 }); 1397 stickySync(); 1398 }, { threshold: 0 }).observe(stickyFooter); 1399 } 1400 } 1401 1402 onScroll(); 1403 1404 /* PDP documentary: poster-only until play â src stays off the wire 1405 so a below-fold ~20MB 1080p file never competes with ATF assets. */ 1406 document.querySelectorAll('.nv-docu__media').forEach(function (wrap) { 1407 var video = wrap.querySelector('.nv-docu__video'); 1408 var btn = wrap.querySelector('.nv-docu__play'); 1409 if (!video || !btn) { 1410 return; 1411 } 1412 var start = function () { 1413 var src = video.getAttribute('data-src'); 1414 if (!src) { 1415 return; 1416 } 1417 if (!video.getAttribute('src')) { 1418 video.src = src; 1419 video.load(); 1420 } 1421 wrap.classList.add('is-playing'); 1422 video.play().catch(function () {}); 1423 btn.setAttribute('hidden', ''); 1424 }; 1425 btn.addEventListener('click', start); 1426 video.addEventListener('play', function () { 1427 wrap.classList.add('is-playing'); 1428 btn.setAttribute('hidden', ''); 1429 }); 1430 }); 1431 1432 /* Diet Poster: native fields when Klaviyo does not inject. Hide them 1433 once the official form hydrates (production). */ 1434 var dietMount = document.querySelector('.klaviyo-form-XEdKVR'); 1435 if (dietMount && 'MutationObserver' in window) { 1436 var hideDietFallback = function () { 1437 if (!dietMount.children.length && !dietMount.querySelector('form, iframe, input')) { 1438 return; 1439 } 1440 var fallback = dietMount.nextElementSibling; 1441 if (fallback && fallback.classList.contains('nv-lead-fallback')) { 1442 fallback.hidden = true; 1443 } 1444 }; 1445 hideDietFallback(); 1446 new MutationObserver(hideDietFallback).observe(dietMount, { childList: true, subtree: true }); 1447 } 1448 1449 /* Newsletter / Insider: same pattern for Vnm96E + U4hFK9 mounts. */ 1450 document.querySelectorAll('.klaviyo-form-Vnm96E, .klaviyo-form-U4hFK9').forEach(function (mount) { 1451 if (!('MutationObserver' in window)) { 1452 return; 1453 } 1454 var hideNewsletterFallback = function () { 1455 if (!mount.children.length && !mount.querySelector('form, iframe, input')) { 1456 return; 1457 } 1458 var fallback = mount.nextElementSibling; 1459 if (fallback && fallback.classList.contains('nv-newsletter-fallback')) { 1460 fallback.hidden = true; 1461 } 1462 }; 1463 hideNewsletterFallback(); 1464 new MutationObserver(hideNewsletterFallback).observe(mount, { childList: true, subtree: true }); 1465 }); 1466 1467 document.querySelectorAll('.nv-lead-fallback').forEach(function (form) { 1468 form.addEventListener('submit', function (event) { 1469 event.preventDefault(); 1470 var rest = form.getAttribute('data-rest'); 1471 var nonce = form.getAttribute('data-nonce'); 1472 var pdf = form.getAttribute('data-pdf'); 1473 var status = form.querySelector('.nv-lead-fallback__status'); 1474 var email = form.querySelector('[name="email"]'); 1475 var first = form.querySelector('[name="first_name"]'); 1476 var honey = form.querySelector('[name="website"]'); 1477 var btn = form.querySelector('[type="submit"]'); 1478 if (!rest || !email || !email.value) { 1479 return; 1480 } 1481 if (btn) { 1482 btn.disabled = true; 1483 } 1484 fetch(rest, { 1485 method: 'POST', 1486 credentials: 'same-origin', 1487 headers: { 1488 'Content-Type': 'application/json', 1489 'X-WP-Nonce': nonce || '' 1490 }, 1491 body: JSON.stringify({ 1492 email: email.value, 1493 first_name: first ? first.value : '', 1494 website: honey ? honey.value : '' 1495 }) 1496 }).then(function (res) { 1497 return res.json().then(function (data) { 1498 return { ok: res.ok, data: data }; 1499 }); 1500 }).then(function (result) { 1501 if (result.ok && result.data && result.data.ok) { 1502 window.location.href = result.data.pdf || pdf; 1503 return; 1504 } 1505 if (status) { 1506 status.hidden = false;
1507 status.textContent = (result.data && result.data.message) || 'Enter a valid email address.'; 1508 } 1509 if (btn) { 1510 btn.disabled = false; 1511 } 1512 }).catch(function () { 1513 if (pdf) { 1514 window.location.href = pdf; 1515 } else if (btn) { 1516 btn.disabled = false; 1517 } 1518 }); 1519 }); 1520 }); 1521 1522 /* ---------- Shared toggle-group / disclosure controller ---------- */ 1523 /* Two mechanically identical jobs behind one controller (SHARED.md item 1524 #1), piloted on metabolism-benefits (6 toggle groups + 5 drawers): 1525 (a) [data-nv-toggle-group] sets .is-active (+ aria-selected/ 1526 aria-pressed where present) on the clicked [data-nv-toggle-item], 1527 clearing its siblings, and optionally swaps a matching set of 1528 [data-nv-toggle-panel] elements; (b) [data-nv-disclosure] toggles 1529 .is-open + aria-expanded on its trigger. A group also fires 1530 `nv:toggle` so page-specific code (e.g. a bar chart driven by the 1531 selected pill) can react without its own click handler. */ 1532 document.querySelectorAll('[data-nv-toggle-group]').forEach(function (group) { 1533 var items = Array.prototype.slice.call(group.querySelectorAll('[data-nv-toggle-item]')); 1534 var panelGroup = group.getAttribute('data-nv-toggle-group'); 1535 1536 group.addEventListener('click', function (e) { 1537 var btn = e.target.closest('[data-nv-toggle-item]'); 1538 if (!btn || !group.contains(btn)) { 1539 return; 1540 } 1541 var value = btn.getAttribute('data-nv-toggle-item'); 1542 1543 items.forEach(function (item) { 1544 var on = item === btn; 1545 item.classList.toggle('is-active', on); 1546 if (item.hasAttribute('aria-selected')) { 1547 item.setAttribute('aria-selected', String(on)); 1548 } 1549 if (item.hasAttribute('aria-pressed')) { 1550 item.setAttribute('aria-pressed', String(on)); 1551 } 1552 }); 1553 1554 if (panelGroup) { 1555 document.querySelectorAll('[data-nv-toggle-panel="' + panelGroup + '"]').forEach(function (panel) { 1556 panel.hidden = panel.getAttribute('data-nv-toggle-panel-value') !== value; 1557 }); 1558 } 1559 1560 group.dispatchEvent(new CustomEvent('nv:toggle', { detail: { value: value } })); 1561 }); 1562 }); 1563 1564 document.querySelectorAll('[data-nv-disclosure]').forEach(function (drawer) { 1565 var trigger = drawer.querySelector('[data-nv-disclosure-trigger]'); 1566 if (!trigger) { 1567 return; 1568 } 1569 trigger.addEventListener('click', function () { 1570 var open = drawer.classList.toggle('is-open'); 1571 trigger.setAttribute('aria-expanded', String(open)); 1572 }); 1573 }); 1574 1575 /* ---------- Metabolism-benefits: hero caption cycler ---------- */ 1576 var mbHero = document.querySelector('[data-nv-hero-cycler]'); 1577 if (mbHero) { 1578 var mbLayers = Array.prototype.slice.call(mbHero.querySelectorAll('[data-nv-hero-layer]')); 1579 if (mbLayers.length) { 1580 var mbSetStep = function (i) { 1581 mbLayers.forEach(function (layer, idx) { 1582 layer.classList.toggle('is-active', idx === i); 1583 }); 1584 }; 1585 if (reduce) { 1586 mbSetStep(mbLayers.length - 1); 1587 } else { 1588 var mbStep = 0; 1589 mbSetStep(mbStep); 1590 window.setInterval(function () { 1591 mbStep = (mbStep + 1) % mbLayers.length; 1592 mbSetStep(mbStep); 1593 }, 1800); 1594 } 1595 } 1596 } 1597 1598 /* ---------- Metabolism-benefits: sticky scrollspy subnav ---------- */ 1599 var mbSubnav = document.querySelector('[data-nv-subnav]'); 1600 if (mbSubnav) { 1601 var mbFill = mbSubnav.querySelector('[data-nv-subnav-fill]'); 1602 var mbPills = Array.prototype.slice.call(mbSubnav.querySelectorAll('[data-nv-toggle-item]')); 1603 var mbSections = mbPills 1604 .map(function (pill) { 1605 return document.getElementById(pill.getAttribute('data-nv-toggle-item')); 1606 }) 1607 .filter(Boolean); 1608 1609 mbPills.forEach(function (pill) { 1610 pill.addEventListener('click', function () { 1611 var target = document.getElementById(pill.getAttribute('data-nv-toggle-item')); 1612 if (target) { 1613 target.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' }); 1614 } 1615 }); 1616 }); 1617 1618 if ('IntersectionObserver' in window && mbSections.length) { 1619 var mbSubnavObserver = new IntersectionObserver( 1620 function (entries) { 1621 var visible = entries 1622 .filter(function (entry) { 1623 return entry.isIntersecting; 1624 }) 1625 .sort(function (a, b) { 1626 return b.intersectionRatio - a.intersectionRatio; 1627 })[0]; 1628 if (visible) {
1629 mbPills.forEach(function (pill) { 1630 pill.classList.toggle('is-active', pill.getAttribute('data-nv-toggle-item') === visible.target.id); 1631 }); 1632 } 1633 }, 1634 { rootMargin: '-45% 0px -45% 0px', threshold: [0, 0.25, 0.6] } 1635 ); 1636 mbSections.forEach(function (section) { 1637 mbSubnavObserver.observe(section); 1638 }); 1639 } 1640 1641 if (mbFill) { 1642 var mbOnScroll = function () { 1643 var doc = document.documentElement; 1644 var max = doc.scrollHeight - doc.clientHeight; 1645 mbFill.style.width = (max > 0 ? Math.min(100, (doc.scrollTop / max) * 100) : 0) + '%'; 1646 }; 1647 window.addEventListener('scroll', mbOnScroll, { passive: true }); 1648 mbOnScroll(); 1649 } 1650 } 1651 1652 /* ---------- Metabolism-benefits: illustrative fat/lean bar chart ---------- */ 1653 var mbBarChart = document.querySelector('[data-nv-barchart]'); 1654 var mbProteinToggle = document.querySelector('[data-nv-toggle-group="mb-protein"]'); 1655 if (mbBarChart && mbProteinToggle) { 1656 var mbFat = mbBarChart.querySelector('[data-nv-barchart-fat]'); 1657 var mbLean = mbBarChart.querySelector('[data-nv-barchart-lean]'); 1658 var mbFatValue = mbBarChart.querySelector('[data-nv-barchart-fat-value]'); 1659 var mbLeanValue = mbBarChart.querySelector('[data-nv-barchart-lean-value]'); 1660 var mbStack = mbBarChart.querySelector('[data-nv-barchart-stack]'); 1661 mbProteinToggle.addEventListener('nv:toggle', function (e) { 1662 var on = e.detail.value === 'on'; 1663 var fat = on ? 82 : 55; 1664 var lean = 100 - fat; 1665 if (mbFat) { 1666 mbFat.style.width = fat + '%'; 1667 } 1668 if (mbLean) { 1669 mbLean.style.width = lean + '%'; 1670 } 1671 if (mbFatValue) { 1672 mbFatValue.textContent = fat + '%'; 1673 } 1674 if (mbLeanValue) { 1675 mbLeanValue.textContent = lean + '%'; 1676 } 1677 if (mbStack) { 1678 mbStack.setAttribute('aria-label', (on ? 'With adequate protein and training' : 'With low protein and no training') + ', ' + fat + ' percent of the weight lost is fat and ' + lean + ' percent is muscle.'); 1679 } 1680 }); 1681 } 1682 1683 /* ---------- Metabolism-benefits: loss vs. maintenance toggle ---------- */ 1684 var mbPhaseToggle = document.querySelector('[data-nv-toggle-group="mb-phase"]'); 1685 if (mbPhaseToggle) { 1686 var mbPhasePanels = document.querySelectorAll('[data-nv-phase-panel]'); 1687 mbPhaseToggle.addEventListener('nv:toggle', function (e) { 1688 mbPhasePanels.forEach(function (panel) { 1689 panel.classList.toggle('is-dim', panel.getAttribute('data-nv-phase-panel') !== e.detail.value); 1690 }); 1691 }); 1692 } 1693})();
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.