PageSourceSearch

https://novoslabs.com/wp-content/themes/novos-blocks/assets/js/novos.js?ver=0.9.196

js novoslabs.com collected 2026-09-24 17:20:58 UTC 58,640 bytes, 1,693 lines download raw bytes

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.