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 · â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) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[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.