PageSourceSearch

https://plugsurfing.com/pages-js/plugsurfing-ev-charging-app.js

js plugsurfing.com collected 2026-09-24 08:57:01 UTC 32,863 bytes, 674 lines download raw bytes

1(function(){
2  // Accordion: any button[aria-expanded] toggles its controlled panel. Exclude
3  // the header dropdown toggles (owned by the header block below).
4  document.querySelectorAll('[aria-controls][aria-expanded]:not(.nav-menu-toggle)').forEach(function(btn){
5    btn.addEventListener('click', function(){
6      var open = btn.getAttribute('aria-expanded') === 'true';
7      btn.setAttribute('aria-expanded', String(!open));
8      var panel = document.getElementById(btn.getAttribute('aria-controls'));
9      if (panel) panel.hidden = open;
10    });
11  });
12
13  // ── Header states ─────────────────────────────────────────────────────────
14  // The mirrored CSS styles three header states the stripped theme JS used to
15  // drive, so without this block none of them could ever be reached: .fixed
16  // (the white sticky bar), ul.is-active (the desktop dropdowns) and
17  // header.is-active (the mobile overlay). Ported from the theme's own
18  // components -- header.js, ps-header-nav.js and ps-toggle.js on staging -- so
19  // the behaviour matches the source of truth. Structure, labels and link
20  // targets are untouched; this only toggles classes and ARIA state.
21  var header = document.getElementById('header');
22
23  // .fixed as soon as the page has scrolled clear of the header's own band.
24  //
25  // The theme keyed this off the hero instead -- .fixed for as long as the hero
26  // was out of view -- which made the bar arrive at wildly different points
27  // from page to page, because it is really measuring hero height: 200px into
28  // /legal/how-to-invoice-plugsurfing, 900 on the front page, 1300 on /contact.
29  // On the taller heroes that is most of a screen of scrolling with the bar
30  // still transparent over content it no longer sits on.
31  //
32  // The trigger is the header's own height instead, so the bar turns into the
33  // scrolled pill the moment the page moves out from under it, on every page.
34  // A hysteresis band keeps it from flickering when a scroll lands exactly on
35  // the threshold, which is easy to hit with momentum scrolling.
36  if (header) {
37    var ON = 64, OFF = 48, ticking = false;
38    var syncFixed = function(){
39      ticking = false;
40      var y = window.pageYOffset || document.documentElement.scrollTop || 0;
41      var on = header.classList.contains('fixed');
42      if (!on && y > ON) header.classList.add('fixed');
43      else if (on && y < OFF) header.classList.remove('fixed');
44    };
45    var onScroll = function(){
46      if (ticking) return;
47      ticking = true;
48      requestAnimationFrame(syncFixed);
49    };
50    addEventListener('scroll', onScroll, { passive: true });
51    // A reload can restore a scroll offset before this runs.
52    syncFixed();
53  }
54
55  // ps-toggle.js: the burger controls #header, and the CSS keys the overlay off
56  // is-active. Roles the custom element used to supply are set here too.
57  var DESKTOP = '(min-width: 1025px)';
58  var burger = document.querySelector('.header-menu-toggler');
59  var focusWasOnBurger = false;
60  var closeOverlay = function(){};
61  if (burger && header) {
62    if (!burger.hasAttribute('role')) burger.setAttribute('role', 'button');
63    if (!burger.hasAttribute('tabindex')) burger.setAttribute('tabindex', '0');
64    burger.setAttribute('aria-expanded', 'false');
65
66    // The overlay covers the page opaquely, so the rest of the document is made
67    // inert while it is open. Without that, tabbing past the last nav link
68    // walks into content nobody can see and the focus ring vanishes.
69    // Everything outside the header, including the skip link that sits before
70    // it — Shift+Tab off the first nav item landed on that link behind the
71    // overlay, so containment was never actually closed.
72    var behind = [
73      document.getElementById('main'),
74      document.querySelector('body>div>footer'),
75      document.querySelector('a.visually-hidden-focusable'),
76    ].filter(Boolean);
77    var returnTo = null;
78    // The burger is a sibling of ps-header-nav, so the nav's own focus tracking
79    // never sees it. Crossing up to desktop hides it, and hiding an element
80    // blurs it, so this is the only record that the keyboard position was there.
81    burger.addEventListener('focusin', function(){ focusWasOnBurger = true; });
82    burger.addEventListener('focusout', function(e){
83      if (e.relatedTarget) focusWasOnBurger = false;
84    });
85    var setOverlay = function(open){
86      if (open) returnTo = document.activeElement;
87      header.classList.toggle('is-active', open);
88      burger.setAttribute('aria-expanded', String(open));
89      behind.forEach(function(el){
90        if (!el) return;
91        if (open) { el.setAttribute('inert', ''); el.setAttribute('aria-hidden', 'true'); }
92        else { el.removeAttribute('inert'); el.removeAttribute('aria-hidden'); }
93      });
94      if (!open && returnTo && returnTo.focus) { returnTo.focus(); returnTo = null; }
95      // returnTo is whatever was focused when the overlay opened, and a click
96      // never focuses a button on macOS — so it is usually the body, and
97      // body.focus() does nothing. Escape from a link inside the overlay then
98      // left focus on an element the mobile rules had just hidden. The burger
99      // is the only way back in, so it takes focus when nothing visible has it.
100      // On the desktop crossing the burger is display:none and this is a no-op,
101      // which is what that path wants.
102      if (
103        !open && burger.focus &&
104        (document.activeElement === document.body ||
105          document.activeElement.closest('ps-header-nav'))
106      ) {
107        burger.focus();
108      }
109    };
110    closeOverlay = function(){
111      if (header.classList.contains('is-active')) setOverlay(false);
112    };
113    burger.addEventListener('click', function(){
114      setOverlay(!header.classList.contains('is-active'));
115    });
116    burger.addEventListener('keydown', function(e){
117      if (e.key === 'Enter' || e.key === ' ') {
118        e.preventDefault();
119        setOverlay(!header.classList.contains('is-active'));
120      } else if (e.key === 'Escape') {
121        closeOverlay();
122      }
123    });
124    // Crossing to the desktop layout hides the burger. The rules that give the
125    // header its full height and lock body scroll are unmediated, so an overlay
126    // left open there survives the breakpoint and traps the page with nothing
127    // visible to dismiss it.
128    var wide = window.matchMedia(DESKTOP);
129    var onWide = function(){ if (wide.matches) closeOverlay(); };
130    // Both, deliberately: matchMedia change is the precise signal, but resize
131    // is the one that fires everywhere. Belt and braces on a state that traps
132    // the page when it is missed.
133    addEventListener('resize', onWide);
134    if (wide.addEventListener) wide.addEventListener('change', onWide);
135    else wide.addListener(onWide);
136  }
137
138  // ps-header-nav.js: one dropdown open at a time, announced honestly, and
139  // scoped to the layout that actually has dropdowns.
140  //
141  // Two separate ideas, which is what makes it behave. is-active is the pinned
142  // state a click sets; aria-expanded is only what the button announces. CSS
143  // (nav.css) also opens a submenu on hover and for as long as .nav-item
144  // matches :focus-within, so the announcement has to follow that too — but if
145  // focus pinned the menu, the click that follows it would read the menu as
146  // already open and close it on the way in.
147  var headerNav = document.querySelector('ps-header-nav');
148  if (headerNav) {
149    var toggles = [].slice.call(headerNav.querySelectorAll('button.nav-menu-toggle[aria-controls]'));
150    var menuOf = function(btn){
151      return document.getElementById(btn.getAttribute('aria-controls')) || btn.parentNode.querySelector('ul');
152    };
153    var announce = function(btn, open){ btn.setAttribute('aria-expanded', String(open)); };
154    var pin = function(btn, open){
155      var menu = menuOf(btn);
156      if (menu) {
157        menu.classList.toggle('is-active', open);
158        // A previous dismissal is inline; opening has to lift it or the class
159        // would be set on a menu still told to stay hidden.
160        if (open) { menu.style.visibility = ''; menu.style.opacity = ''; }
161      }
162      announce(btn, open);
163    };
164    var closeAll = function(){ toggles.forEach(function(btn){ pin(btn, false); }); };
165    /**
166     * Close a menu the pointer or focus is still inside.
167     *
168     * nav.css opens a submenu on :hover and on :focus-within, and it does so
169     * from an unlayered stylesheet — which outranks these overrides whatever
170     * their specificity. So clearing the class is not enough: after a closing
171     * click the pointer is necessarily still over the item and the menu stays
172     * on screen while the button announces it shut. Dropping focus deals with
173     * :focus-within; hover needs the element itself to say hidden, which is why
174     * this reaches for inline styles. Both are cleared the moment the pointer
175     * leaves, so hovering back in behaves normally.
176     */
177    var itemOf = function(btn){
178      return (btn.closest ? btn.closest('.nav-item') : null) || btn.parentNode;
179    };
180    /**
181     * Drop focus from inside the nav, without that reading as the visitor
182     * leaving it.
183     *
184     * blur() dispatches focusout synchronously with a null relatedTarget, which
185     * is indistinguishable from focus going somewhere outside — so the
186     * nav-level handler ran closeShown(), found the menu the pointer had just
187     * entered visible through nav.css, and suppressed it inline. The pointer
188     * was then sitting on a dropdown that had been announced open and hidden in
189     * the same gesture, and it stayed hidden until the pointer left and came
190     * back. The flag is only ever true for the duration of the blur itself.
191     */
192    var ownBlur = false;
193    var dropFocus = function(el){
194      var a = el || document.activeElement;
195      if (!a || !a.blur || !headerNav.contains(a)) return;
196      ownBlur = true;
197      a.blur();
198      ownBlur = false;
199    };
200    var release = function(menu){
201      if (!menu) return;
202      menu.style.visibility = '';
203      menu.style.opacity = '';
204    };
205    /**
206     * Close a menu, suppressing hover only when hover is what is holding it
207     * open.
208     *
209     * The inline hide is lifted on mouseleave, so applying it when the pointer
210     * is elsewhere strands the menu: that mouseleave never comes, and nav.css
211     * cannot override an inline style when focus returns. A keyboard close
212     * therefore just clears state and drops focus, which is enough — nothing is
213     * holding the menu open once :focus-within stops matching.
214     */
215    var dismiss = function(btn){
216      var menu = menuOf(btn);
217      var item = itemOf(btn);
218      pin(btn, false);
219      if (!menu || !item.matches(':hover')) return;
220      menu.style.visibility = 'hidden';
221      menu.style.opacity = '0';
222      // once: true so repeated dismissals cannot pile listeners onto the item.
223      item.addEventListener('mouseleave', function(){ release(menu); }, { once: true });
224    };
225    /**
226     * Close everything, suppressing hover only where it is actually holding a
227     * menu open.
228     *
229     * dismiss() works by pinning visibility:hidden inline and lifting it on the
230     * next mouseleave. Applied to a menu the pointer never entered, that
231     * mouseleave may never come — so the menu stays hidden until someone
232     * hovers and leaves it, unreachable by hover or keyboard in the meantime.
233     * Below the breakpoint it is worse: the overlay shows its submenus inline,
234     * so hiding them all would empty the navigation.
235     */
236    var closeShown = function(){
237      if (!desktop.matches) return;
238      toggles.forEach(function(btn){
239        var menu = menuOf(btn);
240        var shown = !!menu && (
241          menu.classList.contains('is-active') ||
242          getComputedStyle(menu).visibility === 'visible'
243        );
244        if (shown) dismiss(btn); else pin(btn, false);
245      });
246      dropFocus();
247    };
248    // Was focus inside the nav, and did it go away on its own rather than being
249    // moved somewhere else? A focusout carrying a relatedTarget is the visitor
250    // tabbing on; a null one is the element being hidden or otherwise losing
251    // focus, which is the case worth rescuing at a breakpoint crossing.
252    var focusWasInNav = false;
253    headerNav.addEventListener('focusin', function(){ focusWasInNav = true; });
254    headerNav.addEventListener('focusout', function(e){
255      if (e.relatedTarget && !headerNav.contains(e.relatedTarget)) focusWasInNav = false;
256    });
257    var navFocusLost = function(){
258      var a = document.activeElement;
259      return focusWasInNav && (!a || a === document.body);
260    };
261
262    // Below the breakpoint the theme leaves every submenu on screen inside the
263    // overlay, so there is nothing to expand or collapse: toggling there only
264    // told assistive technology the links were hidden while they were plainly
265    // listed. The buttons are left alone until the dropdown layout applies.
266    var desktop = window.matchMedia(DESKTOP);
267    // Only at a real crossing: wired to resize as well as to the media query —
268    // the same belt and braces the overlay uses, since a missed crossing here
269    // leaves the announcements describing the other layout — and resize fires
270    // continuously during a window drag, where re-running this would shut a
271    // dropdown the visitor had just opened.
272    var lastScope = null;
273    var applyScope = function(){
274      if (desktop.matches === lastScope) return;
275      lastScope = desktop.matches;
276      if (desktop.matches) {
277        toggles.forEach(function(btn){ pin(btn, false); });
278        // Crossing up from the overlay can leave focus on a nested link that
279        // only the mobile layout listed. The burger is display:none by now, so
280        // the overlay's own focus return has nowhere to go, and nav.css holds
281        // that group open through :focus-within while the loop above has just
282        // announced every button collapsed. Hand focus to the group's toggle —
283        // visible at this width — and let it own the menu it is already
284        // showing.
285        var stranded = document.activeElement;
286        var owner = null;
287        toggles.forEach(function(btn){
288          if (stranded && itemOf(btn).contains(stranded)) owner = btn;
289        });
290        if (owner) {
291          owner.focus();
292          pin(owner, true);
293        } else if (
294          toggles.length &&
295          focusWasOnBurger &&
296          (!stranded || stranded === document.body || stranded === burger)
297        ) {
298          // The burger itself was holding focus and has just been hidden. There
299          // is no group to hand back to, so the first dropdown toggle takes it —
300          // the nearest visible equivalent at this width. Announced state is
301          // left alone: focusing it opens nothing that was not already open.
302          toggles[0].focus();
303        }
304        focusWasOnBurger = false;
305      } else {
306        closeAll();
307        toggles.forEach(function(btn){ btn.removeAttribute('aria-expanded'); });
308        // The mobile rules hide ps-header-nav outright, so a keyboard position
309        // inside it does not survive the crossing. Testing activeElement is not
310        // enough: hiding an element blurs it, so by the time this runs focus
311        // has usually been dropped on the body rather than left on something
312        // invisible. navFocusLost is what distinguishes that from focus the
313        // visitor moved out of the nav themselves — only the first should be
314        // rescued, and the burger is what replaces the nav at this width.
315        if (
316          burger && burger.focus &&
317          (headerNav.contains(document.activeElement) || navFocusLost())
318        ) {
319          burger.focus();
320        }
321        focusWasInNav = false;
322      }
323    };
324    applyScope();
325    (desktop.addEventListener ? desktop.addEventListener.bind(desktop, 'change') : desktop.addListener.bind(desktop))(applyScope);
326    addEventListener('resize', applyScope);
327
328    toggles.forEach(function(btn){
329      var item = btn.closest ? btn.closest('.nav-item') : btn.parentNode;
330      if (!item) item = btn.parentNode;
331      btn.addEventListener('click', function(){
332        if (!desktop.matches) return;
333        var menu = menuOf(btn);
334        var open = !!menu && menu.classList.contains('is-active');
335        closeAll();
336        if (open) { dismiss(btn); dropFocus(btn); } else pin(btn, true);
337      });
338      // The announcement follows the group, not the button: focus moving from
339      // the button into a link inside its own menu keeps :focus-within
340      // matching, so the menu is still open.
341      // nav.css opens the menu on :hover with no script involved, so the
342      // announcement has to follow the pointer as well as focus — otherwise a
343      // hovered menu sits open while its button reports itself collapsed.
344      item.addEventListener('mouseenter', function(){
345        if (!desktop.matches) return;
346        // One at a time applies to the pointer as well: a menu pinned by an
347        // earlier click keeps its class while nav.css opens this one on hover,
348        // which is two menus open at once. Focus has to go with it — clearing
349        // the class alone leaves :focus-within showing a menu whose button now
350        // says collapsed, which is the same contradiction one step over.
351        toggles.forEach(function(other){
352          if (other === btn) return;
353          var otherItem = itemOf(other);
354          if (otherItem.contains(document.activeElement)) dropFocus();
355          pin(other, false);
356        });
357        announce(btn, true);
358      });
359      item.addEventListener('mouseleave', function(){
360        if (!desktop.matches) return;
361        var menu = menuOf(btn);
362        announce(
363          btn,
364          (!!menu && menu.classList.contains('is-active')) ||
365            item.contains(document.activeElement),
366        );
367      });
368      item.addEventListener('focusin', function(){
369        if (!desktop.matches) return;
370        // Focus is a legitimate way to open this menu, so any suppression left
371        // from an earlier close has to go or nav.css cannot reveal it.
372        release(menuOf(btn));
373        // Focus arriving here means CSS has opened this group. Any other group
374        // still pinned from a click would stay open beside it — nav.css shows a
375        // pinned submenu regardless of focus — so one dropdown at a time has to
376        // be enforced on focus as well as on click. dismiss() rather than pin(),
377        // because the pointer may still be resting on the group that was
378        // clicked: unpinning it leaves :hover showing a menu whose button has
379        // just been told to announce itself shut.
380        toggles.forEach(function(other){ if (other !== btn) dismiss(other); });
381        announce(btn, true);
382      });
383      item.addEventListener('focusout', function(e){
384        if (!desktop.matches || item.contains(e.relatedTarget)) return;
385        var menu = menuOf(btn);
386        // Hover counts here for the same reason focus counts in mouseleave:
387        // tabbing from the last link of a hovered menu to a plain nav link
388        // leaves the pointer holding that menu open, and the nav-level handler
389        // stays quiet because focus never left the nav. Announcing on the class
390        // alone called a visible menu collapsed until the pointer moved.
391        // Hover only counts while nothing is suppressing the menu. Escape
392        // dismisses a hovered dropdown by pinning visibility:hidden inline and
393        // then drops focus, and that blur arrives here — announcing on hover
394        // alone called the menu open again a moment after it was hidden.
395        announce(
396          btn,
397          (!!menu && menu.classList.contains('is-active')) ||
398            (item.matches(':hover') && !!menu && menu.style.visibility !== 'hidden'),
399        );
400      });
401    });
402
403    // On the header, not the nav: the overlay also contains the logo, which is
404    // focusable and sits outside ps-header-nav. Shift+Tab from the burger lands
405    // there, where Escape reached neither this listener nor the burger's own —
406    // leaving a full-screen overlay open with the body still scroll-locked.
407    (header || headerNav).addEventListener('keydown', function(e){
408      if (e.key !== 'Escape') return;
409      // Suppress hover only for menus that are actually on screen: clearing
410      // the class alone leaves nav.css's :hover rule showing a menu the button
411      // now calls shut, but suppressing every menu strands the ones nobody
412      // touched.
413      closeShown();
414      // Below the breakpoint this is the handler Escape reaches once focus is
415      // in the menu — the toggler never sees it — so the overlay has to be
416      // dismissed from here or it stays open with the body scroll-locked.
417      if (header && header.classList.contains('is-active')) closeOverlay();
418    });
419    headerNav.addEventListener('focusout', function(e){
420      // closeShown(), not closeAll(): focus can leave the nav while the pointer
421      // is still resting on a dropdown, and clearing the class alone leaves
422      // nav.css showing a menu the button has just called collapsed. No
423      // mouseenter follows to correct it, so it stays wrong until the pointer
424      // happens to leave.
425      if (ownBlur) return;
426      if (desktop.matches && !headerNav.contains(e.relatedTarget)) closeShown();
427    });
428  }
429
430  // White-label-app "Try how it works": switch brand colours + visible mockup.
431  var wlContainer = document.querySelector('.hero-white-label-app-container');
432  var wlTabs = document.querySelectorAll('.hero-white-label-app-tabs [role="tab"]');
433  if (wlContainer && wlTabs.length) {
434    var wlPill = document.querySelector('.hero-white-label-app-tabs .selected-button-bg');
435    wlTabs.forEach(function(tab){
436      tab.addEventListener('click', function(){
437        wlContainer.setAttribute('data-company', tab.getAttribute('data-company'));
438        wlTabs.forEach(function(x){
439          var sel = x === tab;
440          x.setAttribute('aria-selected', String(sel));
441          x.setAttribute('tabindex', sel ? '0' : '-1');
442          var panel = document.getElementById(x.getAttribute('aria-controls'));
443          if (panel) panel.classList.toggle('hidden', !sel);
444        });
445        if (wlPill) tab.appendChild(wlPill);
446      });
447    });
448  }
449
450  // ── Carousels (ps-carousel: home "Instant access", driver/business
451  //    testimonials, partner logos wherever this component is reused) ───────
452  // The theme's carousel JS is part of the stripped bundle, so without this
453  // every carousel ships its prev/next/dot buttons permanently disabled and
454  // sits on its first slide with no way to scroll it. CSS already declares
455  // scroll-snap-type/scroll-padding for native snapping; overflow-x is left
456  // off .carousel-slides in the mirrored CSS (the stripped JS presumably set
457  // it), so it's added here too, which also means a no-JS visitor sees a
458  // static first slide rather than a half-working scroller. Dot/arrow clicks
459  // scroll to the target slide; an IntersectionObserver keeps the dots,
460  // arrows and per-slide aria-hidden (used by the dimmed-neighbour styling)
461  // in sync with manual swipes too.
462  document.querySelectorAll('ps-carousel').forEach(function(root){
463    var viewport = root.querySelector('[data-carousel-viewport]');
464    var slidesEl = root.querySelector('[data-carousel-slides]');
465    if (!viewport || !slidesEl) return;
466    var slides = Array.prototype.filter.call(slidesEl.children, function(el){
467      return el.nodeType === 1;
468    });
469    if (slides.length < 2) return;
470    slidesEl.style.overflowX = 'auto';
471    var prevBtn = root.querySelector('[data-carousel-prev]');
472    var nextBtn = root.querySelector('[data-carousel-next]');
473    var dots = Array.prototype.slice.call(root.querySelectorAll('[data-carousel-pagination-item]'));
474    var current = 0;
475
476    function render(){
477      slides.forEach(function(slide, i){
478        if (i === current) slide.removeAttribute('aria-hidden');
479        else slide.setAttribute('aria-hidden', 'true');
480      });
481      dots.forEach(function(dot, i){
482        dot.disabled = false;
483        dot.classList.toggle('active', i === current);
484      });
485      if (prevBtn) prevBtn.disabled = current === 0;
486      if (nextBtn) nextBtn.disabled = current === slides.length - 1;
487    }
488
489    function goTo(index){
490      index = Math.max(0, Math.min(slides.length - 1, index));
491      slides[index].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
492    }
493
494    if (prevBtn) prevBtn.addEventListener('click', function(){ goTo(current - 1); });
495    if (nextBtn) nextBtn.addEventListener('click', function(){ goTo(current + 1); });
496    dots.forEach(function(dot, i){ dot.addEventListener('click', function(){ goTo(i); }); });
497
498    if ('IntersectionObserver' in window) {
499      // Two+ slides can be above any single ratio threshold at once (desktop
500      // shows a 50%-width peek of the next card), so picking "current" off
501      // whichever entry's callback merely crosses a threshold is unreliable
502      // -- it settles on whichever fired last, not the most-visible one.
503      // Track every slide's latest known ratio and always take the max.
504      var ratios = slides.map(function(){ return 0; });
505      var observer = new IntersectionObserver(function(entries){
506        entries.forEach(function(entry){
507          var i = slides.indexOf(entry.target);
508          if (i !== -1) ratios[i] = entry.intersectionRatio;
509        });
510        var best = 0;
511        ratios.forEach(function(r, i){ if (r > ratios[best]) best = i; });
512        if (best !== current) { current = best; render(); }
513      }, { root: viewport, threshold: [0, 0.25, 0.5, 0.6, 0.75, 1] });
514      slides.forEach(function(slide){ observer.observe(slide); });
515    }
516
517    render();
518  });
519})();(function(){
520// ── Driver testimonials carousel ──────────────────────────────────────────
521  // The mirrored <ps-carousel> element was never defined (its script is not
522  // part of the page), so the arrows shipped disabled and nothing moved. The
523  // markup is a scroll-snap track, which already swipes on its own; this adds
524  // what scrolling cannot express by itself -- which card is centred, the
525  // arrow buttons, and the wrap-around.
526  //
527  // Looping is done by cloning the whole set either side of the real one and
528  // silently jumping back by one set width whenever the scroll settles inside
529  // a clone. The clones carry aria-hidden so the quotes are announced once,
530  // and they are added here rather than in the markup so that without this
531  // script the track is still a plain, working snap carousel of four cards.
532  var testi = document.querySelector('.ps-testi');
533  var track = testi && testi.querySelector('[data-testi-track]');
534  if (testi && track) {
535    var real = [].slice.call(track.querySelectorAll('.ps-testi-card'));
536    var n = real.length;
537    var prev = testi.querySelector('[data-testi-prev]');
538    var next = testi.querySelector('[data-testi-next]');
539    var loop = n > 1;
540    var cards = real;
541
542    if (loop) {
543      var head = document.createDocumentFragment();
544      var tail = document.createDocumentFragment();
545      real.forEach(function (card) {
546        var a = card.cloneNode(true);
547        var b = card.cloneNode(true);
548        a.setAttribute('aria-hidden', 'true');
549        b.setAttribute('aria-hidden', 'true');
550        a.setAttribute('data-testi-clone', '');
551        b.setAttribute('data-testi-clone', '');
552        head.appendChild(a);
553        tail.appendChild(b);
554      });
555      track.insertBefore(head, real[0]);
556      track.appendChild(tail);
557      cards = [].slice.call(track.querySelectorAll('.ps-testi-card'));
558    }
559
560    // Off-centre cards only dim once we can tell which one is centred.
561    testi.setAttribute('data-testi-ready', '');
562
563    // offsetLeft is measured from the offsetParent, which here is the body --
564    // not the track. Mixing that with track.scrollLeft is wrong by however far
565    // the section sits from the page's left edge, which is 24px at 1440 but
566    // 520px once the layout hits its max-width and starts centring. Snap hides
567    // the error while it is under half a card; past that it picks the wrong
568    // card and the active one lands well left of centre. So measure every
569    // position against the track itself.
570    var posIn = function (card) {
571      return card.getBoundingClientRect().left - track.getBoundingClientRect().left + track.scrollLeft;
572    };
573    var offsetFor = function (card) {
574      return posIn(card) - (track.clientWidth - card.offsetWidth) / 2;
575    };
576    var jumpTo = function (left) {
577      var was = track.style.scrollBehavior;
578      track.style.scrollBehavior = 'auto';
579      track.scrollLeft = left;
580      track.style.scrollBehavior = was;
581    };
582
583    var current = loop ? n : 0;
584    var setWidth = 0;
585
586    var nearest = function () {
587      var mid = track.scrollLeft + track.clientWidth / 2;
588      var best = 0;
589      var bestDist = Infinity;
590      cards.forEach(function (card, i) {
591        var dist = Math.abs(posIn(card) + card.offsetWidth / 2 - mid);
592        if (dist < bestDist) { bestDist = dist; best = i; }
593      });
594      return best;
595    };
596
597    var mark = function () {
598      cards.forEach(function (card, i) { card.classList.toggle('is-active', i === current); });
599      if (!loop) {
600        if (prev) prev.disabled = current === 0;
601        if (next) next.disabled = current === cards.length - 1;
602      }
603    };
604    var sync = function () {
605      current = nearest();
606      mark();
607    };
608
609    // Highlight the target as the click lands rather than waiting for the
610    // scroll handler, so the dimming leads the animation instead of trailing it.
611    // Recentre onto the identical real card whenever we are sitting in a clone
612    // set. Same pixels on screen, so the correction is invisible; it just puts
613    // the runway back on both sides.
614    var normalise = function () {
615      if (!loop || !setWidth) return;
616      if (current < n) { jumpTo(track.scrollLeft + setWidth); current += n; }
617      else if (current >= n * 2) { jumpTo(track.scrollLeft - setWidth); current -= n; }
618      else return;
619      mark();
620    };
621
622    var step = function (delta) {
623      // Normalise first: from anywhere in the real set there is always a card
624      // to step onto, so the arrows never run out of track no matter how fast
625      // they are clicked -- they do not have to wait for a scroll event.
626      normalise();
627      var card = cards[current + delta];
628      if (!card) return;
629      current += delta;
630      mark();
631      track.scrollTo({ left: offsetFor(card), behavior: 'smooth' });
632    };
633    if (prev) prev.addEventListener('click', function () { step(-1); });
634    if (next) next.addEventListener('click', function () { step(1); });
635
636    var raf = null;
637    var settle = null;
638    track.addEventListener('scroll', function () {
639      if (!raf) raf = requestAnimationFrame(function () { raf = null; sync(); });
640      // scrollend is not everywhere yet, so fall back to a quiet period.
641      clearTimeout(settle);
642      settle = setTimeout(normalise, 140);
643    }, { passive: true });
644    if ('onscrollend' in window) {
645      track.addEventListener('scrollend', function () { clearTimeout(settle); normalise(); });
646    }
647
648    var measure = function () {
649      if (loop) setWidth = posIn(cards[n]) - posIn(cards[0]);
650      jumpTo(offsetFor(cards[loop ? n : 0]));
651      sync();
652    };
653    window.addEventListener('resize', measure);
654    measure();
655    // Fonts landing late change how the quotes wrap, which moves every card.
656    if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure);
657  }
658
659
660  // ── Charging-card glow ────────────────────────────────────────────────────
661  // The glow animates on the compositor, but an infinite animation still keeps
662  // its layer alive for the whole visit, and this section sits a long way down
663  // the page. Run it only while the section is actually on screen.
664  var glowSection = document.querySelector('section.charging-card');
665  if (glowSection && glowSection.querySelector('.charging-card-art')) {
666    if ('IntersectionObserver' in window) {
667      new IntersectionObserver(function (entries) {
668        entries.forEach(function (e) { glowSection.classList.toggle('is-glowing', e.isIntersecting); });
669      }, { rootMargin: '120px' }).observe(glowSection);
670    } else {
671      glowSection.classList.add('is-glowing');
672    }
673  }
674})();

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.