PageSourceSearch

https://www.phreesia.com/wp-content/themes/studio-simpatico/assets/js/script.js?ver=1.0.48

js phreesia.com collected 2026-10-02 04:13:35 UTC 114,169 bytes, 2,639 lines download raw bytes

1/************Passive Listeners************/
2
3jQuery.event.special.touchstart = {
4	setup: function( _, ns, handle ) {
5		this.addEventListener("touchstart", handle, { passive: !ns.includes("noPreventDefault") });
6	}
7};
8jQuery.event.special.touchmove = {
9	setup: function( _, ns, handle ) {
10		this.addEventListener("touchmove", handle, { passive: !ns.includes("noPreventDefault") });
11	}
12};
13jQuery.event.special.wheel = {
14	setup: function( _, ns, handle ){
15		this.addEventListener("wheel", handle, { passive: true });
16	}
17};
18jQuery.event.special.mousewheel = {
19	setup: function( _, ns, handle ){
20		this.addEventListener("mousewheel", handle, { passive: true });
21	}
22};
23
24/************Marketo forms************/
25// Forms arrive as self-loading editor-pasted embeds, so everything binds at
26// whenReady to cover every instance. Two jobs: (1) Marketo scopes its theme CSS
27// to #mktoForm_<id>, out-specifying our class-only .mktoForm rules — re-inject
28// the contested styles per form id and fold Marketo's duplicate checkbox
29// labels; (2) fill the hidden attribution fields (UTMs, referrer, GA
30// client/session ids) per the Marketo team's tracking contract.
31//
32// Required markers are typed into the label copy in Marketo, so nothing here
33// may strip a "*" off a label — that asterisk is the only one a visitor sees.
34
35(function () {
36	function overridesFor(formId) {
37		var f = '#mktoForm_' + formId + '.mktoForm';
38		// Blue-band placement inverts the form to white text; prefixed so it's a no-op elsewhere.
39		var blue = '.module.content-form.bg-digital-blue ' + f;
40		// Marketo scopes its theme + float layout to #mktoForm_<id>, out-specifying our
41		// class-only rules. Re-assert the contested pieces at the same id: un-float the
42		// layout and contain rows with overflow so each keeps its height (a floated field
43		// collapses the row and swallows the gap) — overflow, not display, so hidden rows
44		// stay hidden; skin the labels (eyebrow, or body copy for consent/choice groups);
45		// hide Marketo's own floated asterisk (the label copy carries the marker, so its
46		// one would double it); and theme the button (Marketo re-skins it in-form).
47		return [
48			f + ' .mktoFormRow,' + f + ' .mktoFormCol,' + f + ' .mktoFieldDescriptor,' + f + ' .mktoFieldWrap,' + f + ' .mktoLogicalField{float:none!important;width:100%!important;box-sizing:border-box!important;}',
49			f + ' div,' + f + ' span,' + f + ' label,' + f + ' p{padding:0!important;}',
50			// The row only ever needed to contain Marketo's floats — uncontained, a floated
51			// field collapses the row and swallows the gap. overflow:hidden did that via the
52			// block formatting context it establishes, but the clipping that comes with it
53			// cropped the focus ring off any control on a row edge (the checkboxes, the
54			// patient toggle). contain:layout is the same independent formatting context
55			// with no clipping, so the rings paint: measured against this form, hidden and
56			// contain:layout both give a 906px row, clip and visible both collapse it to 738.
57			// Not display:flow-root (1002px) — that also un-hides the rows Marketo hides.
58			f + ' .mktoFormRow{contain:layout!important;overflow:visible!important;margin-bottom:var(--text-spacing-small)!important;}',
59			f + ' .mktoFormRow > .mktoFormRow{margin-bottom:0!important;}',
60			f + ' .mktoFormRow:last-child{margin-bottom:0!important;}',
61			f + ' .mktoOffset,' + f + ' .mktoGutter,' + f + ' .mktoClear{display:none!important;}',
62			f + ' .mktoFormRow .mktoFieldWrap .mktoLabel{color:var(--black-hover)!important;display:block!important;float:none!important;font-size:calc(10rem/16)!important;font-weight:700!important;letter-spacing:.8px!important;line-height:calc(13/10)!important;margin:0 0 var(--text-spacing-xxsmall)!important;padding:0!important;text-transform:uppercase!important;width:auto!important;}',
63			f + ' .mktoFormRow .mktoFieldWrap:has(.mktoCheckboxList) .mktoLabel,' + f + ' .mktoFormRow .mktoFieldWrap:has(.mktoRadioList) .mktoLabel{color:var(--black)!important;font-size:calc(14rem/16)!important;font-weight:400!important;letter-spacing:normal!important;line-height:calc(20/14)!important;margin:0 0 var(--text-spacing-xsmall)!important;text-transform:none!important;}',
64			f + ' .mktoFormRow .mktoFieldWrap .mktoAsterix{display:none!important;}',
65			f + ' .mktoFormRow .mktoFormCol{margin-bottom:0!important;min-height:0!important;}',
66			// Checkbox / radio lists — Marketo's id-scoped skin out-specifies the class
67			// rules in style.css, so re-assert the grid + native-input styling at id weight.
68			f + ' .mktoCheckboxList,' + f + ' .mktoRadioList{align-items:center!important;column-gap:12px!important;display:grid!important;grid-template-columns:14px 1fr!important;justify-items:start!important;margin:0!important;padding:0!important;row-gap:var(--text-spacing-xxsmall)!important;text-indent:0!important;}',
69			f + ' .mktoCheckboxList>input,' + f + ' .mktoRadioList>input{grid-column:1!important;justify-self:start!important;margin:0!important;padding:0!important;}',
70			f + ' .mktoCheckboxList>label,' + f + ' .mktoRadioList>label{grid-column:2!important;margin:0!important;}',
71			f + ' .mktoCheckboxList input[type="checkbox"],' + f + ' .mktoRadioList input[type="radio"]{-webkit-appearance:none!important;appearance:none!important;background:var(--white)!important;border:1px solid var(--gray-light)!important;box-shadow:none!important;clip:auto!important;cursor:pointer;height:14px!important;margin:0!important;opacity:1!important;position:static!important;width:14px!important;}',
72			f + ' .mktoRadioList input[type="radio"]{border-radius:50%!important;}',
73			f + ' .mktoCheckboxList input[type="checkbox"]:checked{background:var(--electric-lavender) url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 12 10\'><path d=\'M1 5l3.2 3.5L11 1\' fill=\'none\' stroke=\'white\' stroke-width=\'2\' stroke-linecap=\'round\' stroke-linejoin=\'round\'/></svg>") center / 10px 8px no-repeat!important;border-color:var(--electric-lavender)!important;}',
74			f + ' .mktoRadioList input[type="radio"]:checked{background:radial-gradient(var(--black) 0 45%,transparent 50%)!important;border-color:var(--black)!important;}',
75			// Same two-tone ring as the rest of the site (style.css :root). Injected here
76			// because these id-weight !important rules out-specify the stylesheet.
77			f + ' .mktoCheckboxList input:focus-visible,' + f + ' .mktoRadioList input:focus-visible{box-shadow:0 0 0 2px var(--focus-halo)!important;outline:2px solid var(--focus-ring)!important;outline-offset:2px!important;}',
78			f + ' .mktoCheckboxList label,' + f + ' .mktoRadioList label{color:var(--gray-dark)!important;font-size:calc(12rem/16)!important;font-weight:400!important;letter-spacing:.2px!important;line-height:calc(16/12)!important;margin:0!important;text-transform:none!important;}',
79			// "Are you a patient?" — render the lone checkbox as the demo-form pill toggle.
80			f + ' .mktoCheckboxList:has(> [name="areyouapatient"]){align-items:center!important;display:flex!important;gap:var(--text-spacing-small)!important;justify-content:space-between!important;}',
81			f + ' .mktoCheckboxList>label[for="areyouapatient"]{color:var(--black-hover)!important;font-weight:700!important;order:-1!important;}',
82			f + ' .mktoCheckboxList input[name="areyouapatient"]{background:var(--parchment)!important;border:0!important;border-radius:30px!important;flex-shrink:0!important;height:30px!important;opacity:1!important;position:relative!important;width:58px!important;}',
83			f + ' .mktoCheckboxList input[name="areyouapatient"]::before{background:var(--black)!important;border-radius:50%!important;content:\'\'!important;height:20px!important;left:5px!important;position:absolute!important;top:5px!important;transition:left .2s ease!important;width:20px!important;}',
84			f + ' .mktoCheckboxList input[name="areyouapatient"]::after{color:var(--gray-dark)!important;content:\'NO\'!important;font-size:calc(8rem/16)!important;font-weight:700!important;letter-spacing:.8px!important;position:absolute!important;right:9px!important;top:50%!important;transform:translateY(-50%)!important;}',
85			f + ' .mktoCheckboxList input[name="areyouapatient"]:checked{background:var(--electric-lavender)!important;}',
86			f + ' .mktoCheckboxList input[name="areyouapatient"]:checked::before{background:var(--white)!important;left:33px!important;}',
87			f + ' .mktoCheckboxList input[name="areyouapatient"]:checked::after{color:var(--white)!important;content:\'YES\'!important;left:9px!important;right:auto!important;}',
88			f + ' .mktoButtonWrap .mktoButton{background:var(--black)!important;background-image:none!important;border:0!important;border-radius:0!important;color:var(--white)!important;cursor:pointer;font-family:var(--body-font)!important;font-size:calc(15rem/16)!important;font-weight:600!important;line-height:calc(18/15)!important;padding:19px var(--text-spacing-small)!important;text-transform:none!important;}',
89			f + ' .mktoButtonWrap .mktoButton:hover,' + f + ' .mktoButtonWrap .mktoButton:focus-visible{background:var(--black-hover)!important;color:var(--white)!important;}',
90			// Focus needs a ring, not just the hover tint — the darkened fill alone reads as
91			// "slightly greyer" and is easy to miss when tabbing.
92			f + ' .mktoButtonWrap .mktoButton:focus-visible{box-shadow:0 0 0 2px var(--focus-halo)!important;outline:2px solid var(--focus-ring)!important;outline-offset:2px!important;}',
93			// Blue band — invert to white text + translucent fields, mirroring the module's
94			// own bg-digital-blue treatment. The .module.content-form.bg-digital-blue prefix
95			// out-specifies the id-scoped colours above and only fires inside that band.
96			blue + ' .mktoFormRow .mktoFieldWrap .mktoLabel{color:var(--white)!important;}',
97			blue + ' .mktoFormRow .mktoFieldWrap:has(.mktoCheckboxList) .mktoLabel,' + blue + ' .mktoFormRow .mktoFieldWrap:has(.mktoRadioList) .mktoLabel{color:var(--white)!important;}',
98			blue + ' .mktoCheckboxList label,' + blue + ' .mktoRadioList label,' + blue + ' .mktoCheckboxList>label[for="areyouapatient"]{color:var(--white)!important;}',
99			blue + ' .mktoInstruction{color:color-mix(in srgb, var(--white) 70%, transparent)!important;}',
100			blue + ' input.mktoField:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),' + blue + ' select.mktoField,' + blue + ' textarea.mktoField{background:color-mix(in srgb, var(--white) 8%, transparent)!important;border-color:color-mix(in srgb, var(--white) 55%, transparent)!important;color:var(--white)!important;}',
101			blue + ' ::placeholder{color:color-mix(in srgb, var(--white) 70%, transparent)!important;}',
102			blue + ' .mktoCheckboxList input[type="checkbox"],' + blue + ' .mktoRadioList input[type="radio"]{border-color:color-mix(in srgb, var(--white) 55%, transparent)!important;}',
103			// White button on the blue band (matches the module's own submit); overrides the
104			// base black button + its dark hover, which would otherwise flip white → black.
105			blue + ' .mktoButtonWrap .mktoButton{background:var(--white)!important;color:var(--black)!important;}',
106			blue + ' .mktoButtonWrap .mktoButton:hover,' + blue + ' .mktoButtonWrap .mktoButton:focus-visible{background:color-mix(in srgb, var(--white) 90%, var(--black))!important;color:var(--black)!important;}'
107		].join('');
108	}
109
110	// Patient gate — the demo forms are for organizations. A visitor who marks themselves
111	// a patient (the "Are you a patient?" toggle, or the "…I am a patient" org-type option)
112	// gets a warning dialog, and the signal is cleared on close so they can't submit as one.
113	var gateData    = window.phreesiaMktoForm || {};
114	var gateEl      = null;
115	var gateTrigger = null;
116
117	function buildPatientGate() {
118		var dialog = document.createElement('dialog');
119		dialog.className = 'patient-gate';
120
121		var heading = document.createElement('h2');
122		heading.className = 'heading-4';
123		heading.id = 'patient-gate-title';
124		heading.textContent = gateData.title || 'Are you a patient?';
125		dialog.setAttribute('aria-labelledby', heading.id);
126
127		var body = document.createElement('p');
128		body.textContent = gateData.body || '';
129
130		var buttons = document.createElement('div');
131		buttons.className = 'buttons';
132		if (gateData.faqUrl) {
133			var faq = document.createElement('a');
134			faq.className = 'btn primary';
135			faq.href = gateData.faqUrl;
136			faq.textContent = gateData.faqLabel || 'Patient FAQ';
137			buttons.appendChild(faq);
138		}
139		var back = document.createElement('button');
140		back.type = 'button';
141		back.className = 'btn secondary';
142		back.textContent = gateData.closeLabel || 'Go back';
143		back.addEventListener('click', function () { dialog.close(); });
144		buttons.appendChild(back);
145
146		dialog.append(heading, body, buttons);
147		dialog.addEventListener('click', function (e) { if (e.target === dialog) dialog.close(); }); // backdrop click
148		dialog.addEventListener('close', function () {
149			if (!gateTrigger) return;
150			if (gateTrigger.type === 'checkbox') gateTrigger.checked = false;
151			else gateTrigger.selectedIndex = 0;
152			// Sync Marketo's model; blur is the iOS fix for a programmatic value change.
153			gateTrigger.dispatchEvent(new Event('change', { bubbles: true }));
154			if (gateTrigger.blur) gateTrigger.blur();
155			gateTrigger = null;
156		});
157
158		document.body.appendChild(dialog);
159		return dialog;
160	}
161
162	function openPatientGate(trigger) {
163		if (!gateEl) gateEl = buildPatientGate();
164		gateTrigger = trigger;
165		if (gateEl.showModal) gateEl.showModal();
166	}
167
168	function styleMarketoForm(form) {
169		var formId  = form.getId();
170		var styleId = 'mkto-overrides-' + formId;
171		if (!document.getElementById(styleId)) {
172			var style = document.createElement('style');
173			style.id = styleId;
174			style.textContent = overridesFor(formId);
175			document.head.appendChild(style);
176		}
177
178		var formEl = form.getFormElem()[0];
179		if (formEl) {
180			// Marketo gives each checkbox two <label for>s (its .mktoLabel plus the
181			// wrapper's own) — screen readers announce the name twice. Fold the
182			// .mktoLabel's content into the real label and drop it.
183			formEl.querySelectorAll("input[type='checkbox']").forEach(function (box) {
184				if (!box.id) return;
185				var selector  = 'label[for="' + CSS.escape(box.id) + '"]';
186				var primary   = formEl.querySelector(selector + '.mktoLabel');
187				var secondary = formEl.querySelector(selector + ':not(.mktoLabel)');
188				if (primary && secondary) {
189					secondary.innerHTML = primary.innerHTML;
190					primary.remove();
191				}
192			});
193
194			// Marketo ships the submit label shouty ("SUBMIT"); de-shout an all-caps
195			// label to sentence case ("Submit") and leave already-cased labels alone.
196			var submit = formEl.querySelector('.mktoButtonWrap button.mktoButton');
197			if (submit) {
198				var buttonText = submit.textContent.trim();
199				if (buttonText && buttonText === buttonText.toUpperCase() && /[A-Z]/.test(buttonText)) {
200					submit.textContent = buttonText.charAt(0) + buttonText.slice(1).toLowerCase();
201				}
202			}
203
204			// Patient gate — open the warning when the org-type dropdown lands on the
205			// patient option or the "Are you a patient?" toggle is checked (once per form).
206			if (!formEl.dataset.patientGateWired) {
207				formEl.dataset.patientGateWired = '1';
208				var orgType = formEl.querySelector('[name="Organization_Type__c"]');
209				if (orgType) {
210					orgType.addEventListener('change', function () {
211						if (/patient/i.test(orgType.value)) openPatientGate(orgType);
212					});
213				}
214				var patientToggle = formEl.querySelector('[name="areyouapatient"]');
215				if (patientToggle) {
216					patientToggle.addEventListener('change', function () {
217						if (patientToggle.checked) openPatientGate(patientToggle);
218					});
219				}
220			}
221		}
222	}
223
224	// The tracking params the Marketo team's contract expects on every form. This one
225	// allowlist drives all four paths — URL read, session re-filter, demo-link
226	// decoration, and the Marketo fill — so a param is only persisted if it's here.
227	// gclid is Google's own click id rather than a UTM, but it travels the same route.
228	var utmParams = ['utm_source', 'utm_medium', 'utm_destinationmedium', 'utm_campaign', 'utm_campaignid', 'utm_term', 'utm_content', 'utm_audience', 'utm_audience1', 'utm_audience2', 'utm_audience3', 'utm_vendor', 'utm_date', 'utm_destinationcontent', 'utm_device', 'utm_matchtype', 'gclid'];
229
230	// Reading UTMs off the current URL alone was right while the form sat at the foot of
231	// every page — landing URL and form URL were one URL. The form now lives on its own
232	// page, so an email click that browses before converting arrives bare and the touch
233	// is lost. Session storage (not a cookie) keeps the original guarantee: it dies with
234	// the tab, so a *previous visit* still can't ride along.
235	var UTM_STORE_KEY = 'phreesia_utm_touch';
236
237	function urlUtms() {
238		var params = new URLSearchParams(window.location.search);
239		var found  = {};
240		utmParams.forEach(function (name) {
241			var value = params.get(name);
242			if (value !== null) found[name] = value;
243		});
244		return found;
245	}
246
247	// Re-filtered on the way out, not just on the way in: whatever sits under the key
248	// is untrusted input by the time we read it back, and these values go on to build
249	// hrefs. Same allowlist the URL read uses, so neither path can widen it.
250	function storedUtms() {
251		var parsed;
252		try {
253			parsed = JSON.parse(sessionStorage.getItem(UTM_STORE_KEY));
254		} catch (error) {
255			return {}; // private mode, storage blocked by consent, or malformed
256		}
257		if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
258
259		var clean = {};
260		utmParams.forEach(function (name) {
261			var value = parsed[name];
262			if (typeof value === 'string' && value !== '') clean[name] = value;
263		});
264		return clean;
265	}
266
267	// A URL carrying any UTM is a fresh touch and replaces the stored set wholesale —
268	// merging per-param would splice two campaigns into an attribution that never ran.
269	function captureUtms() {
270		var found = urlUtms();
271		if (!Object.keys(found).length) return;
272		try {
273			sessionStorage.setItem(UTM_STORE_KEY, JSON.stringify(found));
274		} catch (error) {}
275	}
276
277	function currentUtms() {
278		var found = urlUtms();
279		return Object.keys(found).length ? found : storedUtms();
280	}
281
282	// Also hang the touch on the demo CTA so it's visible in the URL on arrival for
283	// anything reading it server-side. Scoped to that one destination, not every
284	// internal link: a UTM on ordinary navigation restarts campaign attribution in
285	// GA, which would credit the campaign for each click of a browsing session.
286	function decorateDemoLinks() {
287		var touch = currentUtms();
288		if (!Object.keys(touch).length) return;
289
290		var cta = document.querySelector('.primary-nav a.btn.primary[href]');
291		if (!cta) return;
292
293		var ctaHref = cta.getAttribute('href');
294		if (!ctaHref || ctaHref.charAt(0) === '#') return; // on-page form anchor, nothing to carry
295
296		var demoPath;
297		try {
298			demoPath = new URL(ctaHref, location.origin).pathname;
299		} catch (error) {
300			return;
301		}
302
303		document.querySelectorAll('a[href]').forEach(function (link) {
304			var href = link.getAttribute('href');
305			if (!href || href.charAt(0) === '#') return;
306
307			var url;
308			try {
309				url = new URL(href, location.origin);
310			} catch (error) {
311				return;
312			}
313			if (url.origin !== location.origin || url.pathname !== demoPath) return;
314			// A link that already carries its own campaign owns it.
315			if (utmParams.some(function (name) { return url.searchParams.has(name); })) return;
316
317			Object.keys(touch).forEach(function (name) { url.searchParams.set(name, touch[name]); });
318			link.setAttribute('href', url.pathname + url.search + url.hash);
319		});
320	}
321
322	function getCookie(name) {
323		var match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
324		return match ? decodeURIComponent(match[1]) : '';
325	}
326
327	// GA ids for the hidden clientID/sessionID fields — client is parts 3+4 of
328	// _ga (GA1.1.<client>.<ts>), session is part 3 of the property cookie. That
329	// cookie name is tied to the GA4 measurement ID; a property change breaks it.
330	function getAnalyticsIds() {
331		var client  = getCookie('_ga').split('.');
332		var session = getCookie('_ga_JG24CFP60C').split('.');
333		return {
334			clientID:  client.length  >= 4 ? client[2] + '.' + client[3] : '',
335			sessionID: session.length >= 3 ? session[2] : ''
336		};
337	}
338
339	function fillTrackingFields(form) {
340		var touch  = currentUtms();
341		var log    = [];
342		var values = {
343			referringURL: document.referrer || '',
344			pageUrl: window.location.href
345		};
346
347		// Blank every UTM field, then set only the params in this touch — a value
348		// Marketo remembered from a previous visit must not ride along.
349		utmParams.forEach(function (name) {
350			var value = Object.prototype.hasOwnProperty.call(touch, name) ? touch[name] : null;
351			values[name] = value === null ? '' : value;
352			if (value !== null) log.push(name + '=' + value);
353		});
354		values.utm_log = log.join('&');
355
356		var ids = getAnalyticsIds();
357		if (ids.clientID)  values.clientID  = ids.clientID;
358		if (ids.sessionID) values.sessionID = ids.sessionID;
359		form.setValues(values);
360
361		// GA sets its cookies async — on a first visit they can land after
362		// whenReady, so retry once late (the legacy build waited 3s post-load).
363		if (!ids.clientID || !ids.sessionID) {
364			setTimeout(function () {
365				var late = getAnalyticsIds();
366				var lateValues = {};
367				if (late.clientID)  lateValues.clientID  = late.clientID;
368				if (late.sessionID) lateValues.sessionID = late.sessionID;
369				if (lateValues.clientID || lateValues.sessionID) form.setValues(lateValues);
370			}, 3000);
371		}
372	}
373
374	// Redirect to a thank-you page on submit. Precedence: the placement's own
375	// data-mkto-success-url (Content + Form module) → the sitewide success URL
376	// (Site Settings) → the form's own Marketo follow-up. No URL anywhere leaves
377	// Marketo's default behaviour untouched.
378	function redirectMarketoForm(form) {
379		form.onSuccess(function (values, followUpUrl) {
380			var el      = form.getFormElem()[0];
381			var perForm = el && el.closest('[data-mkto-success-url]');
382			var url     = (perForm && perForm.getAttribute('data-mkto-success-url'))
383				|| gateData.successUrl
384				|| followUpUrl;
385			if (url) {
386				location.href = url;
387				return false; // we own the redirect; suppress Marketo's default
388			}
389		});
390	}
391
392	// Contact form department routing — three departments are answered by inbox, not by
393	// this form (see phreesiaMktoForm.departments). Selecting one hides Submit and shows
394	// the address instead; the old site hid the button with no explanation, which left a
395	// keyboard or screen-reader user with a vanished control and no reason for it.
396	function wireDepartmentRouting(form) {
397		var formEl = form.getFormElem()[0];
398		if (!formEl || formEl.dataset.departmentRoutingWired) return;
399
400		var select = formEl.querySelector('[name="contact_us_form_department"]');
401		var row    = formEl.querySelector('.mktoButtonRow');
402		if (!select || !row) return;
403		formEl.dataset.departmentRoutingWired = '1';
404
405		var routed = (window.phreesiaMktoForm || {}).departments || [];
406
407		function render() {
408			// Inline display mirrors what Marketo's own scripts do to this row, and
409			// beats the theme's injected form overrides without another !important.
410			row.style.display = routed.indexOf(select.value) === -1 ? '' : 'none';
411		}
412
413		select.addEventListener('change', render);
414
415		// Deep link — /contact/?department=Patient. Lets an editor point a plain link
416		// at a department with the normal link button, no class or code view needed.
417		// Matched against the select's own options so a stale link can't set a value
418		// the form would reject. setValues so Marketo registers it, not just the DOM.
419		var wanted = new URLSearchParams(window.location.search).get('department');
420		var known  = wanted && Array.prototype.some.call(select.options, function (option) {
421			return option.value === wanted;
422		});
423		if (known) {
424			var values = {};
425			values[select.name] = wanted;
426			form.setValues(values);
427		}
428
429		render();
430	}
431
432	function bindMarketo() {
433		if (!window.MktoForms2 || typeof window.MktoForms2.whenReady !== 'function') return false;
434		window.MktoForms2.whenReady(styleMarketoForm);
435		window.MktoForms2.whenReady(fillTrackingFields);
436		window.MktoForms2.whenReady(redirectMarketoForm);
437		window.MktoForms2.whenReady(wireDepartmentRouting);
438		return true;
439	}
440
441	// Before anything reads the store, and on every page — the landing page that
442	// carries the campaign usually has no form on it at all.
443	captureUtms();
444
445	if (document.readyState === 'loading') {
446		document.addEventListener('DOMContentLoaded', decorateDemoLinks);
447	} else {
448		decorateDemoLinks();
449	}
450
451	if (!bindMarketo()) {
452		var tries = 0;
453		var poll = setInterval(function () {
454			if (bindMarketo() || ++tries > 40) clearInterval(poll);
455		}, 250);
456	}
457})();
458
459/************Initialize JazzyScroll************/
460
461let Jazzy;
462let isMotionAllowed = window.matchMedia('(prefers-reduced-motion: no-preference)');
463
464if (isMotionAllowed.matches) {
465	Jazzy = {
466		scroll: new JazzyScroll({
467			speed: 800,
468			delay: 0,
469			offset: 0
470		}),
471		parallax: new JazzyParallax({
472			'speed': -0.1,
473			'boundingBox': 'bottom',
474		})
475	};
476
477	// Safety net for the on-load reveal race. JazzyScroll builds its
478	// IntersectionObservers at defer-time — before images and web fonts settle the
479	// above-the-fold layout — and each observer fires ONE initial callback, then
480	// disconnects on reveal. An element that wasn't yet in its final position at
481	// that instant misses its only reveal, and since it never scrolls, nothing
482	// re-triggers it — a fade-in element then stays stuck at opacity:0.
483	//
484	// Once layout is actually settled (load, and again when fonts resolve), reveal
485	// anything now in view that never fired. Still :not(.animated-in) at this point
486	// means the observer genuinely missed it, so tagging it is the correct recovery
487	// (and idempotent — already-revealed elements are skipped).
488	var revealInViewStuck = function () {
489		var vh = window.innerHeight || document.documentElement.clientHeight;
490		document.querySelectorAll('[data-jazzy-scroll]:not(.animated-in)').forEach(function (el) {
491			var r = el.getBoundingClientRect();
492			if (r.top < vh && r.bottom > 0) el.classList.add('animated-in');
493		});
494	};
495
496	if (document.readyState === 'complete') revealInViewStuck();
497	else window.addEventListener('load', revealInViewStuck);
498
499	if (document.fonts && document.fonts.ready) document.fonts.ready.then(revealInViewStuck);
500}
501
502/************Fragment jumps — land the target clear of the fixed chrome************/
503// WebKit ignores scroll-padding on fragment navigation, so a plain #hash link parks
504// its target at viewport top, behind the header. Everything that drives its own jump
505// reads the same computed value, so the CSS stays the one source of truth and no two
506// landings disagree.
507function anchorScrollPadding() {
508	var pad = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop);
509	return isNaN(pad) ? 120 : pad;
510}
511
512// centre is for targets that fill the viewport (an SVP slot), where aligning the top
513// would land the reader above the content rather than on it.
514function scrollToAnchor(target, href, centre) {
515	if (centre) {
516		target.scrollIntoView({ behavior: isMotionAllowed.matches ? 'smooth' : 'auto', block: 'center' });
517	} else {
518		window.scrollTo({
519			top: target.getBoundingClientRect().top + window.scrollY - anchorScrollPadding(),
520			behavior: isMotionAllowed.matches ? 'smooth' : 'auto'
521		});
522	}
523	if (href && history.replaceState) history.replaceState(null, '', href);
524	// replaceState doesn't move focus — do it explicitly so keyboard/SR users follow
525	// the jump rather than tabbing on from the link they just used.
526	if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
527	target.focus({ preventScroll: true });
528}
529
530// Citation markers and the back-links that return from them. Scoped to these rather
531// than every #hash link on the page: a blanket handler would suppress the hashchange
532// that deep-linked accordions open on, and race the dialog triggers for their own.
533document.addEventListener('click', function(e) {
534	if (e.defaultPrevented || e.button !== 0) return;
535	if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
536
537	var link = e.target.closest('.ref-marker-link, .ref-backref');
538	if (!link) return;
539
540	var href = link.getAttribute('href') || '';
541	if (href.charAt(0) !== '#' || href.length < 2) return;
542
543	var target = document.getElementById(decodeURIComponent(href.slice(1)));
544	if (!target) return;
545
546	e.preventDefault();
547	scrollToAnchor(target, href, false);
548});
549
550/************Document Ready Functions************/
551
552jQuery(function($) {
553
554	/************CAROUSELS************/
555	// All carousels now use JazzyCarousel via [data-jazzy-carousel] markup.
556
557	// Content Media - custom crossfade gallery
558	document.querySelectorAll('.content-media .carousel').forEach(function(carousel) {
559		var slides = carousel.querySelectorAll('.slide');
560		if (slides.length < 2) return;
561
562		var current = 0;
563		carousel.classList.add('crossfade-gallery');
564		slides[0].classList.add('active');
565
566		if (!isMotionAllowed.matches) return; // reduced motion: hold on the first slide
567
568		var timer = null;
569		function start() {
570			if (timer) return;
571			timer = setInterval(function() {
572				slides[current].classList.remove('active');
573				current = (current + 1) % slides.length;
574				slides[current].classList.add('active');
575			}, 4500);
576		}
577		function stop() { clearInterval(timer); timer = null; }
578
579		// Pause on hover / keyboard focus so the rotation is stoppable (WCAG 2.2.2).
580		carousel.addEventListener('mouseenter', stop);
581		carousel.addEventListener('mouseleave', start);
582		carousel.addEventListener('focusin', stop);
583		carousel.addEventListener('focusout', start);
584
585		start();
586	});
587
588	/************Hero Background Video Crossfade************/
589	// Duration-driven: each clip plays through, and ~LEAD before it ends we start
590	// the (pre-buffered) next clip and tell JazzyCarousel to crossfade — so the fade
591	// reveals a clip that's already playing, never a black frame. JazzyCarousel
592	// (data-jazzy-carousel="fade", no autoplay) only renders the crossfade + state;
593	// we drive the timing. Clips lazy-load (data-src) one ahead. .media is aria-hidden.
594
595	document.querySelectorAll('.module.hero .media[data-jazzy-carousel]').forEach(function(media) {
596		var videos = media.querySelectorAll('video');
597		if (videos.length < 2) return;
598
599		var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
600		var LEAD = 0.8; // seconds before a clip ends to start the next + crossfade
601
602		// Optional per-clip play window (data-loop-start / data-loop-end, seconds).
603		function clipStart(video) {
604			var start = parseFloat(video.dataset.loopStart);
605			return !isNaN(start) && start > 0 ? start : 0;
606		}
607		function clipEnd(video) {
608			var end = parseFloat(video.dataset.loopEnd);
609			return !isNaN(end) && end > clipStart(video) ? end : 0; // 0 = natural end
610		}
611
612		// Swap data-src → src and begin buffering (once per clip).
613		function prime(video) {
614			var source = video.querySelector('source[data-src]');
615			if (!source) return;
616			source.src = source.dataset.src;
617			source.removeAttribute('data-src');
618			video.preload = 'auto';
619			video.load();
620		}
621
622		// The clips are decorative (.media is aria-hidden). Re-assert muted on the
623		// PROPERTY before every play: these get load()-ed when their data-src is
624		// swapped in, and Safari refuses playback unless muted is true at that moment.
625		function play(video) {
626			video.muted = true;
627			video.playsInline = true;
628			var p = video.play();
629			if (p) p.catch(function() {});
630		}
631
632		var activeVideo = null;
633		var advanceHandler = null;
634
635		function setup(active, index) {
636			if (activeVideo && advanceHandler) {
637				activeVideo.removeEventListener('timeupdate', advanceHandler);
638			}
639			activeVideo = active;
640
641			prime(active);
642			// Seek into the clip's window on its first show (a re-shown clip was
643			// already parked at its start by the advance below).
644			var from = clipStart(active);
645			if (from && active.currentTime < from) {
646				if (active.readyState >= 1) { try { active.currentTime = from; } catch (e) {} }
647				else active.addEventListener('loadedmetadata', function() { try { active.currentTime = from; } catch (e) {} }, { once: true });
648			}
649			play(active);
650			videos.forEach(function(v) { if (v !== active) v.pause(); });
651
652			if (reduceMotion) return; // hold on the first clip, no crossfade
653
654			var next = videos[(index + 1) % videos.length];
655			prime(next); // buffer it now so the cut is instant
656
657			var advanced = false;
658			advanceHandler = function() {
659				if (advanced || !active.duration || !media._jazzyCarousel) return;
660				var stop = clipEnd(active) || active.duration;
661				if (active.currentTime >= stop - LEAD) {
662					advanced = true;
663					next.currentTime = clipStart(next); // restart from its window start — a re-shown clip is paused at its tail, which would re-trigger the advance instantly
664					play(next); // pre-buffered → starts instantly, plays under the fade
665					media._jazzyCarousel.next();
666				}
667			};
668			active.addEventListener('timeupdate', advanceHandler);
669		}
670
671		setup(videos[0], 0);
672		media.addEventListener('jazzycarousel:change', function(e) {
673			if (e.detail && e.detail.slide) setup(e.detail.slide, e.detail.index);
674		});
675	});
676
677	/************Scrolling Value Props************/
678	// Triggers drive which step is active and crossfade the lede word / copy /
679	// proof card / media frame, play the active video, and sync the header
680	// nav-context rotator word. Homepage version uses invisible .svp-step
681	// overlaps; the subpages version has the naturally-scrolling .svp-proof-point
682	// rows drive the sticky media stage (only the media frames crossfade there).
683
684	// Below the 950px stacking breakpoint the module is plain stacked content
685	// (scrolling-value-props.css — must stay in sync with that MQ, or the observer runs
686	// against a display:none stage). Query it live — never cache .matches into a boolean at
687	// load: the scroll-driven hooks below then read a breakpoint that's already stale after
688	// a resize, and a page opened ≤950 and widened would stay dead.
689	var svpStackedQuery = window.matchMedia('(max-width: 950px)');
690
691	// Desktop (.svp-step) and stacked (.svp-stacked-prop) are separate renders of the
692	// same props, and a display:none target can't be scrolled to — so the anch
692or id
693	// follows whichever render is live. Desktop keeps the id in markup so jump links
694	// still work with JS off; only the swap needs JS. Remove before add — two nodes
695	// must never hold the same id, or #anchor resolves to the hidden one.
696	(function () {
697		var modules = document.querySelectorAll('.module.scrolling-value-props');
698		if (!modules.length) return;
699
700		function syncAnchors() {
701			var toStacked = svpStackedQuery.matches;
702			modules.forEach(function (svp) {
703				var steps   = svp.querySelectorAll('.svp-step[data-anchor]');
704				var stacked = svp.querySelectorAll('.svp-stacked-prop[data-anchor]');
705				var stale   = toStacked ? steps : stacked;
706				var live    = toStacked ? stacked : steps;
707				stale.forEach(function (el) { el.removeAttribute('id'); });
708				live.forEach(function (el) { el.id = el.dataset.anchor; });
709			});
710		}
711
712		syncAnchors();
713		svpStackedQuery.addEventListener('change', syncAnchors);
714	})();
715
716	document.querySelectorAll('.module.scrolling-value-props').forEach(function(svp) {
717		// Reduced motion: hold the inline autoplay videos (mobile point + stacked media)
718		// on their poster. Before the step-count guard so a 1-prop module is covered too;
719		// the desktop stage videos are gated in activate().
720		if (!isMotionAllowed.matches) {
721			svp.querySelectorAll('.svp-point-media video, .svp-stacked-media video').forEach(function(v) {
722				v.removeAttribute('autoplay');
723				v.autoplay = false;
724				v.pause();
725			});
726		}
727
728		var steps = svp.querySelectorAll('.svp-step, .svp-proof-point');
729		if (steps.length < 2 || !('IntersectionObserver' in window)) return;
730		// No breakpoint guard here on purpose: below 950 the CSS display:none's .svp-steps,
731		// and a box-less element can never intersect, so the observer self-idles. Gating on a
732		// matchMedia boolean captured at load instead meant a page opened ≤950 and widened
733		// never wired the rotator at all.
734
735		var navWords = document.querySelectorAll('.nav-context .nav-context-word');
736		var prevIdx = 0;
737
738		// Restart a metric-icon's animation each time its card is (re)shown — ring
739		// draw, chart bars, and the rise-in arrow all gate on .animated-in, which
740		// JazzyScroll sets only once on first view, so crossfaded-in cards (step
741		// switch / proof-card carousel) keep showing an already-finished icon.
742		function replayMetricIcons(scope) {
743			if (!scope) return;
744			scope.querySelectorAll('.metric-icon').forEach(function(icon) {
745				icon.classList.remove('animated-in');
746				void icon.offsetWidth; // force reflow so the animation can restart
747				icon.classList.add('animated-in');
748			});
749		}
750
751		// JazzyScroll fires a stat frame's entrance once, on first sight — but the frames
752		// crossfade in place and never leave the viewport, and the stat is usually behind
753		// another frame when the stage first enters, so its orbit + rise play unseen. Replay
754		// the FULL entrance (remove → reflow → re-add) each time the frame is re-activated so
755		// the whole animation runs while it's actually in view. Mobile (.svp-stacked) never
756		// reaches here — it animates once on its own scroll-in via JazzyScroll. No-op under
757		// reduced motion, where Jazzy is never constructed and the figure sits at final value.
758		function replayBigStat(scope) {
759			if (!scope || !Jazzy || !Jazzy.scroll) return;
760			scope.querySelectorAll('.big-stat-graphic').forEach(function(graphic) {
761				graphic.classList.remove('animated-in');
762				void graphic.offsetWidth; // reflow so the orbit entrance + rise restart from the top
763				graphic.classList.add('animated-in');
764			});
765			scope.querySelectorAll('.count-up-value').forEach(function(value) {
766				// Re-seed from the reserve: animateIn() reads the target off textContent,
767				// so a mid-count value would otherwise become the new target.
768				var reserve = value.parentNode.querySelector('.count-up-reserve');
769				if (reserve) value.textContent = reserve.textContent;
770				value.classList.remove('animated-in');
771				Jazzy.scroll.animateIn(value);
772			});
773		}
774
775		// One frame can serve several steps when they reuse the same asset (the PHP
776		// dedupes them), so data-frame is a space-separated list of step indexes.
777		function frameServes(frame, idx) {
778			return !!frame && (' ' + (frame.dataset.frame || '') + ' ').indexOf(' ' + idx + ' ') !== -1;
779		}
780
781		function activate(idx) {
782			// Flip the word animation to match scroll direction — rise on the way
783			// down (index increasing), descend on the way back up.
784			var n = parseInt(idx, 10);
785			svp.style.setProperty('--svp-dir', n < prevIdx ? '-1' : '1');
786			prevIdx = n;
787
788			// Heading words rotate directionally — the outgoing word rises out
789			// (.is-leaving), the incoming rises in (.is-active).
790			svp.querySelectorAll('.svp-word').forEach(function(el) {
791				var active = el.dataset.word === idx;
792				if (active) {
793					el.classList.remove('is-leaving');
794					el.classList.add('is-active');
795				} else {
796					if (el.classList.contains('is-active')) el.classList.add('is-leaving');
797					el.classList.remove('is-active');
798				}
799				el.inert = !active; // keep only the visible word in the a11y tree + tab order
800			});
801
802			// Copy, proof card, media frame — straight opacity crossfade.
803			svp.querySelectorAll('[data-copy], [data-proof], [data-frame]').forEach(function(el) {
804				var active = el.dataset.frame !== undefined
805					? frameServes(el, idx)
806					: (el.dataset.copy || el.dataset.proof) === idx;
807				// Only the entering frame replays — a frame shared by adjacent steps stays
808				// active across them, and the server-rendered one is JazzyScroll's to fire.
809				var entering = active && !el.classList.contains('is-active');
810				el.classList.toggle('is-active', active);
811				// Copies + proof carousels stay reachable so Tab hits every step's CTA and
812				// carousel (the focusin handler scrolls the focused step in). Only the
813				// decorative media frames (in the aria-hidden stage) inert while inactive.
814				if (el.dataset.frame !== undefined) el.inert = !active;
815				// Re-draw the proof's ring each time the step makes it active.
816				if (active && el.dataset.proof !== undefined) replayMetricIcons(el);
817				if (entering && el.dataset.frame !== undefined) replayBigStat(el);
818			});
819			svp.querySelectorAll('.svp-frame video').forEach(function(v) {
820				// Reduced motion: hold the decorative loop on its poster, never play.
821				if (isMotionAllowed.matches && frameServes(v.closest('.svp-frame'), idx)) {
822					// Decorative stage (.svp-media is aria-hidden) and carries no autoplay —
823					// playback is entirely on us, so muted has to be true on the PROPERTY or
824					// Safari rejects it. Already-playing: no-op, so a shared frame never restarts.
825					v.muted = true;
826					v.playsInline = true;
827					var p = v.play();
828					if (p) p.catch(function() {});
829				} else {
830					v.pause();
831				}
832			});
833			navWords.forEach(function(w) { w.classList.toggle('is-active', w.dataset.word === idx); });
834		}
835
836		// Collapse the root to the viewport centerline. Each .svp-step is 100vh and
837		// they tile, so exactly one covers the line at a time — activate whoever
838		// crosses it. Deterministic both directions; IO reports the final state on
839		// fling-scroll too, so the correct frame always lands. (Ratio-based picking
840		// failed: a 20vh band over 100vh steps caps the ratio at 0.2, so the higher
841		// thresholds never fired and steps got skipped.)
842		var observer = new IntersectionObserver(function(entries) {
843			entries.forEach(function(e) {
844				if (e.isIntersecting) activate(e.target.dataset.step);
845			});
846		}, { rootMargin: '-50% 0px -50% 0px', threshold: 0 });
847
848		steps.forEach(function(s) { observer.observe(s); });
849
850		// Re-draw the ring each time a proof-cards carousel crossfades to a new card.
851		svp.querySelectorAll('.svp-proof-cards[data-jazzy-carousel]').forEach(function(carousel) {
852			carousel.addEventListener('jazzycarousel:change', function(e) {
853				if (e.detail && e.detail.slide) replayMetricIcons(e.detail.slide);
854			});
855		});
856
857		// Initial a11y state: inert every inactive crossfade item — EXCEPT the copies,
858		// which stay tabbable/readable so every step's CTA and text is reachable (the
859		// focusin handler below scrolls the focused step into view).
860		svp.querySelectorAll('.svp-word, [data-frame]').forEach(function(el) {
861			el.inert = !el.classList.contains('is-active');
862		});
863		// Copies + proof carousels ship server-inert (no-JS safety); JS owns their
864		// reachability via the focusin handler below, so clear it — every step's CTA
865		// and carousel stays tabbable.
866		svp.querySelectorAll('[data-copy], [data-proof]').forEach(function(el) { el.inert = false; });
867
868		// Tabbing a not-yet-visible step's CTA or proof carousel scrolls that step to
869		// centre so the observer crossfades it in — otherwise focus would sit on an
870		// invisible (opacity:0) item stacked behind the active one.
871		svp.addEventListener('focusin', function(e) {
872			var host = e.target.closest('[data-copy], [data-proof]');
873			if (!host || host.classList.contains('is-active')) return;
874			var idx = host.dataset.copy !== undefined ? host.dataset.copy : host.dataset.proof;
875			var step = svp.querySelector('.svp-step[data-step="' + idx + '"]');
876			if (step) step.scrollIntoView({ behavior: isMotionAllowed.matches ? 'smooth' : 'auto', block: 'center' });
877		});
878
879		// Per-step tab order. The DOM groups all copies (one rotator) then all proofs
880		// (another), so native Tab would visit every copy before any proof — and when a
881		// trailing step has no proof, Tab off the last copy lands back in the proof
882		// rotator and loops, never leaving (keyboard trap, 2.1.2). Walk a flat logical
883		// order instead — each step's copy, then its own proof — and at the two ends
884		// hand focus to the nearest control OUTSIDE the module so it always exits.
885		function svpFocusables(host) {
886			return Array.prototype.filter.call(
887				host.querySelectorAll('a[href], button, input, select, textarea, [tabindex]'),
888				function (n) { return !n.disabled && n.tabIndex >= 0 && !n.closest('[inert]'); }
889			);
890		}
891		function svpCopy(i)  { return svp.querySelector('.svp-copy[data-copy="' + i + '"]'); }
892		function svpProof(i) { return svp.querySelector('.svp-proof[data-proof="' + i + '"]'); }
893
894		function svpSequence() {
895			var order = [];
896			for (var i = 0; i < steps.length; i++) {
897				var copy  = svpCopy(i);  if (copy)  order.push.apply(order, svpFocusables(copy));
898				var proof = svpProof(i); if (proof) order.push.apply(order, svpFocusables(proof));
899			}
900			return order;
901		}
902		// Nearest tabbable outside the module in the given direction — the escape hatch
903		// that skips the DOM-grouped siblings so focus can leave both ends.
904		function svpExit(forward) {
905			var out = null;
906			Array.prototype.forEach.call(
907				document.querySelectorAll('a[href], button, input, select, textarea, [tabindex]'),
908				function (n) {
909					// getClientRects() skips display:none (e.g. another module's hidden
910					// dual-render), matching what native Tab would pass over.
911					if (n.disabled || n.tabIndex < 0 || svp.contains(n) || n.closest('[inert]') || !n.getClientRects().length) return;
912					var rel = svp.compareDocumentPosition(n);
913					if (forward) { if (!out && (rel & Node.DOCUMENT_POSITION_FOLLOWING)) out = n; }
914					else if (rel & Node.DOCUMENT_POSITION_PRECEDING) out = n; // keep the last one before
915				}
916			);
917			return out;
918		}
919
920		svp.addEventListener('keydown', function(e) {
921			if (e.key !== 'Tab') return;
922			if (!e.target.closest('[data-copy], [data-proof]')) return;
923			var seq = svpSequence();
924			var pos = seq.indexOf(e.target);
925			if (pos === -1) return;
926			var nextPos = pos + (e.shiftKey ? -1 : 1);
927			var next = (nextPos >= 0 && nextPos < seq.length) ? seq[nextPos] : svpExit(!e.shiftKey);
928			if (next) { e.preventDefault(); next.focus(); }
929		});
930
931		// The routing above only runs once focus is already inside, so entering
932		// backwards was still native: Shift+Tab off the control after the module lands
933		// on the DOM-last tabbable, which is the last PROOF — an early step's carousel,
934		// not the last step's CTA — and focusin then scrolls that early step into view.
935		// Hand it the tail of the logical sequence instead. Forward entry needs no
936		// equivalent: the DOM's first tabbable is already the head of the sequence.
937		document.addEventListener('keydown', function(e) {
938			if (e.key !== 'Tab' || !e.shiftKey) return;
939			if (svp.contains(e.target) || e.target !== svpExit(true)) return;
940			var seq  = svpSequence();
941			var last = seq[seq.length - 1];
942			if (!last) return;
943			e.preventDefault();
944			last.focus();
945		});
946
947		// Subpage (proof-points-scroll): position the sticky media by its measured
948		// height so its box centres exactly — top edge at half the leftover space
949		// (the viewport when pinned, the first slot for the lead-in). This is more
950		// reliable than the CSS transform's "shift up half my own height" guess, so
951		// clear that transform once we've measured. Recompute on resize.
952		if (svp.classList.contains('style-proof-points-scroll')) {
953			var media  = svp.querySelector('.svp-media');
954			var points = svp.querySelectorAll('.svp-proof-point');
955
956			if (media && points.length) {
957				var firstPoint = points[0];
958				var lastPoint  = points[points.length - 1];
959				var stickyMenu = document.querySelector('.sticky-menu');
960
961				// Where a point's copy ACTUALLY renders — its centre as an offset from the
962				// point's top edge (or bottom, mirrored). Measured rather than derived: the
963				// box is taller than the copy (min-height + --module-spacing-xlarge padding),
964				// and justify-content centres the items' MARGIN boxes, so the visible copy's
965				// centre is not the box centre whenever the children's margins are uneven.
966				// Measuring folds the padding, the gaps and those margins in for free.
967				var copyCentre = function(point, fromBottom) {
968					var box = point.getBoundingClientRect();
969					var top = Infinity;
970					var bottom = -Infinity;
971
972					Array.prototype.forEach.call(point.children, function(kid) {
973						// Skip anything not in the layout. The point's LAST child is the
974						// mobile-only .svp-point-media, display:none in this style — and a
975						// display:none box reports a getBoundingClientRect() of all zeros,
976						// so reading the extent off first/last child puts the "bottom" at
977						// viewport 0 and throws the centre out completely.
978						if (!kid.getClientRects().length) return;
979						var r = kid.getBoundingClientRect();
980						if (r.top < top) top = r.top;
981						if (r.bottom > bottom) bottom = r.bottom;
982					});
983
984					if (top === Infinity) return box.height / 2;
985
986					var centre = (top + bottom) / 2;
987					return fromBottom ? box.bottom - centre : centre - box.top;
988				};
989
990				// Grow the point's padding until its copy centre sits `target` from the edge.
991				// Solved by measurement, not formula: padding shifts the copy 1:1 once the
992				// point outgrows the slot, but only 1:2 while min-height still wins (the
993				// leftover box re-centres and swallows half). Converges in one pass in the
994				// common (content-driven) case; the guard covers the other regime.
995				var solveLead = function(point, prop, target, fromBottom) {
996					var lead = 0;
997					var prevError = Infinity;
998					for (var i = 0; i < 6; i++) {
999						var error = target - copyCentre(point, fromBottom);
1000						if (error <= 0.5) break;
1001						// Bail when the copy stops closing on the target — an unreachable target
1002						// otherwise re-adds the same error every pass and the lead runs away.
1003						if (error >= prevError) break;
1004						prevError = error;
1005						lead += error;
1006						svp.style.setProperty(prop, lead + 'px');
1007					}
1008					return lead;
1009				};
1010
1011				var centreMedia = function() {
1012					var h = media.offsetHeight;
1013					if (!h) return;
1014					var vh = window.innerHeight;
1015
1016					// The sticky media is centred IN the viewport, so it can never sensibly
1017					// be taller than one. Clamping guards solveLead() below: fed an oversized
1018					// h its target (h/2) becomes unreachable and it settles on a lead of
1019					// ~2×error — thousands of px, which lands as padding on the end points.
1020					h = Math.min(h, vh);
1021
1022					// Measure the natural geometry — clear our own leads first, or a resize
1023					// compounds the previous pass's correction.
1024					svp.style.setProperty('--svp-point-lead-start', '0px');
1025					svp.style.setProperty('--svp-point-lead-end', '0px');
1026
1027					var headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0;
1028					var chrome  = headerH + (stickyMenu ? stickyMenu.offsetHeight : 0);
1029
1030					// Align the media's centre with the first point's copy centre. Never with
1031					// --svp-slot: that's only the point's min-height, and padding-block plus
1032					// real copy push a point well past it, so centring on the slot strands the
1033					// media off the copy by more the taller the point gets.
1034					var firstCentre = copyCentre(firstPoint, false);
1035
1036					// Pre-pin margin. A media taller than the copy's centre-line wants a
1037					// negative margin, which would bleed it up into the module above — floor
1038					// it at 0 and let the end points absorb the offset instead.
1039					media.style.transform = 'none';
1040					// Viewport-centred when pinned, clamped below the pinned chrome (fixed
1041					// header + this section's sticky nav) so it can't land behind them.
1042					media.style.setProperty('--svp-media-top', Math.max(chrome, (vh - h) / 2) + 'px');
1043					media.style.setProperty('--svp-media-lead', Math.max(0, firstCentre - h / 2) + 'px');
1044
1045					// The other half of the same alignment: when the media is the taller one,
1046					// the opening and closing points take the offset. Solved per point — the
1047					// two ends hold different copy, so one shared value can't centre both.
1048					solveLead(firstPoint, '--svp-point-lead-start', h / 2, false);
1049					solveLead(lastPoint,  '--svp-point-lead-end',   h / 2, true);
1050				};
1051
1052				centreMedia();
1053				window.addEventListener('resize', centreMedia, { passive: true });
1054				// Media height is image-driven; a cached-miss decode lands after first paint.
1055				svp.querySelectorAll('.svp-media img').forEach(function(img) {
1056					if (!img.complete) img.addEventListener('load', centreMedia, { once: true });
1057				});
1058				// copyCentre() measures rendered text, so a webfont swapping in after first
1059				// paint reflows the copy and strands the media off-centre until a resize.
1060				if (document.fonts && document.fonts.ready) document.fonts.ready.then(centreMedia);
1061			}
1062		}
1063	});
1064
1065	/************Outcome Marquee************/
1066	// Multi-outcome marquees pin a 100vh stage and crossfade the active outcome
1067	// (media + copy) as invisible .step targets cross the viewport centreline —
1068	// same IntersectionObserver trick as the SVP stage. Below 950px the CSS un-pins
1069	// and stacks the outcomes (and display:none's .steps), so the observer self-idles.
1070
1071	document.querySelectorAll('.module.outcome-marquee.has-multi').forEach(function(marquee) {
1072		var steps   = marquee.querySelectorAll('.step');
1073		var outcomes = marquee.querySelectorAll('.outcome');
1074		if (steps.length < 2 || outcomes.length < 2 || !('IntersectionObserver' in window)) return;
1075		// As with the SVP stage: below 950 the CSS display:none's .steps, so the observer
1076		// self-idles. A load-time matchMedia boolean would strand it after a widen.
1077
1078		function activate(idx) {
1079			outcomes.forEach(function(outcome) {
1080				var active = outcome.dataset.outcome === idx;
1081				outcome.classList.toggle('is-active', active);
1082				outcome.inert = !active; // only the visible outcome stays in the a11y tree + tab order
1083				outcome.querySelectorAll('video').forEach(function(video) {
1084					if (active) { var p = video.play(); if (p) p.catch(function() {}); }
1085					else video.pause();
1086				});
1087			});
1088		}
1089
1090		// -50%/-50% collapses the root to the centerline; each 100vh step tiles it,
1091		// so exactly one covers the line and its outcome activates (both directions).
1092		var observer = new IntersectionObserver(function(entries) {
1093			entries.forEach(function(e) {
1094				if (e.isIntersecting) activate(e.target.dataset.step);
1095			});
1096		}, { rootMargin: '-50% 0px -50% 0px', threshold: 0 });
1097		steps.forEach(function(s) { observer.observe(s); });
1098
1099		// Initial a11y state before the observer first fires: only the active outcome.
1100		outcomes.forEach(function(outcome) { outcome.inert = !outcome.classList.contains('is-active'); });
1101	});
1102
1103	/************Sticky Menu — drive the anchor jump ourselves************/
1104	// Two reasons the browser's own jump won't do. An SVP slot is 100vh with its
1105	// content centred, so a top-align lands the reader 50vh above what they clicked.
1106	// And WebKit ignores scroll-padding on fragment navigation outright, parking an
1107	// ordinary section at viewport top, behind the header. scrollToAnchor() covers
1108	// both — see its definition above.
1109	document.querySelectorAll('.module.sticky-menu .sticky-menu-link').forEach(function(link) {
1110		var href = link.getAttribute('href') || '';
1111		if (href.charAt(0) !== '#' || href.length < 2) return;
1112		link.addEventListener('click', function(e) {
1113			var target = document.getElementById(href.slice(1));
1114			if (!target) return;
1115
1116			var isSvp = !!target.closest('.module.scrolling-value-props');
1117			// Checked at click time, not bind time — the stacked render wants the browser's
1118			// plain anchor jump, and a load-time boolean left this unbound after a widen.
1119			if (isSvp && svpStackedQuery.matches) return;
1120
1121			e.preventDefault();
1122			scrollToAnchor(target, href, isSvp);
1123		});
1124	});
1125
1126	/************Sticky Menu — keep the active link in horizontal view************/
1127	// The list scrolls on overflow-x once the links outrun the container (mobile, or
1128	// simply a lot of links). JazzyHighlight flags the in-view section's link .active,
1129	// so on each change nudge the LIST — never scrollIntoView, which would drag the
1130	// page vertically under a sticky nav.
1131	document.querySelectorAll('.module.sticky-menu').forEach(function(menu) {
1132		var list = menu.querySelector('.sticky-menu-list');
1133		if (!list) return;
1134
1135		function revealActive(link) {
1136			if (!link) return;
1137			var max = list.scrollWidth - list.clientWidth;
1138			if (max <= 0) return; // not overflowing — nothing to reveal
1139
1140			var linkRect = link.getBoundingClientRect();
1141			var listRect = list.getBoundingClientRect();
1142			if (linkRect.left >= listRect.left && linkRect.right <= listRect.right) return; // already fully in view
1143
1144			// Absolute target, never a relative scrollBy: rapid highlight changes on a
1145			// fast scroll must converge on one position, not compound into an overshoot.
1146			// linkStart is scroll-invariant, so mid-animation calls resolve identically.
1147			// Centre the link, then clamp to [0, max] so the first item is never scrolled
1148			// past the start (nor the last past the end).
1149			var linkStart = list.scrollLeft + (linkRect.left - listRect.left);
1150			var target    = linkStart - (list.clientWidth - linkRect.width) / 2;
1151			target        = Math.max(0, Math.min(target, max));
1152			list.scrollTo({ left: target, behavior: isMotionAllowed.matches ? 'smooth' : 'auto' });
1153		}
1154
1155		menu.addEventListener('jazzyhighlight:change', function(e) {
1156			revealActive(e.detail && e.detail.menuItem);
1157		});
1158
1159		revealActive(list.querySelector('.sticky-menu-link.active')); // active on first paint
1160	});
1161
1162	/************Header — sticky skin + nav-context************/
1163	// The header is pinned (see #header in style.css). On scroll it gains a solid
1164	// skin (.is-stuck) once it clears the hero. The nav links swap for the
1165	// page-context label (.is-context) on scroll DOWN; links return on scroll up.
1166	// When the label is SVP-driven (a .nav-context-rotator the Scrolling Value
1167	// Props module populates), it only shows while that module is under the
1168	// header and stops once it scrolls past. A pinned predicate / full text (no
1169	// rotator) always shows on scroll down, with or without an SVP module.
1170
1171	(function() {
1172		var nav = document.querySelector('.primary-nav');
1173		if (!nav) return;
1174
1175		var svp = document.querySelector('.module.scrolling-value-props');
1176		var ctx = nav.querySelector('.nav-context');
1177		var rotator = ctx && ctx.querySelector('.nav-context-rotator');
1178		var isSvpDriven = !!rotator;
1179		// Inherit mode with no driver: rotator slot rendered but nothing populated
1180		// it (no SVP on page, or SVP present with enable_nav_context off). Treat
1181		// like the label doesn't exist — the menu links stay visible at all scroll
1182		// positions. The SVP populates the rotator via inline <script> during HTML
1183		// parse, so by the time this runs the children are already in place.
1184		var isInheritEmpty = isSvpDriven && rotator.children.length === 0;
1185		// --header-height is responsive (80px, 72px at ≤650), so re-read it on resize —
1186		// a value frozen at load skews the in-section threshold below.
1187		var readHeaderHeight = function() {
1188			return parseInt(getComputedStyle(document.documentElement).getPropertyValue('--header-height'), 10) || 80;
1189		};
1190		var headerHeight = readHeaderHeight();
1191		window.addEventListener('resize', function() { headerHeight = readHeaderHeight(); }, { passive: true });
1192		var lastY = window.scrollY;
1193		var ticking = false;
1194		var navFocused = false; // keep the nav links revealed while the header has focus
1195
1196		function update() {
1197			// A dialog pins the body, so window.scrollY reads 0 and the skin would
1198			// come off mid-modal. Nothing can scroll while locked — hold the state.
1199			// Clear ticking first or the rAF throttle latches and never runs again.
1200			if (document.documentElement.classList.contains('dialog-open')) {
1201				ticking = false;
1202				return;
1203			}
1204
1205			var y = window.scrollY;
1206			var delta = y - lastY;
1207
1208			// Solid skin the moment the page scrolls; transparent only at the very top.
1209			nav.classList.toggle('is-stuck', y > 0);
1210
1211			// Toggle the label on scroll down / off on scroll up. (4px = jitter guard.)
1212			if (ctx && !isInheritEmpty && Math.abs(delta) >= 4) {
1213				var show = delta > 0;
1214				if (isSvpDriven) {
1215					// Inherit mode — gate to the on-page SVP module; nothing once it leaves.
1216					var inSection = false;
1217					if (svp) {
1218						var r = svp.getBoundingClientRect();
1219						inSection = r.top <= headerHeight && r.bottom > headerHeight;
1220					}
1221					show = show && inSection;
1222				}
1223				nav.classList.toggle('is-context', show && !navFocused);
1224				lastY = y;
1225			}
1226			ticking = false;
1227		}
1228
1229		window.addEventListener('scroll', function() {
1230			if (!ticking) {
1231				requestAnimationFrame(update);
1232				ticking = true;
1233			}
1234		}, { passive: true });
1235
1236		// Keyboard access (HK3): the scroll-collapse hides the nav links via
1237		// visibility:hidden, putting them out of reach for a keyboard user who
1238		// scrolled down. Focusing any header element (logo, CTAs) reveals them,
1239		// and the collapse is suppressed until focus leaves the header. 2.1.1.
1240		nav.addEventListener('focusin', function() {
1241			navFocused = true;
1242			nav.classList.remove('is-context');
1243		});
1244		nav.addEventListener('focusout', function() {
1245			setTimeout(function() {
1246				if (!nav.contains(document.activeElement)) navFocused = false;
1247			}, 0);
1248		});
1249
1250		update();
1251	})();
1252
1253	/************ALERT BAR OFFSET************/
1254
1255	// An overlay header is position:fixed, so it can't be pushed down by the alert
1256	// bar sitting in flow above it. Publish how much of the bar is still on screen
1257	// and let the header ride that down to 0 as the bar scrolls away.
1258	(function() {
1259		var bars = document.querySelectorAll('.alert-bar');
1260		if (!bars.length) return;
1261
1262		var root = document.documentElement;
1263		var ticking = false;
1264
1265		// Only one of the desktop/mobile pair is displayed at a time.
1266		function visibleBarHeight() {
1267			for (var i = 0; i < bars.length; i++) {
1268				if (bars[i].offsetHeight) return bars[i].offsetHeight;
1269			}
1270			return 0;
1271		}
1272
1273		function update() {
1274			root.style.setProperty('--alert-bar-offset', Math.max(0, visibleBarHeight() - window.scrollY) + 'px');
1275			ticking = false;
1276		}
1277
1278		window.addEventListener('scroll', function() {
1279			if (!ticking) {
1280				requestAnimationFrame(update);
1281				ticking = true;
1282			}
1283		}, { passive: true });
1284		window.addEventListener('resize', update, { passive: true });
1285
1286		update();
1287	})();
1288
1289	/************MEGAMENU & MOBILE MENU************/
1290
1291	const $header = $('header');
1292	const $mobileMenuBtn = $header.find('.mobile-menu');
1293	const $menuMain = $header.find('.primary-links');
1294	const mobileBreakpoint = '(max-width: 1100px)';
1295	let isMobile = window.matchMedia(mobileBreakpoint).matches;
1296	let hoverTimeout = null;
1297
1298	/**
1299	 * Desktop: open a megamenu dropdown.
1300	 */
1301	function openDesktopMenu($li) {
1302		closeAllDesktopMenus();
1303		$li.addClass('active');
1304		$li.children('a, button').attr('aria-expanded', 'true');
1305	}
1306
1307	/**
1308	 * Desktop: close a specific megamenu dropdown.
1309	 */
1310	function closeDesktopMenu($li) {
1311		$li.removeClass('active');
1312		$li.children('a, button').attr('aria-expanded', 'false');
1313	}
1314
1315	/**
1316	 * Desktop: close all open megamenu dropdowns.
1317	 */
1318	function closeAllDesktopMenus() {
1319		$menuMain.find('> .menu-item-has-children.active').each(function() {
1320			closeDesktopMenu($(this));
1321		});
1322	}
1323
1324	/**
1325	 * Desktop hover handlers for megamenu items.
1326	 */
1327	function initDesktopMenu() {
1328		const $parents = $menuMain.find('> .menu-item-has-children');
1329
1330		$parents.on('mouseenter.megamenu', function() {
1331			clearTimeout(hoverTimeout);
1332			openDesktopMenu($(this));
1333		});
1334
1335		// The .megamenu is a DOM child of the <li>, so these li handlers already cover
1336		// the whole subtree (trigger + gap bridge + panel) — mouseleave fires only when
1337		// the cursor leaves ALL of it. No separate panel handlers: a panel→trigger move
1338		// fires the panel's mouseleave without a matching li mouseenter to clear the
1339		// timer, closing the menu under the cursor (the Safari flicker-out).
1340		$parents.on('mouseleave.megamenu', function() {
1341			const $li = $(this);
1342			hoverTimeout = setTimeout(function() {
1343				closeDesktopMenu($li);
1344			}, 150);
1345		});
1346	}
1347
1348	/**
1349	 * Desktop keyboard navigation (APG disclosure-navigation menubar). JS owns the
1350	 * open state (.active); CSS reflects it. ←/→ rove across top items, Enter/Space/↓
1351	 * open a panel and move focus in, ↑/↓/Home/End move between panel links, Escape
1352	 * (or focus leaving the item) closes and restores the trigger. Delegated on the
1353	 * menu so teardown is a single `.off('.megamenu')`.
1354	 */
1355	function initDesktopKeyboard() {
1356		const TOP_TRIGGERS = '> li > a, > li > button';
1357		const PANEL_LINKS  = '.menu-item-has-children > .megamenu a';
1358
1359		function enterPanel($li) {
1360			openDesktopMenu($li);
1361			const first = $li.find('> .megamenu a')[0];
1362			if (!first) return;
1363			// The panel transitions from visibility:hidden, so a synchronous focu
1363s()
1364			// can no-op before it renders — focus next frame, once it's focusable.
1365			requestAnimationFrame(function () { first.focus(); });
1366		}
1367
1368		// Move focus to the next/prev top-level item (wraps), closing any open panel.
1369		function roveTop(delta) {
1370			const $items = $menuMain.find(TOP_TRIGGERS);
1371			const i = $items.index(document.activeElement);
1372			if (i < 0) return;
1373			closeAllDesktopMenus();
1374			$items.eq((i + delta + $items.length) % $items.length).focus();
1375		}
1376
1377		// Top-level items: open/toggle, rove across the bar, escape.
1378		$menuMain.on('keydown.megamenu', TOP_TRIGGERS, function(e) {
1379			const $trigger = $(this);
1380			const $li = $trigger.parent();
1381			const hasSubmenu = $li.hasClass('menu-item-has-children');
1382			const toggleOnly = this.tagName === 'BUTTON'; // real links keep Enter for navigation
1383
1384			switch (e.key) {
1385				case 'Enter':
1386				case ' ':
1387					// Buttons toggle on both keys; real links navigate on Enter, open on Space.
1388					if (hasSubmenu && (toggleOnly || e.key === ' ')) {
1389						e.preventDefault();
1390						$li.hasClass('active') ? closeDesktopMenu($li) : enterPanel($li);
1391					}
1392					break;
1393				case 'ArrowDown':
1394					if (hasSubmenu) { e.preventDefault(); enterPanel($li); }
1395					break;
1396				case 'ArrowRight':
1397					e.preventDefault();
1398					roveTop(1);
1399					break;
1400				case 'ArrowLeft':
1401					e.preventDefault();
1402					roveTop(-1);
1403					break;
1404				case 'Escape':
1405					if ($li.hasClass('active')) {
1406						e.preventDefault();
1407						closeDesktopMenu($li);
1408						$trigger.focus();
1409					}
1410					break;
1411			}
1412		});
1413
1414		// Inside a panel: ↑/↓/Home/End move between links; ↑ from the first link
1415		// returns to the trigger (panel stays open); Escape/Space close + restore trigger.
1416		$menuMain.on('keydown.megamenu', PANEL_LINKS, function(e) {
1417			const $li = $(this).closest('.menu-item-has-children');
1418			const $links = $li.find('> .megamenu a');
1419			const index = $links.index(this);
1420			const $trigger = $li.children('a, button');
1421
1422			switch (e.key) {
1423				case 'Escape':
1424				case ' ':
1425					e.preventDefault();
1426					closeDesktopMenu($li);
1427					$trigger.focus();
1428					break;
1429				case 'ArrowDown':
1430					e.preventDefault();
1431					$links.eq((index + 1) % $links.length).focus();
1432					break;
1433				case 'ArrowUp':
1434					e.preventDefault();
1435					index <= 0 ? $trigger.focus() : $links.eq(index - 1).focus();
1436					break;
1437				case 'Home':
1438					e.preventDefault();
1439					$links.first().focus();
1440					break;
1441				case 'End':
1442					e.preventDefault();
1443					$links.last().focus();
1444					break;
1445			}
1446		});
1447
1448		// Close any open panel once focus leaves it — covers Tabbing out and moving
1449		// to a sibling. aria-expanded is owned by open/closeDesktopMenu, so it stays in sync.
1450		$menuMain.on('focusout.megamenu', function() {
1451			setTimeout(function() {
1452				$menuMain.find('> .menu-item-has-children.active').each(function() {
1453					if (!$.contains(this, document.activeElement)) closeDesktopMenu($(this));
1454				});
1455			}, 0);
1456		});
1457	}
1458
1459	/**
1460	 * Click outside closes desktop menus.
1461	 */
1462	function initDesktopClickOutside() {
1463		$(document).on('click.megamenu', function(e) {
1464			if (!$(e.target).closest('.primary-links').length) {
1465				closeAllDesktopMenus();
1466			}
1467		});
1468	}
1469
1470	/**
1471	 * Destroy desktop menu handlers.
1472	 */
1473	function destroyDesktopMenu() {
1474		$menuMain.find('> .menu-item-has-children').off('.megamenu');
1475		$menuMain.find('> .menu-item-has-children > .megamenu').off('.megamenu');
1476		$menuMain.find('> .menu-item-has-children > a, > .menu-item-has-children > button').off('.megamenu');
1477		$menuMain.off('.megamenu');
1478		$(document).off('click.megamenu');
1479		closeAllDesktopMenus();
1480	}
1481
1482	/**
1483	 * Apply inert + aria-hidden to page content outside the mobile menu.
1484	 * Traps focus inside the menu while open.
1485	 */
1486	function setMobileMenuInertState(isMenuOpen) {
1487		const targets = [
1488			document.querySelector('main'),
1489			document.querySelector('footer'),
1490			document.getElementById('skip-to-content'),
1491			document.querySelector('.alert-bar'),
1492		];
1493		targets.forEach(function(el) {
1494			if (!el) return;
1495			if (isMenuOpen) {
1496				el.setAttribute('inert', '');
1497				el.setAttribute('aria-hidden', 'true');
1498			} else {
1499				el.removeAttribute('inert');
1500				el.removeAttribute('aria-hidden');
1501			}
1502		});
1503	}
1504
1505	// Scroll-lock the page under the open menu without losing position. iOS Safari
1506	// drops the scroll offset with `overflow: hidden` on the root (so the page jumps
1507	// to the top on close), so pin the body with position:fixed + a negative top
1508	// (see html.active body in style.css) and restore the offset on unlock.
1509	let mobileMenuScrollY = 0;
1510
1511	function lockPageScroll() {
1512		mobileMenuScrollY = window.scrollY || window.pageYOffset || 0;
1513		document.body.style.top = (-mobileMenuScrollY) + 'px';
1514	}
1515
1516	function unlockPageScroll() {
1517		document.body.style.top = '';
1518		// behavior:'instant' overrides html{scroll-behavior:smooth} — otherwise the
1519		// restore animates up from 0, and an interrupted animation (iOS Safari) strands
1520		// the page near the top instead of where the menu was opened.
1521		window.scrollTo({ top: mobileMenuScrollY, left: 0, behavior: 'instant' });
1522	}
1523
1524	/**
1525	 * Collect focusable elements inside the mobile menu for Tab trapping.
1526	 */
1527	function getFocusableMenuElements() {
1528		const selector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not
1528([tabindex="-1"])';
1529		return $header.find(selector).filter(function() {
1530			const $el = $(this);
1531			return $el.is(':visible') && $el.css('visibility') !== 'hidden' && $el.css('opacity') !== '0';
1532		});
1533	}
1534
1535	/**
1536	 * Toggle mobile menu open/closed.
1537	 */
1538	function toggleMobileMenu() {
1539		const isOpen = $header.hasClass('active');
1540
1541		// Close any open dropdowns
1542		$header.find('li.menu-item-has-children.active').removeClass('active');
1543		$header.find('.menu-item-has-children > a, .menu-item-has-children > button').attr('aria-expanded', 'false');
1544
1545		$mobileMenuBtn.attr('aria-expanded', !isOpen);
1546
1547		if (!isOpen) {
1548			lockPageScroll(); // set the body offset before .active pins it, so the view holds
1549			$header.addClass('active');
1550			$('html').addClass('active');
1551			setMobileMenuInertState(true);
1552			setTimeout(function() {
1553				const $firstItem = $menuMain.find('> li:first-child > a, > li:first-child > button').first();
1554				if ($firstItem.length) $firstItem.focus({ preventScroll: true });
1555			}, 100);
1556		} else {
1557			$header.removeClass('active');
1558			$('html').removeClass('active');
1559			setMobileMenuInertState(false);
1560			unlockPageScroll();
1561		}
1562	}
1563
1564	/**
1565	 * Close mobile menu.
1566	 */
1567	function closeMobileMenu() {
1568		if ($header.hasClass('active')) {
1569			$header.removeClass('active');
1570			$('html').removeClass('active');
1571			$header.find('li.menu-item-has-children.active').removeClass('active');
1572			$header.find('.menu-item-has-children > a, .menu-item-has-children > button').attr('aria-expanded', 'false');
1573			$mobileMenuBtn.attr('aria-expanded', 'false');
1574			setMobileMenuInertState(false);
1575			unlockPageScroll();
1576			$mobileMenuBtn.focus({ preventScroll: true });
1577		}
1578	}
1579
1580	/**
1581	 * Mobile menu handlers.
1582	 */
1583	function initMobileMenu() {
1584		$mobileMenuBtn.on('click.mobilemenu', function(e) {
1585			e.preventDefault();
1586			e.stopPropagation();
1587			toggleMobileMenu();
1588		});
1589
1590		// Mobile dropdown toggles. Megamenu parents are always buttons (see
1591		// WPB_Custom_Walker), so nothing here navigates; the <a> arm covers
1592		// top-level items that have no panel.
1593		$menuMain.find('> .menu-item-has-children > a, > .menu-item-has-children > button').on('click.mobilemenu', function(e) {
1594			const $toggle = $(this);
1595			const $li = $toggle.parent();
1596			const isOpen = $li.hasClass('active');
1597			const href = $toggle.attr('href');
1598			const isRealLink = this.tagName === 'A' && href && href !== '#' && href !== '#/';
1599
1600			if (isRealLink && isOpen) return; // let the browser navigate
1601
1602			e.preventDefault();
1603
1604			// Close other open items
1605			$menuMain.find('> .menu-item-has-children.active').not($li).each(function() {
1606				$(this).removeClass('active');
1607				$(this).children('a, button').attr('aria-expanded', 'false');
1608			});
1609
1610			$li.toggleClass('active');
1611			$toggle.attr('aria-expanded', !isOpen);
1612		});
1613
1614		// Escape closes, Tab wraps focus inside the menu
1615		$(document).on('keydown.mobilemenu', function(e) {
1616			if (!$header.hasClass('active')) return;
1617
1618			if (e.key === 'Escape') {
1619				e.preventDefault();
1620				closeMobileMenu();
1621				return;
1622			}
1623
1624			if (e.key === 'Tab') {
1625				const $focusable = getFocusableMenuElements();
1626				if (!$focusable.length) return;
1627
1628				const $first = $focusable.first();
1629				const $last = $focusable.last();
1630				const $focused = $(document.activeElement);
1631
1632				if (e.shiftKey) {
1633					if ($focused.is($first) || !$.contains($header[0], document.activeElement)) {
1634						e.preventDefault();
1635						$last.focus();
1636					}
1637				} else {
1638					if ($focused.is($last)) {
1639						e.preventDefault();
1640						$first.focus();
1641					}
1642				}
1643			}
1644		});
1645	}
1646
1647	/**
1648	 * Destroy mobile menu handlers.
1649	 */
1650	function destroyMobileMenu() {
1651		$mobileMenuBtn.off('.mobilemenu');
1652		$menuMain.find('> .menu-item-has-children > a, > .menu-item-has-children > button').off('.mobilemenu');
1653		$(document).off('.mobilemenu');
1654		closeMobileMenu();
1655	}
1656
1657	/**
1658	 * Check viewport and switch between desktop/mobile handlers.
1659	 */
1660	function checkMobile() {
1661		const wasMobile = isMobile;
1662		isMobile = window.matchMedia(mobileBreakpoint).matches;
1663
1664		if (isMobile !== wasMobile) {
1665			if (isMobile) {
1666				destroyDesktopMenu();
1667				initMobileMenu();
1668			} else {
1669				destroyMobileMenu();
1670				initDesktopMenu();
1671				initDesktopKeyboard();
1672				initDesktopClickOutside();
1673			}
1674		}
1675	}
1676
1677	// Initialize based on current viewport
1678	if (isMobile) {
1679		initMobileMenu();
1680	} else {
1681		initDesktopMenu();
1682		initDesktopKeyboard();
1683		initDesktopClickOutside();
1684	}
1685
1686	window.addEventListener('resize', checkMobile);
1687
1688	/************Lightboxes************/
1689	// Dialogs are handled by JazzyDialog via [data-jazzy-dialog] markup.
1690
1691	/************iPhone: modal-lightbox MP4 plays fullscreen, no dialog************/
1692	// On iPhone a <video> without `playsinline` is forced into the native fullscreen
1693	// player, so opening the lightbox dialog and playing there drops the viewer back
1694	// onto the still-open dialog when they exit fullscreen — a redundant layer. Skip
1695	// the dialog and hand the mp4 straight to that native player. Gate on the iPhone
1696	// UA specifically: desktop Safari and iPad also expose webkitEnterFullscreen but
1697	// play INLINE, so there the hidden player would run invisibly and the dialog would
1698	// never open (that's the "dialog missing on Safari" bug). They keep the dialog —
1699	// as do iframe embeds, which have no single-video fullscreen handle anyway.
1700	if (/iPhone|iPod/.test(navigator.userAgent) &&
1701		typeof document.createElement('video').webkitEnterFullscreen === 'function') {
1702		// Capture phase so this beats JazzyDialog's delegated bubble-phase open handler.
1703		document.addEventListener('click', function (e) {
1704			var trigger = e.target.closest('[data-jazzy-dialog]');
1705			if (!trigger) return;
1706
1707			var dialog = document.getElementById(trigger.getAttribute('data-jazzy-dialog'));
1708			if (!dialog || !dialog.classList.contains('video-lightbox')) return;
1709
1710			var source = dialog.querySelector('video source[src], video[src]');
1711			if (!source) return; // iframe embed — let the dialog open normally
1712
1713			e.preventDefault();
1714			e.stopImmediatePropagation();
1715
1716			var player = document.createElement('video');
1717			player.src = source.getAttribute('src');
1718			player.setAttribute('controls', '');
1719			// No `playsinline` — its absence is what opens the iOS fullscreen player.
1720			player.style.cssText = 'position: fixed; left: 0; top: 0; width: 1px; height: 1px; opacity: 0;';
1721			document.body.appendChild(player);
1722
1723			var cleanup = function () { player.pause(); player.remove(); };
1724			player.addEventListener('webkitendfullscreen', cleanup, { once: true });
1725
1726			// Playing a non-inline video enters fullscreen; the click gesture carries it.
1727			var playing = player.play();
1728			if (playing) playing.catch(cleanup); // blocked/bad src — don't leak the node
1729		}, true);
1730	}
1731
1732	/************Video Facade (inline play)************/
1733
1734	document.querySelectorAll('.video-facade[data-video-url]').forEach(function(button) {
1735		button.addEventListener('click', function() {
1736			var url = button.getAttribute('data-video-url');
1737			var iframe = document.createElement('iframe');
1738			iframe.setAttribute('src', url);
1739			iframe.setAttribute('title', 'YouTube video player');
1740			iframe.setAttribute('frameborder', '0');
1741			iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');
1742			iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
1743			iframe.setAttribute('allowfullscreen', '');
1744			iframe.style.cssText = 'aspect-ratio: 16/9; display: block; height: auto; width: 100%;';
1745			button.parentNode.replaceChild(iframe, button);
1746			// The activated button is gone — move focus into the player (it has a
1747			// title for its accessible name) so focus isn't lost to <body>.
1748			iframe.setAttribute('tabindex', '-1');
1749			iframe.focus();
1750		});
1751	});
1752
1753	/************Video Facade — inline mp4************/
1754	// The orange .play button in a .media.video facade plays an inline <video> behind
1755	// it (poster hidden via .is-inline) and then toggles play↔pause, the icon swapping
1756	// off data-state. The button stays the control, layered on the hover scrim — no
1757	// native controls. Modal-mode facades use JazzyDialog instead.
1758
1759	document.querySelectorAll('.media.video .play[data-video-inline]').forEach(function(btn) {
1760		btn.setAttribute('aria-pressed', 'false'); // play/pause toggle — expose state to AT
1761		btn.addEventListener('click', function() {
1762			var url = btn.getAttribute('data-video-inline');
1763			var media = btn.closest('.media');
1764			if (!url || !media) return;
1765
1766			var video = media.querySelector('video');
1767			if (!video) {
1768				video = document.createElement('video');
1769				video.src = url;
1770				video.playsInline = true;
1771				video.preload = 'auto';
1772				media.insertBefore(video, btn);
1773				media.classList.add('is-inline');
1774				video.addEventListener('play',  function() { btn.dataset.state = 'playing'; btn.setAttribute('aria-pressed', 'true'); });
1775				video.addEventListener('pause', function() { btn.dataset.state = 'paused';  btn.setAttribute('aria-pressed', 'false'); });
1776				video.addEventListener('ended', function() { btn.dataset.state = 'paused';  btn.setAttribute('aria-pressed', 'false'); });
1777			}
1778
1779			if (video.paused) {
1780				var p = video.play();
1781				if (p) p.catch(function() {});
1782			} else {
1783				video.pause();
1784			}
1785		});
1786	});
1787
1788	/************Video Facade — inline embed (YouTube/Vimeo)************/
1789	// Embeds can't play through a native <video>, so the play button loads the
1790	// provider's iframe player in place (autoplay carried by the click gesture),
1791	// then hands off to the player's own controls.
1792
1793	document.querySelectorAll('.media.video .play[data-video-embed]').forEach(function(btn) {
1794		btn.addEventListener('click', function() {
1795			var url = btn.getAttribute('data-video-embed');
1796			var media = btn.closest('.media');
1797			if (!url || !media || media.querySelector('iframe')) return;
1798
1799			var iframe = document.createElement('iframe');
1800			iframe.src = url + (url.indexOf('?') === -1 ? '?' : '&') + 'autoplay=1';
1801			var label = btn.querySelector('.play-label');
1802			iframe.title = (label && label.textContent.trim()) || btn.getAttribute('aria-label') || '';
1803			iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');
1804			iframe.setAttribute('allowfullscreen', '');
1805			media.insertBefore(iframe, btn);
1806			media.classList.add('is-inline', 'is-embed');
1807			btn.hidden = true;
1808		});
1809	});
1810
1811	/************Video Facade — poster video, reduced motion************/
1812	// A poster video is decorative autoplay motion; under reduced-motion swap each
1813	// for its still poster image (or just stop it) so nothing autoplays.
1814
1815	if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
1816		document.querySelectorAll('.media.video .poster-video').forEach(function(posterVideo) {
1817			var poster = posterVideo.getAttribute('poster');
1818			if (poster) {
1819				var img = document.createElement('img');
1820				img.src = poster;
1821				img.alt = '';
1822				img.loading = 'lazy';
1823				posterVideo.replaceWith(img);
1824			} else {
1825				posterVideo.removeAttribute('autoplay');
1826				try { posterVideo.pause(); } catch (e) {}
1827			}
1828		});
1829	}
1830
1831	/************Autoplay — Safari************/
1832	// Safari enforces the muted-autoplay rules far more strictly than Chrome, and
1833	// fails silently (the rejected play() promise is the only signal):
1834	//   • the muted PROPERTY must be true when playback starts — the attribute alone
1835	//     isn't always enough once an element has been moved or re-loaded;
1836	//   • preload="none" is honoured, so an autoplay video carrying it never fetches
1837	//     and never starts. Chrome quietly overrides preload for autoplay;
1838	//   • playback is refused while the element is offscreen, and nothing retries —
1839	//     so a video scrolled into view later just sits frozen on its poster.
1840	// Scoped to video[autoplay]: those are the decorative, muted ones. The facade and
1841	// lightbox videos play with sound on a click and must never be force-muted.
1842	// Reduced motion is handled above (swapped for the poster / paused) — skip here.
1843
1844	if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
1845		var autoplayVideos = document.querySelectorAll('video[autoplay]');
1846
1847		var kickAutoplay = function(video) {
1848			video.muted = true; // property, not just the attribute
1849			var playback = video.play();
1850			if (playback && playback.catch) playback.catch(function() {});
1851		};
1852
1853		autoplayVideos.forEach(function(video) {
1854			video.muted = true;
1855			video.defaultMuted = true;
1856			video.playsInline = true;
1857			// autoplay + preload="none" is self-contradictory; Safari obeys the preload.
1858			if (video.preload === 'none') video.preload = 'metadata';
1859		});
1860
1861		// Retry as each enters view — Safari won't start an offscreen video.
1862		if ('IntersectionObserver' in window && autoplayVideos.length) {
1863			var autoplayObserver = new IntersectionObserver(function(entries) {
1864				entries.forEach(function(entry) {
1865					if (entry.isIntersecting) kickAutoplay(entry.target);
1866				});
1867			}, { rootMargin: '200px' });
1868
1869			autoplayVideos.forEach(function(video) { autoplayObserver.observe(video); });
1870		} else {
1871			autoplayVideos.forEach(kickAutoplay);
1872		}
1873	}
1874
1875	/************Looping Video — loop window************/
1876	// data-loop-start / data-loop-end (seconds) clip a looping video (poster
1877	// videos, hero backgrounds) to a repeating slice of the MP4. Native `loop`
1878	// restarts at 0, so when a window is set we drive the wrap ourselves: seek to
1879	// start, then jump back once currentTime passes end. Videos inside a
1880	// jazzy-carousel are excluded — the hero crossfade drives their windows.
1881	// Skipped under reduced-motion — those videos are swapped/paused above.
1882
1883	if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
1884		document.querySelectorAll('video[data-loop-start], video[data-loop-end]').forEach(function(video) {
1885			if (video.closest('[data-jazzy-carousel]')) return;
1886			var start = parseFloat(video.dataset.loopStart);
1887			var end   = parseFloat(video.dataset.loopEnd);
1888			var from  = !isNaN(start) && start > 0 ? start : 0;
1889			var to    = !isNaN(end) && end > from ? end : Infinity;
1890			if (from === 0 && to === Infinity) return; // no usable window
1891
1892			video.loop = false; // we restart at `from`, not 0
1893
1894			function restart() {
1895				try { video.currentTime = from; } catch (e) {}
1896				var playback = video.play();
1897				if (playback && playback.catch) playback.catch(function() {});
1898			}
1899
1900			if (from > 0) {
1901				if (video.readyState >= 1) { try { video.currentTime = from; } catch (e) {} }
1902				else video.addEventListener('loadedmetadata', function() { try { video.currentTime = from; } catch (e) {} }, { once: true });
1903			}
1904
1905			video.addEventListener('timeupdate', function() {
1906				var stop = to === Infinity ? (video.duration || Infinity) : to;
1907				if (video.currentTime >= stop || video.currentTime < from - 0.25) restart();
1908			});
1909			video.addEventListener('ended', restart);
1910		});
1911	}
1912
1913	/************Big Stats — fit oversized figures to one line************/
1914	// The giant stat figures (.style-big .number) must never wrap. When a value is
1915	// wider than its column (e.g. "$216K"), scale the font down so it fits one line.
1916
1917	function fitBigStats() {
1918		document.querySelectorAll('.module.stats.style-big .number').forEach(function(el) {
1919			el.style.fontSize = '';                 // reset to the CSS size for this breakpoint
1920			var avail = el.clientWidth;
1921			var content = el.scrollWidth;
1922			if (avail > 0 && content > avail) {
1923				var base = parseFloat(window.getComputedStyle(el).fontSize) || 0;
1924				if (base) el.style.fontSize = (base * avail / content * 0.98) + 'px';
1925			}
1926		});
1927	}
1928
1929	if (document.querySelector('.module.stats.style-big .number')) {
1930		fitBigStats();
1931		window.addEventListener('resize', fitBigStats);
1932		// Web fonts change glyph widths — re-fit once they're ready.
1933		if (document.fonts && document.fonts.ready) document.fonts.ready.then(fitBigStats);
1934	}
1935
1936	/************ACCORDIONS************/
1937	// Accordions are handled by JazzyAccordion.
1938
1939	/************ANNOUNCEMENT BANNER************/
1940	// Announcement/toast handled by JazzyToast.
1941	
1942	/************ARCHIVE FILTERS POPUP************/
1943	
1944	const filterBtn = document.getElementById('open-filters');
1945	const sidebar = document.getElementById('sidebar-container');
1946	const sidebarClose = document.getElementById('close-filters');
1947	
1948	if (filterBtn && sidebar && sidebarClose) {
1949		const SIDEBAR_FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not
1949([tabindex="-1"])';
1950
1951		const openSidebar = () => {
1952			filterBtn.classList.remove('active');
1953			sidebar.classList.add('active');
1954			sidebar.setAttribute('aria-hidden', 'false');
1955			const firstInput = sidebar.querySelector('input');
1956			if (firstInput) {
1957				// Wait for the sidebar to be visible before focusing
1958				setTimeout(() => {
1959					firstInput.focus();
1960				}, 250);
1961			}
1962		};
1963
1964		const closeSidebar = () => {
1965			filterBtn.classList.add('active');
1966			sidebar.classList.remove('active');
1967			sidebar.setAttribute('aria-hidden', 'true');
1968			filterBtn.focus();
1969		};
1970
1971		filterBtn.addEventListener('click', openSidebar);
1972		sidebarClose.addEventListener('click', closeSidebar);
1973
1974		// While the mobile overlay is open: Escape closes, Tab is trapped inside.
1975		sidebar.addEventListener('keydown', (e) => {
1976			if (!sidebar.classList.contains('active')) return;
1977
1978			if (e.key === 'Escape') {
1979				e.preventDefault();
1980				closeSidebar();
1981				return;
1982			}
1983
1984			if (e.key === 'Tab') {
1985				const focusable = Array.prototype.slice.call(sidebar.querySelectorAll(SIDEBAR_FOCUSABLE))
1986					.filter((el) => el.offsetParent !== null);
1987				if (!focusable.length) return;
1988				const first = focusable[0];
1989				const last = focusable[focusable.length - 1];
1990				if (e.shiftKey && document.activeElement === first) {
1991					e.preventDefault();
1992					last.focus();
1993				} else if (!e.shiftKey && document.activeElement === last) {
1994					e.preventDefault();
1995					first.focus();
1996				}
1997			}
1998		});
1999	}
2000	
2001	const archiveFilters = () => {
2002		if (!sidebar) return;
2003		if (window.matchMedia('only screen and (max-width: 75rem)').matches) {
2004			sidebar.setAttribute('aria-hidden', 'true');
2005		} else {
2006			sidebar.setAttribute('aria-hidden', 'false');
2007		}
2008	}
2009	
2010	window.addEventListener('resize', function() {
2011		archiveFilters();
2012	});
2013	
2014	archiveFilters();
2015
2016	/************Team Dialogs************/
2017	// Handled by JazzyDialog via [data-jazzy-dialog] on the card trigger.
2018	// Backdrop click → close is also native to JazzyDialog.
2019
2020	/************VoiceAI Play Button************/
2021	// Toggles the audio element next to the play button. Pauses any other
2022	// playing VoiceAI on the page when a new one starts. State drives the
2023	// triangle/pause-bars visual via the [data-state] attribute (see CSS).
2024
2025	document.querySelectorAll('.module.voiceai .play[data-media]').forEach(function(btn) {
2026		var section = btn.closest('.module.voiceai');
2027		var media   = section && section.querySelector('.media-source');
2028		if (!media) return;
2029
2030		// The accessible name carries the state — "Pause" while playing, the demo's
2031		// own label when stopped — so the icon swap isn't sight-only. Deliberately
2032		// not aria-pressed: "pressed" misdescribes playback, and pairing it with a
2033		// changing name double-encodes the same thing.
2034		var labelPlay  = btn.getAttribute('data-label-play')  || btn.getAttribute('aria-label') || '';
2035		var labelPause = btn.getAttribute('data-label-pause') || labelPlay;
2036
2037		function setState(state) {
2038			btn.setAttribute('data-state', state);
2039			btn.setAttribute('aria-label', state === 'playing' ? labelPause : labelPlay);
2040		}
2041		setState('paused');
2042
2043		btn.addEventListener('click', function() {
2044			if (media.paused) {
2045				document.querySelectorAll('.module.voiceai .media-source').forEach(function(m) {
2046					if (m !== media) m.pause();
2047				});
2048				document.querySelectorAll('.module.voiceai .play[data-state="playing"]').forEach(function(b) {
2049					if (b === btn) return;
2050					b.setAttribute('data-state', 'paused');
2051					// Reset its name too, or the stopped button keeps announcing "Pause".
2052					var other = b.getAttribute('data-label-play');
2053					if (other) b.setAttribute('aria-label', other);
2054				});
2055				media.play();
2056				setState('playing');
2057			} else {
2058				media.pause();
2059				setState('paused');
2060			}
2061		});
2062		media.addEventListener('ended', function() { setState('paused'); });
2063		media.addEventListener('pause', function() { if (!media.ended) setState('paused'); });
2064		media.addEventListener('play',  function() { setState('playing'); });
2065	});
2066
2067	/************VoiceAI Video Tabs (modal)************/
2068	// The play button opens a dialog of Vimeo tabs (JazzyDialog handles open/
2069	// close/escape/focus-return). The first tab is active; switching loads that
2070	// tab's player and clears the others so only one plays, and closing clears
2071	// them all to stop playback. Tablist follows the WAI-ARIA tabs pattern:
2072	// roving tabindex + arrow/Home/End key nav.
2073
2074	document.querySelectorAll('dialog.voiceai-modal').forEach(function(dialog) {
2075		var tabs   = Array.prototype.slice.call(dialog.querySelectorAll('[role="tab"]'));
2076		var panels = Array.prototype.slice.call(dialog.querySelectorAll('[role="tabpanel"]'));
2077		if (!tabs.length) return;
2078
2079		function iframeFor(index) {
2080			return panels[index] ? panels[index].querySelector('iframe') : null;
2081		}
2082
2083		function loadVideo(iframe) {
2084			if (!iframe || iframe.getAttribute('src')) return;
2085			var base = iframe.getAttribute('data-vimeo-src');
2086			if (!base) return;
2087			iframe.setAttribute('src', base + (base.indexOf('?') === -1 ? '?' : '&') + 'autoplay=1');
2088		}
2089
2090		function clearVideo(iframe) {
2091			if (iframe) iframe.removeAttribute('src'); // stops Vimeo playback
2092		}
2093
2094		function currentIndex() {
2095			var found = tabs.findIndex(function(tab) { return tab.getAttribute('aria-selected') === 'true'; });
2096			return found < 0 ? 0 : found;
2097		}
2098
2099		function selectTab(index, focusTab) {
2100			tabs.forEach(function(tab, i) {
2101				var selected = (i === index);
2102				tab.setAttribute('aria-selected', selected ? 'true' : 'false');
2103				tab.tabIndex = selected ? 0 : -1;
2104				if (panels[i]) panels[i].hidden = !selected;
2105				if (!selected) clearVideo(iframeFor(i));
2106			});
2107			if (dialog.open) loadVideo(iframeFor(index));
2108			if (focusTab && tabs[index]) tabs[index].focus();
2109		}
2110
2111		tabs.forEach(function(tab, i) {
2112			tab.addEventListener('click', function() { selectTab(i, true); });
2113		});
2114
2115		var tablist = dialog.querySelector('[role="tablist"]');
2116		if (tablist) {
2117			tablist.addEventListener('keydown', function(e) {
2118				var i = currentIndex();
2119				var next = null;
2120				if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % tabs.length;
2121				else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + tabs.length) % tabs.length;
2122				else if (e.key === 'Home') next = 0;
2123				else if (e.key === 'End') next = tabs.length - 1;
2124				if (next === null) return;
2125				e.preventDefault();
2126				selectTab(next, true);
2127			});
2128		}
2129
2130		dialog.addEventListener('jazzydialog:open', function() { loadVideo(iframeFor(currentIndex())); });
2131		dialog.addEventListener('jazzydialog:close', function() {
2132			panels.forEach(function(panel) { clearVideo(panel.querySelector('iframe')); });
2133		});
2134	});
2135
2136	/************Logo Garden Rotator************/
2137	// One rotator per garden: every spare logo (each tile's non-active frames)
2138	// joins a shared pool, and on each tick a RANDOM tile crossfades to the
2139	// pool's least-recently-shown logo — its old logo rejoins the back of the
2140	// pool. Any tile can swap next, so the grid reads as unpredictable,
2141	// occasional refreshes instead of the same slots cycling on a schedule.
2142
2143	(function() {
2144		// One swap per (DWELL + FADE); FADE_MS must match the CSS crossfade so a tile
2145		// finishes settling before it can be picked again. Split 2800/400 rather than
2146		// 2400/800 — same 3.2s cadence, but the fade is now a single crossfade.
2147		var DWELL_MS = 2800;
2148		var FADE_MS  = 400;
2149
2150		if (!isMotionAllowed.matches) return; // reduced motion: hold on the markup's default frames
2151
2152		document.querySelectorAll('.module.logo-garden .logos').forEach(function(garden) {
2153			var tiles = Array.prototype.slice.call(garden.querySelectorAll('.logo'));
2154			var pool  = Array.prototype.slice.call(garden.querySelectorAll('.frame:not(.is-active)'));
2155			if (!tiles.length || !pool.length) return; // nothing spare to rotate in (incl. awards style)
2156
2157			// Frames migrate between tiles, so every tile needs the stacked positioning.
2158			tiles.forEach(function(tile) { tile.classList.add('stacked'); });
2159
2160			var lastIdx = -1;
2161
2162			function tick() {
2163				var idx = Math.floor(Math.random() * tiles.length);
2164				if (tiles.length > 1 && idx === lastIdx) idx = (idx + 1) % tiles.length; // never the same tile twice running
2165				lastIdx = idx;
2166
2167				var tile     = tiles[idx];
2168				var outgoing = tile.querySelector('.frame.is-active');
2169				var incoming = pool.shift();
2170				tile.appendChild(incoming); // appendChild moves it from wherever it last showed
2171				void incoming.offsetWidth;  // commit the move before fading so the transition runs
2172				if (outgoing) {
2173					outgoing.classList.remove('is-active');
2174					pool.push(outgoing);
2175				}
2176				incoming.classList.add('is-active');
2177			}
2178
2179			// One steady metronome — pausing (hover / keyboard focus, WCAG 2.2.2)
2180			// skips beats rather than stopping the interval, so resuming never
2181			// re-phases the rhythm and swaps stay evenly spaced.
2182			var paused = false;
2183			garden.addEventListener('mouseenter', function() { paused = true; });
2184			garden.addEventListener('mouseleave', function() { paused = false; });
2185			garden.addEventListener('focusin',    function() { paused = true; });
2186			garden.addEventListener('focusout',   function() { paused = false; });
2187
2188			setTimeout(function() {
2189				if (!paused) tick();
2190				setInterval(function() {
2191					if (!paused) tick();
2192				}, DWELL_MS + FADE_MS);
2193			}, DWELL_MS);
2194		});
2195	})();
2196
2197	// ── Steps Timeline — smooth scroll-linked spine fill + number cascade ──
2198	// The spine runs from 16px below the first number to the last number's centre.
2199	// --steps-progress (0→1) tracks the viewport anchor (~45%) along that span and
2200	// drives the orange fill's scaleY, so the line fills smoothly with scroll. As
2201	// the anchor passes each number it goes .is-completed; the last passed one is
2202	// .is-active (its glow pulses). Geometry is measured (number positions vary
2203	// with content/media height) and re-measured on resize / load / list resize.
2204	(function () {
2205		var lists = document.querySelectorAll('[data-steps-timeline]');
2206		if (!lists.length) return;
2207
2208		lists.forEach(function (list) {
2209			if (list.dataset.stepsInit) return;
2210			list.dataset.stepsInit = '1';
2211
2212			var cards   = Array.prototype.slice.call(list.querySelectorAll('.step-card'));
2213			if (!cards.length) return;
2214			var numbers = cards.map(function (c) { return c.querySelector('.step-card-number'); });
2215			// Sticky media stage frames (desktop) — synced to the active step below.
2216			var frames  = Array.prototype.slice.call((list.parentNode || document).querySelectorAll('.media-stage .media'));
2217			var stage   = (list.parentNode || document).querySelector('.media-stage');
2218			// steps.php drops aria-hidden the moment a step's video gets a play button.
2219			var stageInteractive = !!stage && !stage.hasAttribute('aria-hidden');
2220
2221			var spineTopDoc = 0, spineBottomDoc = 0, centersDoc = [];
2222
2223			function measure() {
2224				var sy        = window.scrollY || window.pageYOffset;
2225				var listTop   = list.getBoundingClientRect().top + sy;
2226				var firstRect = numbers[0].getBoundingClientRect();
2227				var lastRect  = numbers[numbers.length - 1].getBoundingClientRect();
2228
2229				spineTopDoc    = firstRect.bottom + sy + 16;             // 16px below the first number
2230				spineBottomDoc = lastRect.top + sy + lastRect.height / 2; // last number's centre
2231				centersDoc = numbers.map(function (n) {
2232					var r = n.getBoundingClientRect();
2233					return r.top + sy + r.height / 2;
2234				});
2235
2236				list.style.setProperty('--steps-spine-top', (spineTopDoc - listTop) + 'px');
2237				list.style.setProperty('--steps-spine-height', Math.max(0, spineBottomDoc - spineTopDoc) + 'px');
2238			}
2239
2240			function update() {
2241				var sy       = window.scrollY || window.pageYOffset;
2242				var anchor   = sy + window.innerHeight * 0.45;
2243				var span     = spineBottomDoc - spineTopDoc;
2244				var progress = span > 0 ? (anchor - spineTopDoc) / span : 0;
2245				progress = Math.max(0, Math.min(1, progress));
2246				list.style.setProperty('--steps-progress', progress.toFixed(4));
2247
2248				// Last number the anchor has reached is active; earlier ones complete.
2249				var activeIdx = 0;
2250				centersDoc.forEach(function (cy, i) { if (anchor >= cy) activeIdx = i; });
2251				cards.forEach(function (card, i) {
2252					card.classList.toggle('is-active', i === activeIdx);
2253					card.classList.toggle('is-completed', i < activeIdx);
2254				});
2255
2256				// Crossfade the sticky stage to the latest media-bearing step the anchor
2257				// has reached (steps without media keep the previous frame); default to
2258				// the first so the stage is never blank.
2259				if (frames.length) {
2260					var activeFrame = frames[0];
2261					frames.forEach(function (f) {
2262						if (parseInt(f.dataset.step, 10) <= activeIdx) activeFrame = f;
2263					});
2264					// Stacked frames all occupy the same cell, so the faded-out ones are
2265					// still in the tab order. Harmless for an image, not for a video step's
2266					// play button — inert them unless the stage is decorative anyway.
2267					if (stageInteractive) {
2268						frames.forEach(function (f) { f.inert = f !== activeFrame; });
2269					}
2270					frames.forEach(function (f) { f.classList.toggle('is-active', f === activeFrame); });
2271				}
2272			}
2273
2274			var ticking = false;
2275			function onScroll() {
2276				if (ticking) return;
2277				ticking = true;
2278				requestAnimationFrame(function () { update(); ticking = false; });
2279			}
2280
2281			// Coalesce re-measures into one frame — measure() reads rects and writes
2282			// custom properties, so an unthrottled handler thrashes layout.
2283			var pending = false;
2284			function remeasure() {
2285				if (pending) return;
2286				pending = true;
2287				requestAnimationFrame(function () { measure(); update(); pending = false; });
2288			}
2289
2290			measure();
2291			update();
2292			window.addEventListener('scroll', onScroll, { passive: true });
2293			window.addEventListener('resize', remeasure, { passive: true });
2294			// Media / fonts shift the badge positions after first paint — re-measure.
2295			window.addEventListener('load', remeasure);
2296			// Font swap and late media resize the cards without firing a window resize.
2297			// The spine lives on abs-positioned pseudo-elements, so writing the vars
2298			// can't resize .list and re-trigger this.
2299			if (window.ResizeObserver) new ResizeObserver(remeasure).observe(list);
2300		});
2301	})();
2302
2303	// ── Exclusive-open accordions ──
2304	// Inside [data-accordion-exclusive], opening one global .accordion closes the
2305	// others. JazzyAccordion fires a (non-bubbling) jazzyaccordion:change per row,
2306	// so listen on each. Un-instantiated rows (e.g. the default-open one) are
2307	// closed by hand; their next click re-inits from the cleared state.
2308	(function () {
2309		document.querySelectorAll('[data-accordion-exclusive]').forEach(function (group) {
2310			var items = group.querySelectorAll('.accordion');
2311			items.forEach(function (item) {
2312				item.addEventListener('jazzyaccordion:change', function (e) {
2313					if (!e.detail || !e.detail.open) return;
2314					items.forEach(function (other) {
2315						if (other === item || !other.classList.contains('active')) return;
2316						if (other._jazzyAccordion) {
2317							other._jazzyAccordion.hide();
2318						} else {
2319							other.classList.remove('active');
2320							var btn = other.querySelector('button');
2321							if (btn) btn.setAttribute('aria-expanded', 'false');
2322						}
2323					});
2324				});
2325			});
2326		});
2327	})();
2328
2329	// ── Deep-link: open the .accordion whose id matches the URL hash ──
2330	// e.g. /roi-calculators/#roi-reduce-no-shows lands with that calculator open.
2331	// Clicking the button routes through JazzyAccordion (and the exclusive handler
2332	// above); we scroll once it has had a moment to expand. Runs on load so the
2333	// accordions are initialised first.
2334	(function () {
2335		function openHashAccordion() {
2336			var id = (window.location.hash || '').replace(/^#/, '');
2337			if (!id) return;
2338			var el = document.getElementById(id);
2339			if (!el || !el.classList.contains('accordion') || el.classList.contains('active')) return;
2340			var btn = el.querySelector('button');
2341			if (btn) btn.click();
2342			setTimeout(function () {
2343				el.scrollIntoView({ behavior: isMotionAllowed.matches ? 'smooth' : 'auto', block: 'start' });
2344				// Move focus to the opened accordion's trigger so keyboard/SR users
2345				// land on the region they deep-linked to, not <body>.
2346				if (btn) btn.focus({ preventScroll: true });
2347			}, 60);
2348		}
2349		window.addEventListener('load', openHashAccordion);
2350		window.addEventListener('hashchange', openHashAccordion);
2351	})();
2352
2353});
2354
2355/************People — Bio Dialog************/
2356// JazzyDialog opens/closes the shared per-module <dialog class="people-modal">
2357// (escape, backdrop, focus trap, focus restore all native). Each .leader card 
2357is
2358// the trigger; on open we fill the dialog's fields from the card's data-*. Bio
2359// HTML rides in a per-card <template> (keeps real markup out of attributes).
2360jQuery(function ($) {
2361
2362	function initials(name) {
2363		var parts = (name || '').trim().split(/\s+/).filter(Boolean);
2364		if (!parts.length) return '';
2365		var first = parts[0].charAt(0);
2366		var last  = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
2367		return (first + last).toUpperCase();
2368	}
2369
2370	document.querySelectorAll('dialog.people-modal').forEach(function (dialog) {
2371		var nameEl   = dialog.querySelector('[data-name]');
2372		var roleEl   = dialog.querySelector('[data-role]');
2373		var bioEl    = dialog.querySelector('[data-bio]');
2374		var linkEl   = dialog.querySelector('[data-linkedin]');
2375		var photoEl  = dialog.querySelector('[data-photo]');
2376		var initEl   = photoEl ? photoEl.querySelector('.initials') : null;
2377		var scroller = dialog.querySelector('.inner');
2378
2379		dialog.addEventListener('jazzydialog:open', function (e) {
2380			var card = e.detail && e.detail.trigger;
2381			if (!card) return;
2382
2383			// One dialog is reused for every card, so .inner keeps the previous bio's
2384			// scrollTop — a long bio would open the next one mid-way down.
2385			if (scroller) scroller.scrollTop = 0;
2386
2387			var name     = card.getAttribute('data-name')     || '';
2388			var role     = card.getAttribute('data-role')     || '';
2389			var photo    = card.getAttribute('data-photo')    || '';
2390			var linkedin = card.getAttribute('data-linkedin') || '';
2391			var bioId    = card.getAttribute('data-bio')      || '';
2392
2393			if (nameEl) nameEl.textContent = name;
2394			if (roleEl) { roleEl.textContent = role; roleEl.hidden = role === ''; }
2395
2396			// Photo, or initials fallback when the card has no headshot. The
2397			// <img> is created only when there's a src — never rendered empty.
2398			if (photoEl && initEl) {
2399				var photoImg = photoEl.querySelector('img');
2400				if (photo) {
2401					if (!photoImg) {
2402						photoImg = document.createElement('img');
2403						photoEl.insertBefore(photoImg, initEl);
2404					}
2405					photoImg.src = photo;
2406					photoImg.alt = name;
2407					initEl.hidden = true;
2408				} else {
2409					if (photoImg) photoImg.remove();
2410					initEl.textContent = initials(name);
2411					initEl.hidden = false;
2412				}
2413			}
2414
2415			if (bioEl) {
2416				bioEl.innerHTML = '';
2417				var tpl = bioId ? document.getElementById(bioId) : null;
2418				if (tpl && 'content' in tpl) bioEl.appendChild(tpl.content.cloneNode(true));
2419			}
2420
2421			if (linkEl) {
2422				if (linkedin) { linkEl.href = linkedin; linkEl.hidden = false; }
2423				else { linkEl.hidden = true; linkEl.removeAttribute('href'); }
2424			}
2425		});
2426	});
2427
2428});
2429
2430
2431/************ "Clear filters" visibility (Jazzy archive filter bars) ************/
2432// A jx-form-clear-filters control shows only while its form holds an active user
2433// filter — a checked box, a selected option, or search text (locked/persisted and
2434// page inputs don't count). Jazzy's clearFilters() resets inputs without firing
2435// change, and clearing via ONE control (e.g. the link injected into an empty-results
2436// message) must hide the others — so re-sync on any clear-filters click too.
2437document.addEventListener('DOMContentLoaded', function () {
2438	if (!document.querySelector('[jx-form-clear-filters]')) return;
2439
2440	function formFor(ctrl) {
2441		return ctrl.closest('[jx-form-container]')
2442			|| (ctrl.getAttribute('form') ? document.getElementById(ctrl.getAttribute('form')) : null);
2443	}
2444	function hasActiveFilter(form) {
2445		if (!form) return false;
2446		return Array.prototype.some.call(
2447			form.querySelectorAll('[jx-form-query]:not([jx-form-query-persist])'),
2448			function (el) {
2449				if (el.type === 'hidden' || el.getAttribute('jx-form-query') === 'page') return false;
2450				if (el.type === 'checkbox' || el.type === 'radio') return el.checked;
2451				return String(el.value || '').trim() !== '';
2452			}
2453		);
2454	}
2455	function syncAll() {
2456		document.querySelectorAll('[jx-form-clear-filters]').forEach(function (ctrl) {
2457			var form = formFor(ctrl);
2458			if (form) ctrl.hidden = !hasActiveFilter(form); // results-injected link has no form — leave it
2459		});
2460	}
2461
2462	// A filter changed (select / checkbox / search text in any archive form).
2463	document.addEventListener('change', function (e) {
2464		if (e.target.matches && e.target.matches('[jx-form-query]')) syncAll();
2465	});
2466	// Clearing via any control resets every control's inputs on a tick, no change event.
2467	document.addEventListener('click', function (e) {
2468		if (e.target.closest('[jx-form-clear-filters]')) setTimeout(syncAll, 0);
2469	});
2470
2471	syncAll();
2472});
2473
2474/************Article TOC scroll-spy************/
2475// Highlights the contents link for the H2 section currently at the top of the
2476// reading area (single.php renders .article-toc when the Show TOC field is on).
2477document.addEventListener('DOMContentLoaded', function() {
2478	var toc = document.querySelector('.article-toc');
2479	if (!toc) return;
2480
2481	var links = {};
2482	var ids   = [];
2483	toc.querySelectorAll('a[href^="#"]').forEach(function(a) {
2484		var id      = decodeURIComponent(a.getAttribute('href').slice(1));
2485		var section = document.getElementById(id);
2486		if (section) { links[id] = a; ids.push(id); }
2487	});
2488	if (!ids.length) return;
2489
2490	// Read html's scroll-padding-top rather than hard-coding: it's where an anchor
2491	// jump should park a heading (header height + spacing, more under a sticky
2492	// menu), so the spy and the jump can't disagree about what "current" means.
2493	function scrollPadding() {
2494		var pad = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop);
2495		return isNaN(pad) ? 120 : pad;
2496	}
2497
2498	function anchorLine() {
2499		return scrollPadding() + 4;
2500	}
2501
2502	// Do the jump ourselves: WebKit ignores scroll-padding on fragment navigation,
2503	// so a clicked heading parked at the very top of the viewport — behind the
2504	// header — while Chrome placed it correctly. Driving it off scrollPadding()
2505	// lands it in the same spot in every engine, and in the same spot the spy
2506	// measures from. Modified clicks are left to the browser (new tab, download).
2507	toc.addEventListener('click', function(e) {
2508		if (e.defaultPrevented || e.button !== 0) return;
2509		if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
2510
2511		var link = e.target.closest('a[href^="#"]');
2512		if (!link || !toc.contains(link)) return;
2513
2514		var section = document.getElementById(decodeURIComponent(link.getAttribute('href').slice(1)));
2515		if (!section) return;
2516
2517		e.preventDefault();
2518		window.scrollTo({
2519			top: section.getBoundingClientRect().top + window.scrollY - scrollPadding(),
2520			behavior: isMotionAllowed.matches ? 'smooth' : 'auto'
2521		});
2522
2523		// preventDefault costs the two things the browser's own jump gives a
2524		// keyboard user, so hand them back: focus moves into the section it
2525		// scrolled to (the heading isn't focusable on its own), and the URL stays
2526		// shareable — pushState rather than location.hash, which would re-trigger
2527		// the unpadded jump we just replaced.
2528		section.setAttribute('tabindex', '-1');
2529		section.focus({ preventScroll: true });
2530		history.pushState(null, '', link.getAttribute('href'));
2531	});
2532
2533	// Same unpadded jump, but for a URL that arrives with the hash already set —
2534	// the browser does that one before this file runs, so the click handler above
2535	// never sees it. Re-run it on load, once images have reserved their space.
2536	function jumpToHash() {
2537		var id = decodeURIComponent((window.location.hash || '').replace(/^#/, ''));
2538		if (!id || !links[id]) return;
2539
2540		var section = document.getElementById(id);
2541		if (!section) return;
2542
2543		window.scrollTo({
2544			top: section.getBoundingClientRect().top + window.scrollY - scrollPadding(),
2545			behavior: 'auto'
2546		});
2547		section.setAttribute('tabindex', '-1');
2548		section.focus({ preventScroll: true });
2549	}
2550
2551	if (window.location.hash) window.addEventListener('load', jumpToHash);
2552	window.addEventListener('hashchange', jumpToHash);
2553
2554	var current = null;
2555
2556	// Last heading at or above the line wins; above the first heading the first stays
2557	// lit. Chosen by position, not by "is one inside a band" — a band leaves the
2558	// highlight stranded on the old section whenever a jump lands outside it.
2559	function update() {
2560		var line   = anchorLine();
2561		var active = ids[0];
2562		for (var i = 0; i < ids.length; i++) {
2563			if (document.getElementById(ids[i]).getBoundingClientRect().top > line) break;
2564			active = ids[i];
2565		}
2566		if (active === current) return;
2567		current = active;
2568		ids.forEach(function(key) {
2569			var on = key === current;
2570			links[key].classList.toggle('is-active', on);
2571			if (on) links[key].setAttribute('aria-current', 'location');
2572			else    links[key].removeAttribute('aria-current');
2573		});
2574	}
2575
2576	var queued = false;
2577	function schedule() {
2578		if (queued) return;
2579		queued = true;
2580		requestAnimationFrame(function() { queued = false; update(); });
2581	}
2582
2583	window.addEventListener('scroll', schedule, { passive: true });
2584	window.addEventListener('resize', schedule);
2585	update();
2586});
2587
2588/************Demo form pill toggle************/
2589// The [role=switch] pill (.demo-form-toggle) ships styled but inert; the CSS
2590// only reacts to aria-checked. Make it focusable and flip the attribute on
2591// click / Space / Enter so the knob and NO⇄YES label actually move.
2592document.addEventListener('DOMContentLoaded', function() {
2593	document.querySelectorAll('.demo-form-toggle[role="switch"]').forEach(function(toggle) {
2594		if (!toggle.hasAttribute('tabindex')) toggle.setAttribute('tabindex', '0');
2595
2596		function flip() {
2597			var on = toggle.getAttribute('aria-checked') === 'true';
2598			toggle.setAttribute('aria-checked', on ? 'false' : 'true');
2599		}
2600
2601		toggle.addEventListener('click', flip);
2602		toggle.addEventListener('keydown', function(e) {
2603			if (e.key === ' ' || e.key === 'Enter') {
2604				e.preventDefault();
2605				flip();
2606			}
2607		});
2608	});
2609});
2610
2611/************Tooltips************/
2612
2613// Reveal is CSS (:hover / :focus-within). WCAG 1.4.13 additionally requires the
2614// popup be dismissible without moving the pointer, which CSS alone can't do —
2615// Escape sets .is-dismissed, cleared once the pointer or focus actually leaves.
2616(function () {
2617	var tooltips = document.querySelectorAll('.tooltip');
2618	if (!tooltips.length) return;
2619
2620	tooltips.forEach(function (tooltip) {
2621		['mouseleave', 'focusout'].forEach(function (event) {
2622			tooltip.addEventListener(event, function () {
2623				tooltip.classList.remove('is-dismissed');
2624			});
2625		});
2626	});
2627
2628	document.addEventListener('keydown', function (e) {
2629		if (e.key !== 'Escape') return;
2630
2631		tooltips.forEach(function (tooltip) {
2632			var focused = tooltip.contains(document.activeElement);
2633			if (!focused && !tooltip.matches(':hover')) return;
2634
2635			tooltip.classList.add('is-dismissed');
2636			if (focused) document.activeElement.blur(); // drops :focus-within
2637		});
2638	});
2639})();

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.