PageSourceSearch

https://xpathlabs.ai/home.js

js xpathlabs.ai collected 2026-10-05 13:20:28 UTC 18,245 bytes, 423 lines download raw bytes

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.