PageSourceSearch

https://responseiq.ai/scripts-v2.js

js responseiq.ai collected 2026-10-05 14:26:25 UTC 35,684 bytes, 921 lines download raw bytes

1/* ==========================================================================
2   ResponseIQ v2 — vanilla JS + dynamic Three.js hero
3   Runs on module load. All DOM work waits for DOMContentLoaded.
4   ========================================================================== */
5
6'use strict';
7
8/* -------------------------------------------------------------------------
9   0. Small utilities
10   ------------------------------------------------------------------------- */
11const $ = (sel, root = document) => root.querySelector(sel);
12const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
13
14const prefersReducedMotion = () =>
15  window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
16
17const supportsWebGL = () => {
18  try {
19    const c = document.createElement('canvas');
20    return !!(window.WebGL2RenderingContext && (c.getContext('webgl2') || c.getContext('webgl')));
21  } catch (_) {
22    return false;
23  }
24};
25
26const on = (el, ev, fn, opts) => el && el.addEventListener(ev, fn, opts || false);
27
28/* -------------------------------------------------------------------------
29   1. Reveal on scroll (IntersectionObserver + safety net)
30   ------------------------------------------------------------------------- */
31function initReveal() {
32  const items = $$('.reveal');
33  if (!items.length) return;
34
35  const revealAll = () => items.forEach(el => el.classList.add('revealed'));
36
37  if (!('IntersectionObserver' in window) || prefersReducedMotion()) {
38    revealAll();
39    return;
40  }
41
42  const io = new IntersectionObserver((entries) => {
43    for (const entry of entries) {
44      if (entry.isIntersecting) {
45        entry.target.classList.add('revealed');
46        io.unobserve(entry.target);
47      }
48    }
49  }, {
50    root: null,
51    rootMargin: '0px 0px -8% 0px',
52    threshold: 0.05
53  });
54
55  items.forEach(el => io.observe(el));
56
57  // Safety net — after 2s, force-reveal anything still hidden.
58  // Protects users on ad-blockers, slow pages, printing, PDF export, and
59  // any IntersectionObserver edge case where content would otherwise be
60  // permanently invisible.
61  window.setTimeout(() => {
62    items.forEach(el => {
63      if (!el.classList.contains('revealed')) {
64        el.classList.add('revealed');
65      }
66    });
67  }, 2000);
68}
69
70/* -------------------------------------------------------------------------
71   1b. Card media fallback — inject readable project label when img 404s
72   ------------------------------------------------------------------------- */
73function initCardFallbacks() {
74  // Portfolio cards — copy data-project onto media element so CSS ::before
75  // can render the branded name tile if the image errors out.
76  $$('.riq-card[data-project]').forEach(card => {
77    const media = card.querySelector('.riq-card__media');
78    if (media && !media.hasAttribute('data-fallback-label')) {
79      // Decode HTML entities in data-project (e.g. ’ &)
80      const label = card.getAttribute('data-project') || '';
81      const decoded = new DOMParser()
82        .parseFromString(label, 'text/html')
83        .documentElement.textContent;
84      media.setAttribute('data-fallback-label', decoded);
85    }
86  });
87}
88
89/* -------------------------------------------------------------------------
90   2. Sticky nav shadow after scroll
91   ------------------------------------------------------------------------- */
92function initStickyNav() {
93  const nav = $('#nav');
94  if (!nav) return;
95
96  let ticking = false;
97  const apply = () => {
98    if (window.scrollY > 40) {
99      nav.classList.add('scrolled');
100    } else {
101      nav.classList.remove('scrolled');
102    }
103    ticking = false;
104  };
105
106  on(window, 'scroll', () => {
107    if (!ticking) {
108      window.requestAnimationFrame(apply);
109      ticking = true;
110    }
111  }, { passive: true });
112
113  apply();
114}
115
116/* -------------------------------------------------------------------------
117   3. Mobile menu (hamburger)
118   ------------------------------------------------------------------------- */
119function initMobileMenu() {
120  const btn = $('#hamburger');
121  const menu = $('#mobile-menu');
122  if (!btn || !menu) return;
123
124  const close = () => {
125    btn.setAttribute('aria-expanded', 'false');
126    menu.setAttribute('hidden', '');
127    document.body.style.overflow = '';
128  };
129  const open = () => {
130    btn.setAttribute('aria-expanded', 'true');
131    menu.removeAttribute('hidden');
132    document.body.style.overflow = 'hidden';
133  };
134
135  on(btn, 'click', () => {
136    const isOpen = btn.getAttribute('aria-expanded') === 'true';
137    isOpen ? close() : open();
138  });
139
140  // Close menu when a link inside is tapped
141  $$('a', menu).forEach(a => on(a, 'click', close));
142
143  // Escape key
144  on(document, 'keydown', (e) => {
145    if (e.key === 'Escape') close();
146  });
147
148  // Match resize past mobile
149  const mq = window.matchMedia('(min-width: 768px)');
150  const onMq = () => { if (mq.matches) close(); };
151  if (mq.addEventListener) {
152    mq.addEventListener('change', onMq);
153  } else if (mq.addListener) {
154    mq.addListener(onMq);
155  }
156}
157
158/* -------------------------------------------------------------------------
159   4. Smooth-scroll internal anchors (progressive enhancement)
160   ------------------------------------------------------------------------- */
161function initSmoothScroll() {
162  if (prefersReducedMotion()) return;
163
164  on(document, 'click', (e) => {
165    const a = e.target.closest && e.target.closest('a[href^="#"]');
166    if (!a) return;
167    const id = a.getAttribute('href');
168    if (!id || id === '#' || id.length < 2) return;
169    const target = document.getElementById(id.slice(1));
170    if (!target) return;
171    e.preventDefault();
172    target.scrollIntoView({ behavior: 'smooth', block: 'start' });
173    // Update the URL hash without jumping
174    if (history.pushState) history.pushState(null, '', id);
175  });
176}
177
178/* -------------------------------------------------------------------------
179   5. Trust bar — build from /images/portfolio-manifest.json
180   ------------------------------------------------------------------------- */
181async function initTrustBar() {
182  const grid = $('#trust-grid');
183  if (!grid) return;
184
185  try {
186    const res = await fetch('/images/portfolio-manifest.json', { cache: 'default' });
187    if (!res.ok) throw new Error('manifest ' + res.status);
188    const data = await res.json();
189
190    if (!Array.isArray(data) || data.length === 0) {
191      grid.innerHTML = '';
192      return;
193    }
194
195    const frag = document.createDocumentFragment();
196
197    for (const item of data) {
198      const cell = document.createElement('div');
199      cell.className = 'riq-trust__cell' + (item.isPartner ? ' riq-trust__partner' : '');
200      cell.title = item.name + (item.shortDesc ? ' — ' + item.shortDesc : '');
201
202      if (item.logo) {
203        const img = document.createElement('img');
204        img.src = item.logo;
205        img.alt = item.name + ' logo';
206        img.loading = 'lazy';
207        img.decoding = 'async';
208        img.width = 200;
209        img.height = 56;
210        img.onerror = () => {
211          // Replace broken logo with a text badge
212          cell.innerHTML = '';
213          const badge = document.createElement('span');
214          badge.className = 'riq-trust__badge';
215          badge.textContent = item.name;
216          cell.appendChild(badge);
217        };
218        cell.appendChild(img);
219      } else {
220        const badge = document.createElement('span');
221        badge.className = 'riq-trust__badge';
222        badge.textContent = item.name;
223        cell.appendChild(badge);
224      }
225
226      if (item.url) {
227        const a = document.createElement('a');
228        a.href = item.url;
229        a.rel = 'noopener';
230        a.target = '_blank';
231        a.setAttribute('aria-label', 'Visit ' + item.name);
232        a.style.display = 'flex';
233        a.style.alignItems = 'center';
234        a.style.justifyContent = 'center';
235        a.style.width = '100%';
236        a.style.height = '100%';
237        a.appendChild(cell.firstChild);
238        cell.appendChild(a);
239      }
240
241      frag.appendChild(cell);
242    }
243
244    grid.innerHTML = '';
245    grid.appendChild(frag);
246  } catch (err) {
247    // Silent fallback: leave grid empty so the section still degrades gracefully
248    grid.innerHTML = '';
249  }
250}
251
252/* -------------------------------------------------------------------------
253   6. Contact form — POST to portal endpoint
254   ------------------------------------------------------------------------- */
255function initContactForm() {
256  const form = $('#contact-form');
257  const result = $('#form-result');
258  if (!form || !result) return;
259
260  const setState = (state, msg) => {
261    result.className = 'riq-form__result';
262    if (state) result.classList.add('is-' + state);
263    result.textContent = msg || '';
264  };
265
266  on(form, 'submit', async (e) => {
267    e.preventDefault();
268    const fd = new FormData(form);
269
270    // Client-side validation
271    const name = String(fd.get('name') || '').trim();
272    const email = String(fd.get('email') || '').trim();
273    const message = String(fd.get('message') || '').trim();
274    if (!name || !email || !message) {
275      setState('error', 'Please fill out name, email, and message.');
276      return;
277    }
278    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
279      setState('error', 'That email address looks off — mind double-checking?');
280      return;
281    }
282
283    setState('loading', 'Sending…');
284    const btn = form.querySelector('button[type="submit"]');
285    if (btn) btn.disabled = true;
286
287    try {
288      const res = await fetch('/responseiq_portal/backend/contact.php', {
289        method: 'POST',
290        body: fd,
291        headers: { 'Accept': 'application/json' }
292      });
293
294      let ok = res.ok;
295      let payload = null;
296      try { payload = await res.json(); } catch (_) { /* not JSON */ }
297      if (payload && typeof payload.success === 'boolean') ok = payload.success;
298
299      if (ok) {
300        setState('success', payload && payload.message
301          ? payload.message
302          : "Got it. I'll be in touch shortly — usually the same day.");
303        form.reset();
304      } else {
305        setState('error', payload && payload.message
306          ? payload.message
307          : "Something went wrong on our end. Try calling 859-466-9526 — it's faster anyway.");
308      }
309    } catch (err) {
310      setState('error', "Couldn't reach the server. Try calling 859-466-9526 — it's faster anyway.");
311    } finally {
312      if (btn) btn.disabled = false;
313    }
314  });
315}
316
317/* -------------------------------------------------------------------------
318   7. Hero WebGL particle mesh — dynamic Three.js
319   ------------------------------------------------------------------------- */
320async function initHeroWebGL() {
321  if (prefersReducedMotion() || !supportsWebGL()) return;
322
323  const canvas = $('#hero-canvas');
324  if (!canvas) return;
325
326  let THREE;
327  try {
328    THREE = await import('https://esm.sh/[email protected]');
329  } catch (_) {
330    return; // silently fall back to CSS gradient
331  }
332
333  const container = canvas.parentElement || document.body;
334  const scene = new THREE.Scene();
335
336  const camera = new THREE.PerspectiveCamera(
337    60,
338    container.clientWidth / container.clientHeight,
339    0.1,
340    100
341  );
342  camera.position.z = 14;
343
344  const renderer = new THREE.WebGLRenderer({
345    canvas,
346    alpha: true,
347    antialias: true,
348    powerPreference: 'low-power'
349  });
350  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
351  renderer.setSize(container.clientWidth, container.clientHeight, false);
352  renderer.setClearColor(0x000000, 0);
353
354  // Particle mesh — ~320 cyan points (denser, more cinematic)
355  const COUNT = 320;
356  const positions = new Float32Array(COUNT * 3);
357  const speeds = new Float32Array(COUNT);
358  const phases = new Float32Array(COUNT);
359
360  for (let i = 0; i < COUNT; i++) {
361    positions[i * 3 + 0] = (Math.random() - 0.5) * 26;
362    positions[i * 3 + 1] = (Math.random() - 0.5) * 14;
363    positions[i * 3 + 2] = (Math.random() - 0.5) * 12;
364    speeds[i] = 0.15 + Math.random() * 0.55;
365    phases[i] = Math.random() * Math.PI * 2;
366  }
367
368  const geometry = new THREE.BufferGeometry();
369  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
370
371  const material = new THREE.PointsMaterial({
372    color: 0x00b7ff,
373    size: 0.09,
374    transparent: true,
375    opacity: 0.65,
376    sizeAttenuation: true,
377    depthWrite: false,
378    blending: THREE.AdditiveBlending
379  });
380
381  const points = new THREE.Points(geometry, material);
382  scene.add(points);
383
384  // A second slower layer for depth
385  const COUNT2 = 140;
386  const positions2 = new Float32Array(COUNT2 * 3);
387  for (let i = 0; i < COUNT2; i++) {
388    positions2[i * 3 + 0] = (Math.random() - 0.5) * 34;
389    positions2[i * 3 + 1] = (Math.random() - 0.5) * 18;
390    positions2[i * 3 + 2] = (Math.random() - 0.5) * 20 - 6;
391  }
392  const geometry2 = new THREE.BufferGeometry();
393  geometry2.setAttribute('position', new THREE.BufferAttribute(positions2, 3));
394  const material2 = new THREE.PointsMaterial({
395    color: 0x0077cc,
396    size: 0.06,
397    transparent: true,
398    opacity: 0.35,
399    sizeAttenuation: true,
400    depthWrite: false,
401    blending: THREE.AdditiveBlending
402  });
403  const points2 = new THREE.Points(geometry2, material2);
404  scene.add(points2);
405
406  // Interaction — subtle mouse parallax
407  const target = { x: 0, y: 0 };
408  const current = { x: 0, y: 0 };
409
410  const onPointer = (e) => {
411    const rect = canvas.getBoundingClientRect();
412    const x = (e.clientX - rect.left) / rect.width;
413    const y = (e.clientY - rect.top) / rect.height;
414    target.x = (x - 0.5) * 0.6;
415    target.y = (y - 0.5) * 0.4;
416  };
417  on(container, 'pointermove', onPointer);
418
419  // Resize
420  const resize = () => {
421    const w = container.clientWidth;
422    const h = container.clientHeight;
423    camera.aspect = w / h;
424    camera.updateProjectionMatrix();
425    renderer.setSize(w, h, false);
426  };
427  on(window, 'resize', resize, { passive: true });
428  resize();
429
430  // Pause when tab hidden or hero off-screen
431  let visible = true;
432  on(document, 'visibilitychange', () => { visible = !document.hidden; });
433
434  let inView = true;
435  if ('IntersectionObserver' in window) {
436    const io = new IntersectionObserver((entries) => {
437      for (const e of entries) inView = e.isIntersecting;
438    }, { threshold: 0.05 });
439    io.observe(container);
440  }
441
442  // Render loop
443  const clock = new THREE.Clock();
444  const posAttr = geometry.getAttribute('position');
445  const startPositions = new Float32Array(positions);
446
447  const tick = () => {
448    if (visible && inView) {
449      const t = clock.getElapsedTime();
450
451      // Ease camera toward mouse
452      current.x += (target.x - current.x) * 0.04;
453      current.y += (target.y - current.y) * 0.04;
454      camera.position.x = current.x * 2;
455      camera.position.y = -current.y * 2;
456      camera.lookAt(0, 0, 0);
457
458      // Gently drift particles
459      for (let i = 0; i < COUNT; i++) {
460        const base = i * 3;
461        posAttr.array[base + 1] =
462          startPositions[base + 1] + Math.sin(t * speeds[i] + phases[i]) * 0.4;
463        posAttr.array[base + 0] =
464          startPositions[base + 0] + Math.cos(t * speeds[i] * 0.6 + phases[i]) * 0.3;
465      }
466      posAttr.needsUpdate = true;
467
468      points.rotation.y = t * 0.02;
469      points2.rotation.y = -t * 0.012;
470
471      renderer.render(scene, camera);
472    }
473    requestAnimationFrame(tick);
474  };
475  tick();
476}
477
478/* -------------------------------------------------------------------------
479   Q1. Scroll progress bar (thin cyan bar at top of viewport)
480   ------------------------------------------------------------------------- */
481function initScrollProgress() {
482  if (prefersReducedMotion()) return;
483  const bar = document.createElement('div');
484  bar.className = 'riq-scroll-progress';
485  bar.setAttribute('aria-hidden', 'true');
486  document.body.appendChild(bar);
487  const update = () => {
488    const h = document.documentElement;
489    const max = h.scrollHeight - h.clientHeight;
490    const pct = max > 0 ? h.scrollTop / max : 0;
491    bar.style.transform = `scaleX(${pct})`;
492  };
493  update();
494  on(window, 'scroll', update, { passive: true });
495  on(window, 'resize', update, { passive: true });
496}
497
498/* -------------------------------------------------------------------------
499   Q2. Hero H1 word-by-word stagger reveal
500   ------------------------------------------------------------------------- */
501function initHeroStagger() {
502  const h1 = $('.riq-hero__title');
503  if (!h1) return;
504  if (prefersReducedMotion()) return;
505  // Split each direct text node (and inline spans) into word wrappers.
506  const walk = (node) => {
507    const kids = Array.from(node.childNodes);
508    for (const c of kids) {
509      if (c.nodeType === 3) { // text
510        const words = c.nodeValue.split(/(\s+)/); // keep whitespace
511        const frag = document.createDocumentFragment();
512        for (const w of words) {
513          if (/^\s+$/.test(w) || w === '') {
514            frag.appendChild(document.createTextNode(w));
515          } else {
516            const outer = document.createElement('span');
517            outer.className = 'riq-word';
518            const inner = document.createElement('span');
519            inner.className = 'riq-word__inner';
520            inner.textContent = w;
521            outer.appendChild(inner);
522            frag.appendChild(outer);
523          }
524        }
525        c.parentNode.replaceChild(frag, c);
526      } else if (c.nodeType === 1 && c.tagName !== 'BR') {
527        // Recurse into inline children like .riq-hero__title-accent
528        walk(c);
529      }
530    }
531  };
532  walk(h1);
533  // Stagger: 60ms per word
534  const words = $$('.riq-word__inner', h1);
535  words.forEach((w, i) => {
536    w.style.transitionDelay = (i * 60) + 'ms';
537  });
538  // Trigger on next frame so initial state applies
539  requestAnimationFrame(() => {
540    requestAnimationFrame(() => h1.classList.add('riq-hero__title--staggered'));
541  });
542}
543
544/* -------------------------------------------------------------------------
545   Q3. Number roll-up on stats (count 0 → target when in view)
546   ------------------------------------------------------------------------- */
547function initStatCounter() {
548  if (prefersReducedMotion()) return;
549  const targets = $$('.riq-hero__meta strong');
550  if (!targets.length) return;
551  const rolled = new WeakSet();
552  const easeOut = t => 1 - Math.pow(1 - t, 3);
553  const animate = (el) => {
554    if (rolled.has(el)) return;
555    rolled.add(el);
556    // Parse: strip non-digits from text, e.g. "20+" → 20
557    const raw = el.textContent.trim();
558    const m = raw.match(/(\d+)/);
559    if (!m) return;
560    const target = parseInt(m[1], 10);
561    const suffix = raw.slice(m[0].length); // e.g., "+"
562    const prefix = raw.slice(0, m.index);
563    const start = performance.now();
564    const dur = Math.min(1200, 400 + target * 15);
565    const step = (now) => {
566      const t = Math.min(1, (now - start) / dur);
567      const v = Math.round(easeOut(t) * target);
568      el.textContent = prefix + v + suffix;
569      if (t < 1) requestAnimationFrame(step);
570      else el.textContent = raw; // exact final value
571    };
572    // Prime with 0 before first frame so we don't flash the final number
573    el.textContent = prefix + '0' + suffix;
574    requestAnimationFrame(step);
575  };
576  if (!('IntersectionObserver' in window)) {
577    targets.forEach(animate);
578    return;
579  }
580  const io = new IntersectionObserver((entries) => {
581    for (const e of entries) if (e.isIntersecting) { animate(e.target); io.unobserve(e.target); }
582  }, { threshold: 0.6 });
583  targets.forEach(t => io.observe(t));
584}
585
586/* -------------------------------------------------------------------------
587   Q4. Card tilt on hover (3D perspective, cursor-tracked)
588   ------------------------------------------------------------------------- */
589function initCardTilt() {
590  if (prefersReducedMotion()) return;
591  // Skip on touch devices — hover tilt on tap-only devices feels janky.
592  if (window.matchMedia && window.matchMedia('(hover: none)').matches) return;
593  const cards = $$('.riq-card, .riq-industry, .riq-cap');
594  if (!cards.length) return;
595  const MAX_TILT = 6; // degrees
596  cards.forEach(card => {
597    let raf = 0;
598    let lastX = 0, lastY = 0;
599    card.style.transformStyle = 'preserve-3d';
600    card.style.willChange = 'transform';
601    on(card, 'mousemove', (e) => {
602      const r = card.getBoundingClientRect();
603      const x = (e.clientX - r.left) / r.width;   // 0..1
604      const y = (e.clientY - r.top) / r.height;   // 0..1
605      lastX = (x - 0.5) * 2 * MAX_TILT;           // -MAX..+MAX (rotateY)
606      lastY = (0.5 - y) * 2 * MAX_TILT;           // -MAX..+MAX (rotateX)
607      if (!raf) raf = requestAnimationFrame(() => {
608        card.style.transform = `perspective(900px) rotateX(${lastY.toFixed(2)}deg) rotateY(${lastX.toFixed(2)}deg) translateZ(0)`;
609        raf = 0;
610      });
611    });
612    on(card, 'mouseleave', () => {
613      cancelAnimationFrame(raf); raf = 0;
614      card.style.transition = 'transform 400ms cubic-bezier(0.16, 1, 0.3, 1)';
615      card.style.transform = '';
616      // Clear transition after it completes so hover animations stay snappy
617      setTimeout(() => { card.style.transition = ''; }, 400);
618    });
619    on(card, 'mouseenter', () => {
620      card.style.transition = 'transform 120ms cubic-bezier(0.16, 1, 0.3, 1)';
621      setTimeout(() => { card.style.transition = ''; }, 120);
622    });
623  });
624}
625
626/* -------------------------------------------------------------------------
627   Q6. Cinematic hero cursor-reactive glow orb
628       Injects a soft cyan blur div that follows the cursor across the hero.
629       Desktop hover-only. Respects prefers-reduced-motion.
630   ------------------------------------------------------------------------- */
631function initHeroCursorGlow() {
632  if (prefersReducedMotion()) return;
633  if (window.matchMedia && window.matchMedia('(hover: none)').matches) return;
634  const hero = document.querySelector('.riq-hero');
635  if (!hero) return;
636  const glow = document.createElement('div');
637  glow.className = 'riq-hero__cursor-glow';
638  glow.setAttribute('aria-hidden', 'true');
639  hero.appendChild(glow);
640  let raf = 0, tx = 0, ty = 0;
641  on(hero, 'mousemove', (e) => {
642    const r = hero.getBoundingClientRect();
643    tx = e.clientX - r.left;
644    ty = e.clientY - r.top;
645    if (!raf) raf = requestAnimationFrame(() => {
646      glow.style.transform = `translate3d(${tx}px, ${ty}px, 0)`;
647      raf = 0;
648    });
649  });
650  on(hero, 'mouseleave', () => {
651    cancelAnimationFrame(raf); raf = 0;
652    glow.style.transform = 'translate3d(-9999px, -9999px, 0)';
653  });
654}
655
656/* -------------------------------------------------------------------------
657   Q5. Cmd+K / Ctrl+K spotlight quick-action modal
658       (Linear/Raycast/Superhuman-style command palette)
659   ------------------------------------------------------------------------- */
660function initSpotlight() {
661  const ICONS = {
662    phone: '<svg viewBox="0 0 24 24" width="18" height="18">
662<path fill="currentColor" d="M6.6 10.8a15 15 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1 11.4 11.4 0 0 0 .57 3.6 1 1 0 0 1-.25 1z"/></svg>',
663    chat: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M4 4h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8l-4 3V6a2 2 0 0 1 2-2z"/></svg>',
664    mail: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M4 6h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zm0 2 8 5 8-5"/></svg>',
665    copy: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 4h10a2 2 0 0 1 2 2v12h-2V6H8zM6 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"/></svg>',
666    quote: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
667    work: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M9 4h6a2 2 0 0 1 2 2v2h3a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h3V6a2 2 0 0 1 2-2zm0 4h6V6H9z"/></svg>',
668    stack: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M4 4h16v4H4zm0 6h16v4H4zm0 6h16v4H4z"/></svg>',
669    gear: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm10 4v-2l-2.4-.3a7.9 7.9 0 0 0-.6-1.5l1.5-2-1.4-1.4-2 1.5a7.9 7.9 0 0 0-1.5-.6L15.5 3h-2l-.3 2.4a7.9 7.9 0 0 0-1.5.6L9.7 4.5 8.3 5.9l1.5 2a7.9 7.9 0 0 0-.6 1.5L6.8 10v2l2.4.3a7.9 7.9 0 0 0 .6 1.5l-1.5 2 1.4 1.4 2-1.5a7.9 7.9 0 0 0 1.5.6l.3 2.4h2l.3-2.4a7.9 7.9 0 0 0 1.5-.6l2 1.5 1.4-1.4-1.5-2a7.9 7.9 0 0 0 .6-1.5z"/></svg>',
670    users: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm0 2c-4 0-8 2-8 6v2h16v-2c0-4-4-6-8-6zm10-10a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm-.5 8c3 0 5.5 1.5 5.5 4v2h-5v-2c0-1.5-1-3-2.5-4z"/></svg>',
671    grid: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M4 4h7v7H4zm9 0h7v7h-7zM4 13h7v7H4zm9 0h7v7h-7z"/></svg>',
672    external: '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="none" stroke="currentColor" stroke-width="2" d="M14 5h5v5M19 5l-9 9M5 5h4M5 19h14v-6"/></svg>',
673    search: '<svg viewBox="0 0 24 24" width="18" height="18"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M20 20l-4-4"/></svg>',
674  };
675
676  const scrollTo = (id) => {
677    const el = document.getElementById(id);
678    if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
679  };
680  const openExt = (url) => window.open(url, '_blank', 'noopener');
681
682  const ACTIONS = [
683    { group: 'Contact', label: 'Call 859-466-9526',    icon: 'phone', shortcut: 'tel:',    action: () => { window.location.href = 'tel:+18594669526'; } },
684    { group: 'Contact', label: 'Text 859-466-9526',    icon: 'chat',  shortcut: 'sms:',    action: () => { window.location.href = 'sms:+18594669526'; } },
685    { group: 'Contact', label: 'Email [email protected]', icon: 'mail', shortcut: 'mailto:', action: () => { window.location.href = 'mailto:[email protected]'; } },
686    { group: 'Contact', label: 'Get a Quote',          icon: 'quote', hint: 'Scroll to contact form', action: () => scrollTo('contact') },
687    { group: 'Contact', label: 'Copy phone number',    icon: 'copy',  hint: '+1 859-466-9526', action: () => copyText('+18594669526') },
688    { group: 'Contact', label: 'Copy email address',   icon: 'copy',  hint: '[email protected]', action: () => copyText('[email protected]') },
689    { group: 'Navigate', label: 'Featured work',       icon: 'work',  hint: 'Case studies', action: () => scrollTo('work') },
690    { group: 'Navigate', label: 'What we build',       icon: 'stack', hint: 'Capabilities', action: () => scrollTo('capabilities') },
691    { group: 'Navigate', label: 'Industries we serve', icon: 'users', hint: 'Fire, EMS, gov, small biz, aviation', action: () => scrollTo('industries') },
692    { group: 'Navigate', label: 'How we work',         icon: 'gear',  hint: 'Process', action: () => scrollTo('process') },
693    { group: 'Navigate', label: 'Client list',         icon: 'users', hint: 'Trust bar', action: () => scrollTo('trust') },
694    { group: 'External', label: 'View portfolio page', icon: 'grid',  hint: 'portfolio.html', action: () => { window.location.href = '/portfolio.html'; } },
695    { group: 'External', label: 'Marx Fabrication',    icon: 'external', hint: 'responseiq.ai/marxfabrication', action: () => openExt('https://responseiq.ai/marxfabrication/') },
696    { group: 'External', label: 'Newport Fire/EMS portal', icon: 'external', hint: 'responseiq.ai/newport-portal', action: () => openExt('https://responseiq.ai/newport-portal/') },
697    { group: 'External', label: 'Chopper R44 flight ops', icon: 'external', hint: 'responseiq.ai/chopper', action: () => openExt('https://responseiq.ai/chopper/') },
698  ];
699
700  const copyText = async (txt) => {
701    try {
702      await navigator.clipboard.writeText(txt);
703      flash(`Copied: ${txt}`);
704    } catch (_) {
705      flash('Copy failed — clipboard not available');
706    }
707  };
708
709  // Build modal DOM once
710  const modal = document.createElement('div');
711  modal.className = 'riq-spotlight';
712  modal.setAttribute('role', 'dialog');
713  modal.setAttribute('aria-modal', 'true');
714  modal.setAttribute('aria-hidden', 'true');
715  modal.innerHTML = `
716    <div class="riq-spotlight__backdrop" aria-hidden="true"></div>
717    <div class="riq-spotlight__panel" role="document">
718      <div class="riq-spotlight__search">
719        <span class="riq-spotlight__search-icon" aria-hidden="true">${ICONS.search}</span>
720        <input class="riq-spotlight__input" type="text" placeholder="Type a command or search…" aria-label="Command palette" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
721        <kbd class="riq-spotlight__esc">esc</kbd>
722      </div>
723      <ul class="riq-spotlight__list" role="listbox" aria-label="Quick actions"></ul>
724      <div class="riq-spotlight__footer">
725        <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
726        <span><kbd>↵</kbd> select</span>
727        <span><kbd>esc</kbd> close</span>
728        <span class="riq-spotlight__brand">ResponseIQ &middot; ⌘K anywhere</span>
729      </div>
730    </div>
731  `;
732  document.body.appendChild(modal);
733
734  const input = modal.querySelector('.riq-spotlight__input');
735  const list  = modal.querySelector('.riq-spotlight__list');
736  const backdrop = modal.querySelector('.riq-spotlight__backdrop');
737  let selectedIdx = 0;
738  let currentActions = ACTIONS;
739  let flashTimer = 0;
740
741  // Toast flash for copy feedback
742  const flash = (msg) => {
743    let toast = document.getElementById('riq-flash');
744    if (!toast) {
745      toast = document.createElement('div');
746      toast.id = 'riq-flash';
747      toast.className = 'riq-flash';
748      document.body.appendChild(toast);
749    }
750    toast.textContent = msg;
751    toast.classList.add('is-visible');
752    clearTimeout(flashTimer);
753    flashTimer = setTimeout(() => toast.classList.remove('is-visible'), 2000);
754  };
755
756  const escapeHTML = (s) => (s || '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]);
757
758  function render(actions) {
759    currentActions = actions;
760    if (!actions.length) {
761      list.innerHTML = '<li class="riq-spotlight__empty">No matches. Press <kbd>esc</kbd> to close.</li>';
762      return;
763    }
764    // Group by .group
765    let html = '';
766    let lastGroup = null;
767    actions.forEach((a, i) => {
768      if (a.group !== lastGroup) {
769        html += `<li class="riq-spotlight__group" aria-hidden="true">${escapeHTML(a.group)}</li>`;
770        lastGroup = a.group;
771      }
772      html += `
773        <li class="riq-spotlight__item ${i === selectedIdx ? 'is-active' : ''}" data-idx="${i}" role="option" aria-selected="${i === selectedIdx}">
774          <span class="riq-spotlight__item-icon" aria-hidden="true">${ICONS[a.icon] || ICONS.grid}</span>
775          <span class="riq-spotlight__item-label">${escapeHTML(a.label)}</span>
776          ${a.hint ? `<span class="riq-spotlight__item-hint">${escapeHTML(a.hint)}</span>` : ''}
777          ${a.shortcut ? `<kbd>${escapeHTML(a.shortcut)}</kbd>` : ''}
778        </li>
779      `;
780    });
781    list.innerHTML = html;
782    list.querySelectorAll('.riq-spotlight__item').forEach((el) => {
783      el.addEventListener('click', () => {
784        selectedIdx = parseInt(el.dataset.idx, 10);
785        activate();
786      });
787      el.addEventListener('mouseenter', () => {
788        selectedIdx = parseInt(el.dataset.idx, 10);
789        updateActive();
790      });
791    });
792  }
793
794  function updateActive() {
795    list.querySelectorAll('.riq-spotlight__item').forEach((el) => {
796      const i = parseInt(el.dataset.idx, 10);
797      const active = i === selectedIdx;
798      el.classList.toggle('is-active', active);
799      el.setAttribute('aria-selected', active ? 'true' : 'false');
800      if (active) el.scrollIntoView({ block: 'nearest' });
801    });
802  }
803
804  function fuzzyMatch(str, query) {
805    // Simple substring first; fall back to per-char sequence
806    const s = str.toLowerCase(), q = query.toLowerCase();
807    if (s.includes(q)) return true;
808    let qi = 0;
809    for (const ch of s) if (ch === q[qi]) qi++;
810    return qi === q.length;
811  }
812
813  function filter(query) {
814    if (!query.trim()) return ACTIONS;
815    return ACTIONS.filter(a => fuzzyMatch(a.label, query) || fuzzyMatch(a.hint || '', query) || fuzzyMatch(a.group, query));
816  }
817
818  function open() {
819    modal.classList.add('is-open');
820    modal.setAttribute('aria-hidden', 'false');
821    document.body.style.overflow = 'hidden';
822    selectedIdx = 0;
823    input.value = '';
824    render(ACTIONS);
825    setTimeout(() => input.focus(), 30);
826  }
827
828  function close() {
829    modal.classList.remove('is-open');
830    modal.setAttribute('aria-hidden', 'true');
831    document.body.style.overflow = '';
832    input.blur();
833  }
834
835  function activate() {
836    const action = currentActions[selectedIdx];
837    if (!action) return;
838    close();
839    setTimeout(() => { try { action.action(); } catch (_) {} }, 80);
840  }
841
842  function move(delta) {
843    if (!currentActions.length) return;
844    selectedIdx = (selectedIdx + delta + currentActions.length) % currentActions.length;
845    updateActive();
846  }
847
848  // Global keyboard: ⌘K / Ctrl+K opens, / opens (when not typing in a form)
849  on(document, 'keydown', (e) => {
850    const isMeta = e.metaKey || e.ctrlKey;
851    if (isMeta && e.key.toLowerCase() === 'k') {
852      e.preventDefault();
853      modal.classList.contains('is-open') ? close() : open();
854      return;
855    }
856    const tag = document.activeElement?.tagName;
857    if (e.key === '/' && !modal.classList.contains('is-open') && tag !== 'INPUT' && tag !== 'TEXTAREA') {
858      e.preventDefault();
859      open();
860      return;
861    }
862    if (!modal.classList.contains('is-open')) return;
863    if (e.key === 'Escape')      { e.preventDefault(); close(); }
864    else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
865    else if (e.key === 'ArrowUp')   { e.preventDefault(); move(-1); }
866    else if (e.key === 'Enter')     { e.preventDefault(); activate(); }
867  });
868
869  on(input, 'input', () => {
870    selectedIdx = 0;
871    render(filter(input.value));
872  });
873
874  on(backdrop, 'click', close);
875
876  // Discoverability: expose a small ⌘K hint on desktop nav (if hover-capable + not touch)
877  if (!window.matchMedia('(hover: none)').matches) {
878    const nav = document.querySelector('.riq-nav__cta');
879    if (nav) {
880      const btn = document.createElement('button');
881      btn.type = 'button';
882      btn.className = 'riq-spotlight-trigger';
883      btn.setAttribute('aria-label', 'Open command palette (⌘K)');
884      btn.setAttribute('title', 'Open command palette (⌘K)');
885      const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || '');
886      btn.innerHTML = `<kbd>${isMac ? '⌘' : 'Ctrl'}</kbd><kbd>K</kbd>`;
887      btn.addEventListener('click', open);
888      nav.insertBefore(btn, nav.firstChild);
889    }
890  }
891}
892
893/* -------------------------------------------------------------------------
894   8. Bootstrap
895   ------------------------------------------------------------------------- */
896function boot() {
897  initStickyNav();
898  initMobileMenu();
899  initSmoothScroll();
900  initCardFallbacks();
901  initReveal();
902  initTrustBar();
903  initContactForm();
904  // Quick-wins polish pass
905  initScrollProgress();
906  initHeroStagger();
907  initStatCounter();
908  initCardTilt();
909  // Cinematic hero enhancements
910  initHeroCursorGlow();
911  // Signature piece: Cmd+K spotlight
912  initSpotlight();
913  // Fire-and-forget WebGL — never blocks page interactivity
914  initHeroWebGL();
915}
916
917if (document.readyState === 'loading') {
918  document.addEventListener('DOMContentLoaded', boot, { once: true });
919} else {
920  boot();
921}

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.