1/** Site behavior: navigation, scroll reveal, counters, tech marquee */ 2(function () { 3 'use strict'; 4 5 const MOBILE = 860; 6 7 /* ---------- Navigation ---------- */ 8 9 function normalize(path) { 10 if (!path || path === '/index.html') return '/'; 11 return path.replace(/\/+$/, '') || '/'; 12 } 13 14 function initNav() { 15 const current = normalize(window.location.pathname); 16 document.querySelectorAll('.nav-link[href]').forEach((link) => { 17 const target = normalize(new URL(link.getAttribute('href'), window.location.origin).pathname); 18 const active = target === current; 19 link.classList.toggle('active', active); 20 if (active) link.setAttribute('aria-current', 'page'); 21 }); 22 23 const toggle = document.querySelector('.menu-toggle'); 24 const list = document.querySelector('.nav-list'); 25 const nav = document.querySelector('.navbar'); 26 if (!toggle || !list || !nav) return; 27 28 function setOpen(open) { 29 list.classList.toggle('active', open); 30 toggle.classList.toggle('active', open); 31 document.body.style.overflow = open ? 'hidden' : ''; 32 toggle.setAttribute('aria-expanded', String(open)); 33 toggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); 34 } 35 36 toggle.addEventListener('click', () => setOpen(!list.classList.contains('active'))); 37 38 document.addEventListener('click', (event) => { 39 if (window.innerWidth > MOBILE) return; 40 if (!nav.contains(event.target)) setOpen(false); 41 }); 42 43 document.addEventListener('keydown', (event) => { 44 if (event.key === 'Escape') setOpen(false); 45 }); 46 47 list.querySelectorAll('.nav-link').forEach((link) => { 48 link.addEventListener('click', () => { 49 if (window.innerWidth <= MOBILE) setOpen(false); 50 }); 51 }); 52 53 window.addEventListener('resize', () => { 54 if (window.innerWidth > MOBILE) setOpen(false); 55 }); 56 } 57 58 /* ---------- Scroll reveal ---------- */ 59 60 function initReveal() { 61 const targets = document.querySelectorAll('.reveal, .reveal-group'); 62 if (!targets.length) return; 63 64 if (!('IntersectionObserver' in window)) { 65 targets.forEach((el) => el.classList.add('visible')); 66 return; 67 } 68 69 const observer = new IntersectionObserver((entries) => { 70 entries.forEach((entry) => { 71 if (entry.isIntersecting) { 72 entry.target.classList.add('visible'); 73 observer.unobserve(entry.target); 74 } 75 }); 76 }, { threshold: 0.12, rootMargin: '0px 0px -48px 0px' }); 77 78 targets.forEach((el) => observer.observe(el)); 79 } 80 81 /* ---------- Count-up stats ---------- */ 82 83 function animateCount(el) { 84 const target = parseFloat(el.dataset.count); 85 if (!Number.isFinite(target)) return; 86 const decimals = (el.dataset.count.split('.')[1] || '').length; 87 const duration = 1400; 88 const start = performance.now(); 89 90 function frame(now) { 91 const progress = Math.min((now - start) / duration, 1); 92 const eased = 1 - Math.pow(1 - progress, 3); 93 el.textContent = (target * eased).toFixed(decimals); 94 if (progress < 1) requestAnimationFrame(frame); 95 } 96 97 requestAnimationFrame(frame); 98 } 99 100 function initCounters() { 101 const counters = document.querySelectorAll('[data-count]'); 102 if (!counters.length) return; 103 104 const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 105 if (reduced || !('IntersectionObserver' in window)) { 106 counters.forEach((el) => { el.textContent = el.dataset.count; }); 107 return; 108 } 109 110 const observer = new IntersectionObserver((entries) => { 111 entries.forEach((entry) => { 112 if (entry.isIntersecting) { 113 animateCount(entry.target); 114 observer.unobserve(entry.target); 115 } 116 }); 117 }, { threshold: 0.4 }); 118
119 counters.forEach((el) => observer.observe(el)); 120 } 121 122 /* ---------- Tech marquee ---------- */ 123 124 const TECH = [ 125 'Shopify', 'Next.js', 'React', 'TypeScript', 'WordPress', 'Tailwind CSS', 126 'JavaScript', 'Node.js', 'Klaviyo', 'Bloomreach', 'HTML/CSS', 'Liquid', 'Drupal' 127 ]; 128 129 function initMarquee() { 130 document.querySelectorAll('[data-tech-marquee]').forEach((container) => { 131 container.textContent = ''; 132 const track = document.createElement('div'); 133 track.className = 'marquee'; 134 for (let i = 0; i < 2; i += 1) { 135 const group = document.createElement('div'); 136 group.className = 'marquee-group'; 137 if (i === 1) group.setAttribute('aria-hidden', 'true'); 138 TECH.forEach((name) => { 139 const item = document.createElement('span'); 140 item.className = 'marquee-item'; 141 item.textContent = name; 142 group.appendChild(item); 143 }); 144 track.appendChild(group); 145 } 146 container.appendChild(track); 147 }); 148 } 149 150 /* ---------- Tech pills (static grid variant) ---------- */ 151 152 function initTechPills() { 153 document.querySelectorAll('[data-tech-pills]').forEach((container) => { 154 container.textContent = ''; 155 TECH.forEach((name) => { 156 const chip = document.createElement('span'); 157 chip.className = 'pill'; 158 chip.textContent = name; 159 container.appendChild(chip); 160 }); 161 }); 162 } 163 164 /* ---------- Boot ---------- */ 165 166 function init() { 167 initNav(); 168 initReveal(); 169 initCounters(); 170 initMarquee(); 171 initTechPills(); 172 } 173 174 if (document.querySelector('.navbar')) { 175 init(); 176 } else { 177 document.addEventListener('shell:ready', init, { once: true }); 178 } 179})();
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.