1/** 2 * Site interactions: mobile menu, hero load-in and gravity, 3 * auto-advancing "path" tabs, scroll reveals, and scroll-linked effects 4 * (marquee velocity, mission word fill, inset section). 5 * Reduced motion keeps things moving gently instead of freezing them. 6 */ 7(function () { 8 const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 9 10 // ---------- Mobile menu ---------- 11 const toggle = document.getElementById('mobileMenuToggle'); 12 const menu = document.getElementById('mobileMenu'); 13 const overlay = document.getElementById('mobileMenuOverlay'); 14 const closeBtn = document.getElementById('mobileMenuClose'); 15 16 function openMenu() { 17 menu.style.display = 'flex'; 18 overlay.style.display = 'block'; 19 document.body.style.overflow = 'hidden'; 20 toggle.setAttribute('aria-expanded', 'true'); 21 requestAnimationFrame(() => { 22 menu.classList.add('active'); 23 overlay.classList.add('active'); 24 }); 25 } 26 27 function closeMenu() { 28 menu.classList.remove('active'); 29 overlay.classList.remove('active'); 30 document.body.style.overflow = ''; 31 toggle.setAttribute('aria-expanded', 'false'); 32 setTimeout(() => { 33 menu.style.display = 'none'; 34 overlay.style.display = 'none'; 35 }, 300); 36 } 37 38 if (toggle && menu && overlay) { 39 toggle.addEventListener('click', openMenu); 40 overlay.addEventListener('click', closeMenu); 41 closeBtn?.addEventListener('click', closeMenu); 42 menu.querySelectorAll('a').forEach(a => a.addEventListener('click', closeMenu)); 43 document.addEventListener('keydown', e => { 44 if (e.key === 'Escape' && menu.classList.contains('active')) closeMenu(); 45 }); 46 } 47 48 // ---------- "Get found": questions people ask AI assistants, one sector at a time ---------- 49 const foundPanel = document.getElementById('panel-found'); 50 const foundAnswer = document.getElementById('foundAnswer'); 51 const foundQuery = document.getElementById('foundQuery'); 52 const foundHit = document.getElementById('foundHit'); 53 if (foundPanel && foundAnswer && foundQuery && foundHit) { 54 const questions = [ 55 ['Who\'s a reliable roofer near Olympia?', 'Your roofing company'], 56 ['What\'s a clean protein powder that actually tastes good?', 'Your brand'], 57 ['Is there a family law attorney near me with good reviews?', 'Your firm'] 58 ]; 59 const QUESTION_MS = 3800; 60 let q = 0; 61 let typing = null; 62 let cycle = null; 63 64 function ask() { 65 clearTimeout(typing); 66 clearTimeout(cycle); 67 const [text, hit] = questions[q % questions.length]; 68 q++; 69 foundHit.textContent = hit; 70 if (reduceMotion) { 71 foundQuery.textContent = text; 72 foundAnswer.classList.add('is-answered'); 73 } else { 74 foundAnswer.classList.remove('is-answered'); 75 foundQuery.textContent = ''; 76 let i = 0; 77 (function type() { 78 foundQuery.textContent = text.slice(0, ++i); 79 if (i < text.length) typing = setTimeout(type, 22 + Math.random() * 18); 80 else typing = setTimeout(() => foundAnswer.classList.add('is-answered'), 350); 81 })(); 82 } 83 // Keep cycling while the step stays open (e.g. the visitor paused the tabs) 84 // Play each question once per visit, then hold on the last one until the step moves on 85 if (q < questions.length) { 86 cycle = setTimeout(() => { if (!foundPanel.hidden) ask(); }, QUESTION_MS); 87 } 88 } 89 90 new MutationObserver(() => { 91 if (!foundPanel.hidden) { q = 0; ask(); } 92 else { clearTimeout(typing); clearTimeout(cycle); } 93 }).observe(foundPanel, { attributes: true, attributeFilter: ['hidden'] }); 94
95 // The first step is already open on load, so start typing when it scrolls into view 96 if ('IntersectionObserver' in window) { 97 const firstView = new IntersectionObserver(entries => { 98 if (entries[0].isIntersecting && !foundPanel.hidden) { 99 ask(); 100 firstView.disconnect(); 101 } 102 }, { threshold: 0.5 }); 103 firstView.observe(foundPanel); 104 } 105 } 106 107 // ---------- "Learn what works": dashboard counters tick up each time the step opens ---------- 108 const learnPanel = document.getElementById('panel-learn'); 109 if (learnPanel) { 110 const counters = Array.from(learnPanel.querySelectorAll('[data-count]')); 111 function countUp() { 112 if (reduceMotion) return; 113 const t0 = performance.now(); 114 const DURATION = 1300; 115 (function tick(now) { 116 const t = Math.min(1, (now - t0) / DURATION); 117 const eased = 1 - Math.pow(1 - t, 3); 118 counters.forEach(c => { c.textContent = Math.round(+c.dataset.count * eased); }); 119 if (t < 1 && !learnPanel.hidden) requestAnimationFrame(tick); 120 })(t0); 121 } 122 new MutationObserver(() => { if (!learnPanel.hidden) countUp(); }) 123 .observe(learnPanel, { attributes: true, attributeFilter: ['hidden'] }); 124 } 125 126 // ---------- Path tabs ---------- 127 const path = document.getElementById('pathTabs'); 128 if (path) { 129 const tabs = Array.from(path.querySelectorAll('[role="tab"]')); 130 const DEFAULT_DURATION = 6000; 131 const durationOf = tab => +tab.dataset.duration || DEFAULT_DURATION; 132 let current = 0; 133 let timer = null; 134 let paused = false; 135 136 137 // Phones and tablets show the illustration under the active step (accordion) and don't auto-advance 138 const stacked = window.matchMedia('(max-width: 1024px)'); 139 140 function select(index, focus) { 141 current = (index + tabs.length) % tabs.length; 142 path.style.setProperty('--path-duration', durationOf(tabs[current]) + 'ms'); 143 path.style.setProperty('--stage-order', current * 2 + 2);
144 tabs.forEach((tab, n) => { 145 const active = n === current; 146 const panel = document.getElementById(tab.getAttribute('aria-controls')); 147 tab.classList.toggle('is-active', active); 148 tab.setAttribute('aria-selected', String(active)); 149 tab.tabIndex = active ? 0 : -1; 150 if (panel) { 151 panel.hidden = !active; 152 panel.classList.toggle('is-active', active); 153 } 154 }); 155 if (focus) tabs[current].focus(); 156 schedule(); 157 } 158 159 function schedule() { 160 clearTimeout(timer); 161 if (paused || stacked.matches) return; 162 timer = setTimeout(() => select(current + 1), durationOf(tabs[current])); 163 } 164 165 function setPaused(value) { 166 paused = value; 167 path.classList.toggle('is-paused', value); 168 if (value) { 169 clearTimeout(timer); 170 } else { 171 // Restart the progress line along with the timer 172 select(current); 173 } 174 } 175 176 stacked.addEventListener?.('change', schedule); 177
178 tabs.forEach((tab, n) => { 179 tab.addEventListener('click', () => select(n)); 180 tab.addEventListener('keydown', e => { 181 if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); select(current + 1, true); } 182 if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); select(current - 1, true); } 183 }); 184 }); 185 186 path.addEventListener('mouseenter', () => setPaused(true)); 187 path.addEventListener('mouseleave', () => setPaused(false)); 188 path.addEventListener('focusin', () => setPaused(true)); 189 path.addEventListener('focusout', e => { 190 if (!path.contains(e.relatedTarget)) setPaused(false); 191 }); 192 193 // Only start cycling once the section is on screen 194 if ('IntersectionObserver' in window) { 195 let started = false; 196 new IntersectionObserver((entries, obs) => { 197 if (entries[0].isIntersecting && !started) { 198 started = true; 199 select(0); 200 obs.disconnect(); 201 } 202 }, { threshold: 0.35 }).observe(path); 203 } else { 204 select(0); 205 } 206 } 207 208 // ---------- Scroll reveal ---------- 209 const reveals = document.querySelectorAll('.reveal'); 210 if (!('IntersectionObserver' in window) || reduceMotion) { 211 reveals.forEach(el => el.classList.add('is-visible')); 212 } else { 213 const io = new IntersectionObserver(entries => { 214 entries.forEach(entry => { 215 if (!entry.isIntersecting) return; 216 const el = entry.target; 217 // Stagger siblings that enter together 218 const siblings = Array.from(el.parentElement.children).filter(c => c.classList.contains('reveal')); 219 el.style.transitionDelay = Math.min(siblings.indexOf(el), 6) * 80 + 'ms'; 220 el.classList.add('is-visible'); 221 el.addEventListener('transitionend', () => { el.style.transitionDelay = ''; }, { once: true }); 222 io.unobserve(el); 223 }); 224 }, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' }); 225 reveals.forEach(el => io.observe(el)); 226 227 // Fast flick-scrolls and anchor jumps can skip past an element without the observer 228 // ever seeing it on screen; anything already reached or passed must still show. 229 let pending = Array.from(reveals); 230 const catchUp = () => { 231 pending = pending.filter(el => { 232 if (el.classList.contains('is-visible')) return false; 233 if (el.getBoundingClientRect().top < window.innerHeight) { 234 el.classList.add('is-visible'); 235 io.unobserve(el); 236 return false; 237 } 238 return true; 239 }); 240 if (!pending.length) window.removeEventListener('scroll', onScroll); 241 }; 242 let queued = false; 243 const onScroll = () => { 244 if (queued) return; 245 queued = true; 246 requestAnimationFrame(() => { queued = false; catchUp(); }); 247 }; 248 window.addEventListener('scroll', onScroll, { passive: true }); 249 window.addEventListener('hashchange', catchUp); 250 catchUp(); 251 } 252 253 // ---------- Hero load-in + gravity ---------- 254 const hero = document.querySelector('.h-hero'); 255 if (hero) { 256 const ready = () => requestAnimationFrame(() => hero.classList.add('is-ready')); 257 (document.fonts?.ready || Promise.resolve()).then(ready); 258 setTimeout(ready, 1200); 259 initGravity(hero); 260 } 261 262 // Leads (small squares) drift in and are pulled into the "business" block, 263 // which widens with each one it absorbs, then eases back and starts again. 264 function initGravity(hero) { 265 const canvas = document.getElementById('heroGravity'); 266 const block = document.getElementById('heroBlock'); 267 if (!canvas || !block) return; 268 const ctx = canvas.getContext('2d'); 269 const MAX_LEADS = 14; 270 const RESET_AFTER = 18; 271 const pace = reduceMotion ? 0.5 : 1; 272 let w = 0; 273 let h = 0; 274 let leads = []; 275 let absorbed = 0; 276 const BASE_PAD = 0.16; 277 let pad = BASE_PAD; 278
279 // Widest the block can get without pushing "business." onto another line 280 function maxPad() { 281 const line = block.closest('.h-ln > span'); 282 const title = block.closest('.h-hero-title'); 283 if (!line || !title || window.getComputedStyle(line).display === 'inline') return 0.3; 284 const fontPx = parseFloat(window.getComputedStyle(block).fontSize); 285 const padPx = parseFloat(window.getComputedStyle(block).paddingLeft); 286 const lineAtTarget = line.getBoundingClientRect().width - padPx * 2 + pad * fontPx * 2; 287 const room = title.clientWidth - lineAtTarget - 16; 288 return Math.max(BASE_PAD, Math.min(0.7, pad + room / 2 / fontPx)); 289 } 290 let spawnAt = performance.now() + 1400; 291 let visible = true; 292 let last = performance.now(); 293 294 function size() { 295 const dpr = Math.min(2, window.devicePixelRatio || 1); 296 w = hero.clientWidth; 297 h = hero.clientHeight; 298 canvas.width = w * dpr; 299 canvas.height = h * dpr; 300 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 301 } 302 size(); 303 window.addEventListener('resize', size); 304 if ('IntersectionObserver' in window) { 305 new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }).observe(hero); 306 } 307 308 function spawn(now) { 309 const fromTop = Math.random() < 0.3; 310 const s = Math.max(6, Math.min(12, w / 150)) * (0.8 + Math.random() * 0.4); 311 leads.push(fromTop 312 ? { x: w * (0.45 + Math.random() * 0.5), y: -s, vx: -(10 + Math.random() * 20), vy: 30 + Math.random() * 30, s } 313 : { x: w + s, y: h * (0.05 + Math.random() * 0.7), vx: -(40 + Math.random() * 50), vy: (Math.random() - 0.5) * 20, s }); 314 spawnAt = now + (reduceMotion ? 1200 : 300 + Math.random() * 450); 315 } 316 317 function frame(now) { 318 const dt = Math.min(0.05, (now - last) / 1000); 319 last = now; 320 if (visible && !document.hidden) { 321 if (now > spawnAt && leads.length < MAX_LEADS) spawn(now); 322 const heroRect = hero.getBoundingClientRect(); 323 const b = block.getBoundingClientRect(); 324 const bx = b.left - heroRect.left; 325 const by = b.top - heroRect.top; 326 const cx = bx + b.width / 2; 327 const cy = by + b.height / 2; 328 const strength = 60000 * (w / 700); 329 330 ctx.clearRect(0, 0, w, h); 331 ctx.fillStyle = '#fff'; 332 leads.forEach(p => { 333 const dx = cx - p.x; 334 const dy = cy - p.y; 335 const dist = Math.hypot(dx, dy) || 1; 336 const pull = Math.min(1200, strength / dist); 337 p.vx = (p.vx + (dx / dist) * pull * dt) * 0.985; 338 p.vy = (p.vy + (dy / dist) * pull * dt) * 0.985; 339 p.x += p.vx * dt * pace; 340 p.y += p.vy * dt * pace; 341 if (p.x > bx && p.x < bx + b.width && p.y > by && p.y < by + b.height) { 342 p.dead = true; 343 absorbed++; 344 pad = Math.min(pad + 0.03, maxPad()); 345 block.style.setProperty('--pad', pad.toFixed(3) + 'em'); 346 } else if (p.x < -40 || p.y > h + 40) { 347 p.dead = true; 348 } 349 ctx.fillRect(p.x - p.s / 2, p.y - p.s / 2, p.s, p.s); 350 }); 351 leads = leads.filter(p => !p.dead); 352 if (absorbed >= RESET_AFTER) { 353 absorbed = 0; 354 pad = BASE_PAD; 355 block.style.setProperty('--pad', pad + 'em'); 356 spawnAt = now + 1600; 357 } 358 } 359 requestAnimationFrame(frame); 360 } 361 requestAnimationFrame(frame); 362 } 363 364 // ---------- Mission statement: split into words for the scroll fill ---------- 365 const scrub = document.querySelector('[data-scrub]'); 366 let scrubWords = []; 367 if (scrub) { 368 // Words stay in the accessibility tree; the fill is purely visual 369 const text = scrub.textContent.trim(); 370 scrub.innerHTML = text.split(/\s+/).map(w => '<span class="h-word">' + w + '</span>').join(' '); 371 scrubWords = Array.from(scrub.querySelectorAll('.h-word')); 372 } 373 374 // ---------- Scroll-linked effects ---------- 375 const track = document.querySelector('.h-marquee-track'); 376 const inset = document.querySelector('[data-inset]'); 377 const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); 378 const BASE_SPEED = reduceMotion ? 18 : 48; 379 let offset = 0; 380 let direction = 1; 381 let boost = 0; 382 let lastY = window.scrollY; 383 let lastT = performance.now(); 384 385 function frame(now) { 386 const dt = Math.min(64, now - lastT) / 1000; 387 lastT = now; 388 const y = window.scrollY; 389 const dy = y - lastY; 390 lastY = y; 391 const vh = window.innerHeight; 392 393 if (track) { 394 if (dy !== 0) direction = dy > 0 ? 1 : -1; 395 if (!reduceMotion) boost = Math.max(boost * 0.92, Math.min(Math.abs(dy) * 22, 900)); 396 const half = track.scrollWidth / 2; 397 offset = (offset + direction * (BASE_SPEED + boost) * dt) % half; 398 if (offset < 0) offset += half; 399 track.style.transform = 'translate3d(' + (-offset) + 'px,0,0)'; 400 } 401 402 if (scrubWords.length) { 403 const r = scrub.getBoundingClientRect(); 404 const p = clamp((vh * 0.85 - r.top) / (r.height + vh * 0.35), 0, 1); 405 const lit = Math.round(p * scrubWords.length); 406 scrubWords.forEach((w, n) => w.classList.toggle('is-on', n < lit)); 407 } 408 409 if (inset && !reduceMotion) { 410 const r = inset.getBoundingClientRect(); 411 const p = clamp((vh - r.top) / (vh * 0.7), 0, 1); 412 const max = Math.min(64, window.innerWidth * 0.05); 413 inset.style.setProperty('--inset', ((1 - p) * max).toFixed(1) + 'px'); 414 } 415 416 requestAnimationFrame(frame); 417 } 418 if (track || scrubWords.length || inset) requestAnimationFrame(frame); 419 420 // ---------- Footer year ---------- 421 const year = document.getElementById('year'); 422 if (year) year.textContent = new Date().getFullYear(); 423})();
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.