PageSourceSearch

https://www.sonovate.com/wp-content/themes/sonovate/dist/sonovate2026/theme.js?ver=1790326069

js sonovate.com collected 2026-10-03 23:28:09 UTC 38,744 bytes, 892 lines download raw bytes

1/* Sonovate 2026 template layer, homepage interactions.
2   Only runs on pages rendered by the 2026 layout ([data-snv-2026]). */
3(function () {
4  'use strict';
5  if (!document.querySelector('[data-snv-2026]')) return;
6
7  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
8
9  /* ---------- 1. Nav shell scroll state ---------- */
10  const navShell = document.querySelector('.nav-shell');
11  const onScroll = () => {
12    if (!navShell) return;
13    if (window.scrollY > 12) navShell.classList.add('is-scrolled');
14    else navShell.classList.remove('is-scrolled');
15  };
16  window.addEventListener('scroll', onScroll, { passive: true });
17  onScroll();
18
19  /* ---------- 2. Nav dropdowns, mega + regular panels ---------- */
20
21  // Collect all triggers + panels
22  const allTriggers = [
23    ...document.querySelectorAll('[data-mega-trigger]'),
24    ...document.querySelectorAll('[data-dropdown-trigger]'),
25  ];
26  const allPanels = [
27    ...document.querySelectorAll('[data-mega-panel]'),
28    ...document.querySelectorAll('[data-dropdown-panel]'),
29  ];
30
31  let openTimeout;
32  let closeTimeout;
33
34  function closeAll(exceptPanel) {
35    allPanels.forEach((p) => {
36      if (p === exceptPanel) return;
37      p.classList.remove('is-open');
38    });
39    allTriggers.forEach((t) => {
40      const panelId = t.getAttribute('aria-controls');
41      const controlled = document.getElementById(panelId);
42      if (controlled !== exceptPanel) t.setAttribute('aria-expanded', 'false');
43    });
44  }
45
46  function openPanel(trigger, panel) {
47    clearTimeout(closeTimeout);
48    closeAll(panel);
49    // For simple dropdowns: position under the trigger
50    if (panel.classList.contains('dropdown-panel')) {
51      const rect = trigger.getBoundingClientRect();
52      let left = rect.left;
53      // Clamp so it doesn't overflow viewport
54      const panelW = 300;
55      const maxLeft = window.innerWidth - panelW - 16;
56      left = Math.min(left, maxLeft);
57      panel.style.left = left + 'px';
58    }
59    panel.classList.add('is-open');
60    trigger.setAttribute('aria-expanded', 'true');
61  }
62
63  function closePanel(trigger, panel) {
64    closeTimeout = setTimeout(() => {
65      panel.classList.remove('is-open');
66      trigger.setAttribute('aria-expanded', 'false');
67    }, 120);
68  }
69
70  allTriggers.forEach((trigger) => {
71    const panelId = trigger.getAttribute('aria-controls');
72    const panel = document.getElementById(panelId);
73    if (!panel) return;
74
75    [trigger, panel].forEach((el) => {
76      el.addEventListener('mouseenter', () =>
76 openPanel(trigger, panel));
77      el.addEventListener('mouseleave', () => closePanel(trigger, panel));
78    });
79    trigger.addEventListener('focus', () => openPanel(trigger, panel));
80    trigger.addEventListener('click', (e) => {
81      e.preventDefault();
82      // Hover and focus may already have opened it before the click.
83      openPanel(trigger, panel);
84    });
85  });
86
87  document.addEventListener('keydown', (e) => {
88    if (e.key === 'Escape') closeAll(null);
89  });
90
91  // Close when clicking outside
92  document.addEventListener('click', (e) => {
93    if (!e.target.closest('[data-mega-trigger], [data-dropdown-trigger], [data-mega-panel], [data-dropdown-panel]')) {
94      closeAll(null);
95    }
96  });
97
98  /* ---------- 3. Mobile drawer ---------- */
99  const mobileToggle = document.querySelector('[data-mobile-toggle]');
100  const mobileDrawer = document.querySelector('[data-mobile-drawer]');
101
102  if (mobileToggle && mobileDrawer) {
103    mobileToggle.addEventListener('click', () => {
104      const open = mobileDrawer.classList.toggle('is-open');
105      mobileToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
106      document.body.style.overflow = open ? 'hidden' : '';
107    });
108    function closeMobileDrawer() {
109      mobileDrawer.classList.remove('is-open');
110      mobileToggle.setAttribute('aria-expanded', 'false');
111      document.body.style.overflow = '';
112    }
113    document.addEventListener('keydown', (event) => {
114      if (event.key === 'Escape' && mobileDrawer.classList.contains('is-open')) {
115        closeMobileDrawer();
116        mobileToggle.focus();
117      }
118    });
119    mobileDrawer.addEventListener('click', (event) => {
120      if (event.target.closest('a')) closeMobileDrawer();
121    });
122    window.addEventListener('resize', () => {
123      if (window.innerWidth >= 1024) closeMobileDrawer();
124    });
125  }
126
127  /* ---------- 4. Pillar tab switcher ---------- */
128  const pillarTabs = Array.from(document.querySelectorAll('[data-pillar-tab]'));
129  const pillarPanels = document.querySelectorAll('[data-pillar-panel]');
130  const pillarTabList = document.querySelector('.pillar-tabs[role="tablist"]');
131  const pillarVerticalQuery = window.matchMedia('(min-width: 1024px)');
132
133  function scrollTabInline(tab) {
134    const scroller = tab && tab.parentElement;
135    if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return;
136
137    const targetLeft = tab.offsetLeft - ((scroller.clientWidth - tab.offsetWidth) / 2);
138    scroller.scrollTo({
139      left: Math.max(0, targetLeft),
140      behavior: prefersReducedMotion ? 'auto' : 'smooth',
141    });
142  }
143
144  function setPillarOrientation() {
145    if (!pillarTabList) return;
146    pillarTabList.setAttribute('aria-orientation', pillarVerticalQuery.matches ? 'vertical' : 'horizontal');
147  }
148  setPillarOrientation();
149  if (pillarVerticalQuery.addEventListener) {
150    pillarVerticalQuery.addEventListener('change', setPillarOrientation);
151  }
152
153  // No auto-rotation: this section teaches vocabulary, so the visitor
154  // controls the pace (WCAG 2.2.2) rather than content moving on its own.
155  function activatePillar(key, options = {}) {
156    pillarTabs.forEach((tab) => {
157      const isMatch = tab.dataset.pillarTab === key;
158      tab.setAttribute('aria-selected', isMatch ? 'true' : 'false');
159      tab.setAttribute('tabindex', isMatch ? '0' : '-1');
160      if (isMatch && options.scrollTab) {
161        scrollTabInline(tab);
162      }
163      if (isMatch && options.focusTab) {
164        tab.focus();
165      }
166    });
167    pillarPanels.forEach((panel) => {
168      const isMatch = panel.dataset.pillarPanel === key;
169      panel.classList.toggle('is-active', isMatch);
170    });
171  }
172
173  pillarTabs.forEach((tab) => {
174    tab.addEventListener('click', () => {
175      activatePillar(tab.dataset.pillarTab, { scrollTab: true });
176    });
177    tab.addEventListener('keydown', (e) => {
178      const isVertical = pillarVerticalQuery.matches;
179      const isNextKey = e.key === 'ArrowRight' || (isVertical && e.key === 'ArrowDown');
180      const isPrevKey = e.key === 'ArrowLeft' || (isVertical && e.key === 'ArrowUp');
181
182      if (isNextKey) {
183        e.preventDefault();
184        const next = tab.nextElementSibling || pillarTabs[0];
185        activatePillar(next.dataset.pillarTab, { scrollTab: true, focusTab: true });
186      } else if (isPrevKey) {
187        e.preventDefault();
188        const prev = tab.previousElementSibling || pillarTabs[pillarTabs.length - 1];
189        activatePillar(prev.dataset.pillarTab, { scrollTab: true, focusTab: true });
190      } else if (e.key === 'Home') {
191        e.preventDefault();
192        activatePillar(pillarTabs[0].dataset.pillarTab, { scrollTab: true, focusTab: true });
193      } else if (e.key === 'End') {
194        e.preventDefault();
195        activatePillar(pillarTabs[pillarTabs.length - 1].dataset.pillarTab, { scrollTab: true, focusTab: true });
196      }
197    });
198  });
199
200  /* ---------- 5. Buyer tab switcher ---------- */
201  const buyerSection = document.getElementById('buyers');
202  const buyerTabs = Array.from(document.querySelectorAll('[data-buyer-tab]'));
203  const buyerContents = document.querySelectorAll('[data-buyer-content]');
204  let buyerAutoTimer = null;
205  let buyerAutoStarted = false;
206  let buyerAutoStopped = false;
207
208  function activateBuyer(key, options = {}) {
209    buyerTabs.forEach((tab) => {
210      const isMatch = tab.dataset.buyerTab === key;
211      tab.setAttribute('aria-selected', isMatch ? 'true' : 'false');
212      if (isMatch && options.scrollTab) {
213        scrollTabInline(tab);
214      }
215    });
216    buyerContents.forEach((content) => {
217      const isMatch = content.dataset.buyerContent === key;
218      content.classList.toggle('is-active', isMatch);
219    });
220  }
221
222  function stopBuyerAutoCycle() {
223    buyerAutoStopped = true;
224    if (buyerAutoTimer) {
225      clearInterval(buyerAutoTimer);
226      buyerAutoTimer = null;
227    }
228  }
229
230  function startBuyerAutoCycle() {
231    if (!buyerTabs.length || buyerAutoStarted || buyerAutoStopped || prefersReducedMotion) return;
232
233    buyerAutoStarted = true;
234    let activeIndex = 0;
235    activateBuyer(buyerTabs[activeIndex].dataset.buyerTab, { scrollTab: true });
236
237    buyerAutoTimer = setInterval(() => {
238      if (document.hidden || buyerAutoStopped) return;
239      activeIndex = (activeIndex + 1) % buyerTabs.length;
240      activateBuyer(buyerTabs[activeIndex].dataset.buyerTab, { scrollTab: true });
241    }, 3600);
242  }
243
244  buyerTabs.forEach((tab) => {
245    tab.addEventListener('click', () => {
246      stopBuyerAutoCycle();
247      activateBuyer(tab.dataset.buyerTab, { scrollTab: true });
248    });
249    tab.addEventListener('keydown', (e) => {
250      if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
251        e.preventDefault();
252        stopBuyerAutoCycle();
253        const next = tab.nextElementSibling || buyerTabs[0];
254        next.focus();
255        activateBuyer(next.dataset.buyerTab, { scrollTab: true });
256      } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
257        e.preventDefault();
258        stopBuyerAutoCycle();
259        const prev = tab.previousElementSibling || buyerTabs[buyerTabs.length - 1];
260        prev.focus();
261        activateBuyer(prev.dataset.buyerTab, { scrollTab: true });
262      }
263    });
264  });
265
266  if (buyerSection && buyerTabs.length) {
267    if ('IntersectionObserver' in window) {
268      const buyerObserver = new IntersectionObserver((entries) => {
269        entries.forEach((entry) => {
270          if (entry.isIntersecting) {
271            startBuyerAutoCycle();
272            buyerObserver.unobserve(entry.target);
273          }
274        });
275      }, { threshold: 0.35 });
276      buyerObserver.observe(buyerSection);
277    } else {
278      startBuyerAutoCycle();
279    }
280  }
281
282  /* ---------- 6. Scroll reveal ---------- */
283  if ('IntersectionObserver' in window && !prefersReducedMotion) {
284    const reveals = document.querySelectorAll('.reveal');
285    const io = new IntersectionObserver(
286      (entries) => {
287        entries.forEach((entry) => {
288          if (entry.isIntersecting) {
289            entry.target.classList.add('is-visible');
290            io.unobserve(entry.target);
291          }
292        });
293      },
294      { rootMargin: '-40px 0px -40px 0px', threshold: 0.04 }
295    );
296    reveals.forEach((el) => io.observe(el));
297  } else {
298    document.querySelectorAll('.reveal').forEach((el) => el.classList.add('is-visible'));
299  }
300
301  /* ---------- 7. Animated stat counters ---------- */
302  function animateCount(el, target, suffix = '', duration = 1600) {
303    // Keep the approved server-rendered value when animation cannot run.
304    const finalText = el.hasAttribute('data-prefix') ? el.textContent : formatNum(target) + suffix;
305    if (!Number.isFinite(target)) return;
306    if (prefersReducedMotion) { el.textContent = finalText; return; }
307    const prefix = el.dataset.prefix || '';
308    const decimals = (String(target).split('.')[1] || '').length;
309    const start = performance.now();
310    function tick(now) {
311      const t = Math.min((now - start) / duration, 1);
312      const eased = 1 - Math.pow(1 - t, 4);
313      const v = Number((target * eased).toFixed(decimals));
314      el.textContent = t === 1 ? finalText : prefix + formatNum(v) + suffix;
315      if (t < 1) requestAnimationFrame(tick);
316    }
317    requestAnimationFrame(tick);
318  }
319  function formatNum(n) {
320    return n.toLocaleString('en-GB');
321  }
322
323  if ('IntersectionObserver' in window) {
324    const counters = document.querySelectorAll('[data-counter]');
325    const countObserver = new IntersectionObserver(
326      (entries) => {
327        entries.forEach((entry) => {
328          if (entry.isIntersecting) {
329            const el = entry.target;
330            animateCount(el, Number(el.dataset.counter), el.dataset.suffix || '');
331            countObserver.unobserve(el);
332          }
333        });
334      },
335      { threshold: 0.4 }
336    );
337    counters.forEach((c) => countObserver.observe(c));
338  }
339
340  /* ---------- 8. Hero run-step cycling ---------- */
341  const runSteps = Array.from(document.querySelectorAll('[data-run-step]'));
342  if (runSteps.length) {
343    const STEP_DURATION = 2400; // ms each step stays active
344    // Start on a random step
345    let activeIndex = Math.floor(Math.random() * runSteps.length);
346
347    function applyStepStates(active) {
348      runSteps.forEach((step, i) => {
349        step.classList.remove('run-step--done', 'run-step--active', 'run-step--pending');
350        if (i < active)       step.classList.add('run-step--done');
351        else if (i === active) step.classList.add('run-step--active');
352        else                   step.classList.add('run-step--pending');
353      });
354    }
355
356    // Wait for the initial step-in animations to finish before taking over
357    const initDelay = prefersReducedMotion ? 0 : 3000;
358    setTimeout(() => {
359      applyStepStates(activeIndex);
360      setInterval(() => {
361        activeIndex = (activeIndex + 1) % runSteps.length;
362        applyStepStates(activeIndex);
363      }, STEP_DURATION);
364    }, initDelay);
365  }
366
367  /* ---------- 9. Hero peek subtle parallax ---------- */
368  const peek = document.querySelector('[data-peek]');
369  if (peek && !prefersReducedMotion && window.matchMedia('(pointer: fine)').matches) {
370    const parent = peek.parentElement;
371    parent.addEventListener('mousemove', (e) => {
372      const rect = parent.getBoundingClientRect();
373      const x = (e.clientX - rect.left) / rect.width - 0.5;
374      const y = (e.clientY - rect.top) / rect.height - 0.5;
375      peek.style.transform = `perspective(1200px) rotateY(${x * 3}deg) rotateX(${y * -3}deg)`;
376    });
377    parent.addEventListener('mouseleave', () => {
378      peek.style.transform = '';
379    });
380  }
381
382/* ── Ask Sonovate: guided answers ──
383   Guided-first by design. The chips are the primary interface and every answer
384   offers follow-ups, so a visitor can drill down without ever needing to guess
385   what the widget knows. Answers are instant: no fake typing, because we are
386   not pretending to think.
387   Free text still works. When it matches a topic you get the answer; when it
388   does not, the fallback captures the question and an email instead of
389   apologising, and every question asked is logged so we learn what to write
390   next. */
391(function () {
392  'use strict';
393
394  var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
395
396  var SNV = Object.assign({
397    funded: '\u00A38bn+', payments: '800,000+', customers: '3,300+',
398    countries: '44', backer: 'Backed by DZ Bank'
399  }, (typeof SNV_PROOF !== 'undefined' ? SNV_PROOF : {}));
400
401  var TOPICS = {
402    fund: {
403      q: "How does invoice funding work?",
404      next: ["speed", "pricing", "backing"],
405      a: `Sonovate's invoice funding works differently to a traditional facility. When you raise an invoice for a placed worker, capital is available the same day. You do not wait for your client to settle.\n\nThe capital sits on an institutional balance sheet backed by DZ Bank, so it grows with your book rather than being reviewed when market conditions change. We cover contract and temp placements through invoice funding, permanent placements through permanent funding, and all placement types through total funding.`
406    },
407    speed: {
408      q: "How quickly are invoices funded?",
409      next: ["fund", "pay", "start"],
410      a: `Invoices are typically available for funding the same day they're raised, there's no waiting for a credit line to be reviewed or a facility to be refreshed. The capital is on our balance sheet and moves as your invoices move.\n\nOnce funded, worker payments queue automatically and clear on the next payment run.`
411    },
412    pricing: {
413      q: "How does pricing work?",
414      next: ["fund", "start", "platform"],
415      a: `Sonovate doesn't publish pricing publicly. It's configured around each customer's model, placement types and volume, there are no standard tiers or per-seat fees.\
415n\nThe commercial conversation starts with understanding what you're funding and how your business is structured. Our team can give you a realistic picture for your book specifically. The best next step is a quick conversation, sonovate.com/contact.`
416    },
417    backing: {
418      q: "Who backs your capital?",
419      next: ["fund", "protect", "platform"],
420      a: `Sonovate's capital is backed by DZ Bank, Germany's second largest bank and one of the world's highest-rated financial institutions, alongside other institutional partners.\n\nThis matters because it means the funding line is built on a stable balance sheet, not a lender who adjusts terms when credit conditions tighten. The capital is designed to grow with your book, not be reviewed against it.`
421    },
422    pay: {
423      q: "How do worker payments work?",
424      next: ["operate", "countries", "speed"],
425      a: `The Pay pillar handles payroll, payments and transactions for the workers you place. Once an invoice is funded, worker payments queue automatically, with no manual reconciliation, no separate payroll system.\n\nPay supports umbrella, PAYE, limited company and self-employed models across multiple currencies and ${SNV.countries} countries.`
426    },
427    operate: {
428      q: "What does the back office cover?",
429      next: ["pay", "skills", "api"],
430      a: `Operate is Sonovate's back office layer: timesheets, invoicing, assignments, and the workflow that connects them. It captures approved time, generates invoices automatically, and feeds them into Fund without manual intervention.\n\nMost customers find this eliminates significant back-office admin and frees their team to focus on placements rather than paperwork.`
431    },
432    protect: {
433      q: "How do you handle risk?",
434      next: ["fund", "backing", "operate"],
435      a: `Protect covers credit, compliance, risk monitoring and audit controls. It includes real-time debtor credit monitoring, IR35 compliance checks, right-to-work verification, and a full audit trail on every transaction.\n\nCritically, Protect runs inside the same platform as Fund and Pay, so risk assessment happens before capital is committed, not as a separate process.`
436    },
437    scale: {
438      q: "Can you support us abroad?",
439      next: ["countries", "pay", "api"],
440      a: `Scale supports international expansion, new markets, currencies, local compliance and non-standard payment models. Sonovate currently operates in ${SNV.countries} countries, so if you're placing workers across borders or planning to move into new markets, the infrastructure is already there.`
441    },
442    countries: {
443      q: "What countries do you support?",
444      next: ["scale", "pay", "start"],
445      a: `Sonovate currently supports operations in ${SNV.countries} countries. This covers funding, payments, and compliance requirements across most major markets where UK and European staffing and recruitment businesses operate.\n\nIf you have specific countries in mind, our team can confirm coverage and any local requirements. Reach us at sonovate.com/contact.`
446    },
447    skills: {
448      q: "What is the Skills Marketplace?",
449      next: ["operate", "platform", "start"],
450      a: `The Skills Marketplace is Sonovate's automation layer, configurable automations that run inside Fund, Pay, Operate and Protect, triggered by events that matter in your workflow.\n\nExamples: automatically fund an invoice when it's raised, flag a debtor when their credit rating drops, queue a worker payment the moment funding clears. Skills reduce manual touches without requiring custom development.`
451    },
452    api: {
453      q: "Do you have an API?",
454      next: ["operate", "skills", "start"],
455      a: `Sonovate has an API-first integration layer. The platform connects to Microsoft's ecosystem, Codat for accounting data, Xero, Sage, QuickBooks, and major ATS systems including Bullhorn.\n\nFor platforms wanting to offer Sonovate's funding and payment capabilities inside their own product, there's also an embedded finance API, your brand, your experience, our balance sheet.`
456    },
457    platform: {
458      q: "What does Sonovate actually do?",
459      next: ["fund", "pay", "skills"],
460      a: `Sonovate is a financial platform for workforce businesses, recruitment agencies, staffing platforms and consultancies. It covers five connected capabilities:\n\n· Fund, invoice and business funding backed by institutional capital\n· Pay, payroll and payments for placed workers\n· Operate, timesheets, invoicing and back office workflow\n· Protect, credit, compliance and risk monitoring\n· Scale, international expansion and growth infrastru
460cture\n\nMost customers start with funding and stay because the platform does so much more.`
461    },
462    agency: {
463      q: "Is this built for agencies?",
464      next: ["fund", "operate", "start"],
465      a: `Sonovate was built for recruitment agencies from day one. The model was originally designed around the cash flow challenge agencies know well, placing workers this week, waiting 60–90 days for invoices to settle.\n\nToday the platform supports agencies at every stage: start-ups placing their first contractors, growing agencies building infrastructure, and high-growth firms with complex international operations and multi-million pound funding facilities.`
466    },
467    start: {
468      q: "How do we get started?",
469      next: ["pricing", "platform"],
470      a: `The best starting point is a conversation with the sales team. They'll ask about your placement model, the types of workers you place, your current setup, and what's prompting you to look now.\n\nFrom that they'll show you a platform configuration built around your business, not a generic demo. You can reach them at sonovate.com/contact or by clicking the Sign up button above.`
471    },
472  };
473
474  var START = ["platform", "fund", "speed", "pricing", "countries", "skills"];
475
476  var MATCH = [
477    ['speed',     /how.?quick|how.?fast|same.?day|how.?soon|when.?fund|how.?long/],
478    ['pricing',   /pric|cost|how.?much|fee|rate|charge|tier|package|quote/],
479    ['backing',   /who back|dz.?bank|institutional|investor|balance.?sheet|funded by/],
480    ['countries', /countr|abroad|overseas|international|global|outside the uk|europe|\bus\b/],
481    ['skills',    /skill|automat|digital worker|marketplace|outcome/],
482    ['api',       /api|integrat|developer|codat|xero|sage|bullhorn|embed|connect/],
483    ['protect',   /protect|compli|risk|credit|audit|ir35|right.?to.?work|bad debt|default/],
484    ['operate',   /operat|timesheet|back.?offic|admin|assignment|invoicing|reconcil/],
485    ['pay',       /payroll|payment|\bpay\b|wage|salary|paye|umbrella|worker.?paid/],
486    ['scale',     /scale|expan|new market|currenc|grow/],
487    ['fund',      /invoice|fund|capital|facilit|borrow|advance|cash.?flow/],
488    ['agency',    /agenc|recruit|staffing|consultan/],
489    ['start',     /start|sign.?up|demo|get.?going|onboard|next step|talk to/],
490    ['platform',  /what.?is|what.?do|platform|overview|explain|tell.?me.?about/]
491  ];
492
493  /* Does the input look like a real question? Gibberish and stray keystrokes
494     should not be met with a lead-capture form. */
495  function looksLikeQuestion(q) {
496    var t = String(q).trim();
497    if (t.length < 8) return false;
498    if (!/[aeiou]/i.test(t)) return false;            // keyboard mash
499    var words = t.split(/\s+/).filter(function (w) { return w.length > 1; });
500    return words.length >= 2 || /\?$/.test(t);
501  }
502
503  /* A gentle nudge back into the guided flow, with no email ask. */
504  function nudge() {
505    respond(function () {
506      var b = bubble('ai');
507      revealParagraphs(b, 'I did not catch that. Try one of these, or ask a full question and I will do my best.');
508      followUps(b, START.slice(0, 4), 'Try one of these');
509    });
510  }
511
512  function matchTopic(q) {
513    var t = q.toLowerCase();
514    for (var i = 0; i < MATCH.length; i++) {
515      if (MATCH[i][1].test(t)) return MATCH[i][0];
516    }
517    return null;
518  }
519
520  /* Log every question so we learn what prospects actually ask. */
521  function track(question, topic) {
522    try {
523      window.dataLayer = window.dataLayer || [];
524      window.dataLayer.push({
525        event: 'ask_sonovate',
526        ask_question: String(question).slice(0, 120),
527        ask_topic: topic || 'unmatched',
528        ask_matched: topic ? 'yes' : 'no'
529      });
530    } catch (e) {}
531  }
532
533  var msgs    = document.getElementById('ask-messages');
534  var input   = document.getElementById('ask-input');
535  var sendBtn = document.getElementById('ask-send');
536  var chipRow = document.querySelector('.ask-chips');
537  if (!msgs) return;
538
539  var AI_AVATAR = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="22" height="22" aria-hidden="true"><path d="M22 11a4 4 0 0 0-4-4h-4a4 4 0 0 0 0 8h4a4 4 0 0 1 0 8h-4a4 4 0 0 1-4-4" stroke="#01E1FD" stroke-width="2.5" stroke-linecap="round" fill="none"/></svg>';
540  var USER_AVATAR = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
541
542  /* A short beat before answering. Not fake composition: the answers are
543     pre-written and we say so. This is pacing, so the reader registers that
544     something happened and has a moment to move from clicking to reading. */
545  function thinking() {
546    var wrap = document.createElement('div');
547    wrap.className = 'ask-msg ask-msg--ai';
548    var av = document.createElement('div');
549    av.className = 'ask-avatar';
550    av.innerHTML = AI_AVATAR;
551    var b = document.createElement('div');
552    b.className = 'ask-bubble ask-typing';
553    b.innerHTML = '<span></span><span></span><span></span>';
554    b.setAttribute('aria-label', 'Finding an answer');
555    wrap.appendChild(av);
556    wrap.appendChild(b);
557    msgs.appendChild(wrap);
558    msgs.scrollTop = msgs.scrollHeight;
559    return wrap;
560  }
561
562  /* Reveal paragraphs with a light stagger, so a long answer lands in
563     readable beats rather than as one wall of text. */
564  function revealParagraphs(el, text) {
565    var parts = String(text).split('\n\n');
566    parts.forEach(function (para, i) {
567      var p = document.createElement('p');
568      p.textContent = para;
569      if (!reduceMotion) {
570        p.className = 'ask-reveal';
571        p.style.animationDelay = (i * 70) + 'ms';
572      }
573      el.appendChild(p);
574    });
575  }
576
577  /* Answer after a pause, with slight variance so it does not feel robotic. */
578  function respond(build) {
579    if (reduceMotion) { build(); return; }
580    var dots = thinking();
581    var wait = 420 + Math.random() * 260;
582    setTimeout(function () {
583      dots.remove();
584      build();
585    }, wait);
586  }
587
588  function bubble(role) {
589    var wrap = document.createElement('div');
590    wrap.className = 'ask-msg ask-msg--' + role;
591    var av = document.createElement('div');
592    av.className = 'ask-avatar';
593    av.innerHTML = role === 'ai' ? AI_AVATAR : USER_AVATAR;
594    var b = document.createElement('div');
595    b.className = 'ask-bubble';
596    wrap.appendChild(av);
597    wrap.appendChild(b);
598    msgs.appendChild(wrap);
599    msgs.scrollTop = msgs.scrollHeight;
600    return b;
601  }
602
603  function paragraphs(el, text) {
604    String(text).split('\n\n').forEach(function (para) {
605      var p = document.createElement('p');
606      p.textContent = para;
607      el.appendChild(p);
608    });
609  }
610
611  /* Follow-up chips under an answer: the drill-down path. */
612  function followUps(after, keys, label) {
613    if (!keys || !keys.length) return;
614    var row = document.createElement('div');
615    row.className = 'ask-followups';
616    if (label) {
617      var lb = document.createElement('span');
618      lb.className = 'ask-followups-label';
619      lb.textContent = label;
620      row.appendChild(lb);
621    }
622    keys.forEach(function (k) {
623      if (!TOPICS[k]) return;
624      var b = document.createElement('button');
625      b.type = 'button';
626      b.className = 'ask-chip ask-chip--followup';
627      b.textContent = TOPICS[k].q;
628      b.addEventListener('click', function () { ask(TOPICS[k].q, k); });
629      row.appendChild(b);
630    });
631    after.appendChild(row);
632    msgs.scrollTop = msgs.scrollHeight;
633  }
634
635  /* Unmatched: capture the question and an email rather than apologise. */
636  function captureForm(question) {
637    var b = bubble('ai');
638    revealParagraphs(b, 'We do not have an answer to that here yet. Tell our team what you need and we will help you find the right information.');
639
640    var form = document.createElement('form');
641    form.className = 'ask-capture';
642    form.innerHTML =
643      '<label class="sr-only" for="ask-capture-email">Your work email</label>' +
644      '<input class="ask-capture-input" type="email" id="ask-capture-email" name="email" placeholder="Your work email" autocomplete="email" required>' +
645      '<button class="ask-capture-send" type="submit">Send my question</button>';
646    form.addEventListener('submit', function (e) {
647      e.preventDefault();
648      var email = form.querySelector('input').value.trim();
649      if (!email) return;
650
651      var btn = form.querySelector('.ask-capture-send');
652      if (btn) { btn.disabled = true; btn.textContent = 'Sending…'; }
653
654      try {
655        window.dataLayer = window.dataLayer || [];
656        window.dataLayer.push({
657          event: 'ask_sonovate_capture',
658          ask_question: String(question).slice(0, 120),
659          ask_email: email
660        });
661      } catch (err) {}
662
663      function done() {
664        form.innerHTML = '<p class="ask-capture-done">
664Thanks. We have received your question and our team will come back to you.</p>';
665      }
666
667      var hs = (typeof SNV_HS !== 'undefined') ? SNV_HS : null;
668      if (!hs || !hs.formId) { done(); return; }
669
670      /* hubspotutk links this submission to the visitor's existing history
671         in HubSpot instead of creating an unattributed contact. */
672      var hutk = (document.cookie.match(/hubspotutk=([^;]+)/) || [])[1] || '';
673      var payload = {
674        fields: [
675          { objectTypeId: '0-1', name: 'email', value: email },
676          { objectTypeId: '0-1', name: hs.questionField || 'message', value: String(question).slice(0, 900) }
677        ],
678        context: {
679          pageUri: window.location.href,
680          pageName: document.title
681        }
682      };
683      if (hutk) payload.context.hutk = hutk;
684
685      fetch('https://api.hsforms.com/submissions/v3/integration/submit/' + hs.portalId + '/' + hs.formId, {
686        method: 'POST',
687        headers: { 'Content-Type': 'application/json' },
688        body: JSON.stringify(payload)
689      }).then(function (res) {
690        if (!res.ok && window.console) console.warn('Ask Sonovate: HubSpot rejected the submission', res.status);
691        done();
692      }).catch(function () {
693        /* Never strand the visitor on a network error: the question is
694           already in the dataLayer, so confirm and move on. */
695        done();
696      });
697    });
698    b.appendChild(form);
699    followUps(b, START.slice(0, 3), 'Or pick a question');
700    msgs.scrollTop = msgs.scrollHeight;
701  }
702
703  function ask(question, topicKey) {
704    var q = String(question).trim();
705    if (!q) return;
706    input.value = '';
707
708    var userBubble = bubble('user');
709    var p = document.createElement('p');
710    p.textContent = q;
711    userBubble.appendChild(p);
712
713    var key = topicKey || matchTopic(q);
714    track(q, key);
715
716    if (!key || !TOPICS[key]) {
717      if (looksLikeQuestion(q)) respond(function () { captureForm(q); });
718      else nudge();
719      return;
720    }
721
722    respond(function () {
723      var b = bubble('ai');
724      revealParagraphs(b, TOPICS[key].a);
725      followUps(b, TOPICS[key].next);
726    });
727  }
728
729  /* Starter chips attach to the opening message, so the questions sit directly
730     under the invitation to ask one. Same pattern as follow-ups beneath every
731     answer: the reader learns it once. */
732  var greeting = msgs.querySelector('.ask-msg--ai .ask-bubble');
733  if (greeting) {
734    followUps(greeting, START, 'Popular questions');
735  }
736  if (chipRow) chipRow.remove();
737
738  if (sendBtn) sendBtn.addEventListener('click', function () { ask(input.value); });
739  if (input) {
740    input.addEventListener('keydown', function (e) {
741      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(input.value); }
742    });
743  }
744})();
745
746  /* ---------- 10. Skills Marketplace analytics ---------- */
747  (function () {
748    const SECTION = 'skills_marketplace';
749    const skillsSection = document.getElementById('skills');
750    if (!skillsSection) return;
751
752    // Event names stay generic and reusable across the site (GA4 already
753    // attaches page_location/page_title to every event, and keeps its own
754    // event list smaller when sections share names). Which section and
755    // what happened live in the parameters instead, e.g.
756    // section_view {section: 'skills_marketplace'},
757    // cta_click {section: 'skills_marketplace', cta_location: 'hero'}.
758    function track(eventName, params) {
759      if (typeof gtag === 'function') gtag('event', eventName, Object.assign({ section: SECTION }, params));
760    }
761
762    // Fires once per pageview when the section is meaningfully on screen,
763    // not just clipping the viewport edge.
764    if ('IntersectionObserver' in window) {
765      const sectionObserver = new IntersectionObserver(
766        (entries) => {
767          entries.forEach((entry) => {
768            if (entry.isIntersecting) {
769              track('section_view');
770              sectionObserver.unobserve(entry.target);
771            }
772          });
773        },
774        { threshold: 0.5 }
775      );
776      sectionObserver.observe(skillsSection);
777    }
778
779    // The marquee renders every tile twice (a duplicate, aria-hidden pass)
780    // for a seamless loop. Report the logical position within one set of
781    // tiles regardless of which copy was actually clicked.
782    function tilePosition(article) {
783      const tiles = Array.from(skillsSection.querySelectorAll('.skills-tile'));
784      const setSize = tiles.length / 2;
785      const index = tiles.indexOf(article);
786      return setSize > 0 ? index % setSize : index;
787    }
788
789    skillsSection.addEventListener('click', (event) => {
790      const heroCta = event.target.closest('.skills-section-actions a');
791      if (heroCta) {
792        track('cta_click', { cta_location: 'hero' });
793        return;
794      }
795
796      const bannerCta = event.target.closest('.skills-cta-banner');
797      if (bannerCta) {
798        track('cta_click', { cta_location: 'banner' });
799        return;
800      }
801
802      const tileLink = event.target.closest('.skills-tile-link');
803      if (tileLink) {
804        const article = tileLink.closest('.skills-tile');
805        const kind = article && article.classList.contains('skills-tile--worker') ? 'worker' : 'skill';
806        const title = tileLink.querySelector('h3');
807        track('content_click', {
808          content_type: kind === 'worker' ? 'digital_worker' : 'skill',
809          content_name: title ? title.textContent.trim() : '',
810          content_position: article ? tilePosition(article) : null,
811          content_location: 'skills_marketplace',
812        });
813        return;
814      }
815
816      const connector = event.target.closest('.skills-connector');
817      if (connector) {
818        const title = connector.querySelector('h3');
819        const badge = connector.querySelector('.skills-connector-badge');
820        track('content_click', {
821          content_type: 'connector',
822          content_name: title ? title.textContent.trim() : '',
823          content_label: badge ? badge.textContent.trim() : '',
824          content_location: 'skills_marketplace',
825        });
826      }
827    });
828  })();
829
830  /* ---------- 11. Shared navigation analytics ---------- */
831  (function () {
832    function track(eventName, params) {
833      if (typeof gtag === 'function') gtag('event', eventName, Object.assign({ page_context: 'snv2026' }, params));
834    }
835
836    document.addEventListener('click', (event) => {
837      const link = event.target.closest('a');
838      if (!link) return;
839      if (link.closest('.skills-tile-link, .skills-cta-banner, .skills-section-actions')) return;
840
841      const panel = link.closest('[data-mega-panel], [data-dropdown-panel], [data-mobile-drawer]');
842      const footer = link.closest('.footer');
843      const header = link.closest('.nav-shell');
844      const isCta = link.matches('.btn, .btn-primary, .btn-cta, .btn-ghost, .btn-ghost-on-dark');
845      const location = footer ? 'footer' : panel ? (panel.closest('[data-mobile-drawer]') ? 'mobile_menu' : 'header_menu') : header ? 'header' : 'content';
846      const group = panel ? (panel.getAttribute('aria-label') || '').replace(/\s+menu$/i, '').trim().toLowerCase().replace(/\s+/g, '_') : '';
847      const label = (link.textContent || link.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim();
848      const destination = link.href || '';
849
850      if (isCta) {
851        track('cta_click', { cta_location: location, cta_text: label, destination });
852      } else if (location !== 'content' || panel) {
853        track('navigation_click', { nav_location: location, nav_group: group, link_text: label, destination });
854      }
855    });
856  })();
857})();
858
859/* Highlight the section currently represented in the server-rendered contents. */
860(function () {
861  var toc = document.getElementById('post-toc');
862  if (!toc || !('IntersectionObserver' in window)) return;
863
864  var links = Array.prototype.slice.call(toc.querySelectorAll('a[href^="#"]'));
865  var sections = links.map(function (link) {
866    try { return document.getElementById(decodeURIComponent(link.hash.slice(1))); }
867    catch (error) { return null; }
868  }).filter(Boolean);
869  if (!sections.length) return;
870
871  function activate(id) {
872    links.forEach(function (link) {
873      var active = link.hash === '#' + id;
874      link.parentElement.classList.toggle('is-active', active);
875      if (active) link.setAttribute('aria-current', 'location');
876      else link.removeAttribute('aria-current');
877    });
878  }
879
880  var visible = {};
881  var observer = new IntersectionObserver(function (entries) {
882    entries.forEach(function (entry) {
883      if (entry.isIntersecting) visible[entry.target.id] = entry.boundingClientRect.top;
884      else delete visible[entry.target.id];
885    });
886    var ids = Object.keys(visible).sort(function (a, b) { return visible[a] - visible[b]; });
887    if (ids.length) activate(ids[0]);
888  }, { rootMargin: '-15% 0px -70% 0px' });
889
890  sections.forEach(function (section) { observer.observe(section); });
891  activate(sections[0].id);
892})();

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.