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.