1/** 2 * Remodely AI â full-body UX effects layer 3 * Parallax stardust, scroll-reveal, mouse-tilt iso, scroll progress. 4 * 5 * All effects respect prefers-reduced-motion. Each effect feature-detects 6 * and is fully optional â if any module fails, the others keep running. 7 */ 8(() => { 9 'use strict'; 10 11 const prefersReducedMotion = window.matchMedia( 12 '(prefers-reduced-motion: reduce)' 13 ).matches; 14 15 /* --------------------------------------------------------------- 16 1. SCROLL PROGRESS BAR (top of viewport, orange, 2px) 17 --------------------------------------------------------------- */ 18 function initScrollProgress() { 19 const bar = document.createElement('div'); 20 bar.id = 'rmd-scroll-progress'; 21 document.body.appendChild(bar); 22 23 let ticking = false; 24 const update = () => { 25 const h = document.documentElement; 26 const scrolled = h.scrollTop; 27 const max = h.scrollHeight - h.clientHeight; 28 const pct = max > 0 ? (scrolled / max) * 100 : 0; 29 bar.style.transform = `scaleX(${pct / 100})`; 30 ticking = false; 31 }; 32 33 window.addEventListener( 34 'scroll', 35 () => { 36 if (!ticking) { 37 requestAnimationFrame(update); 38 ticking = true; 39 } 40 }, 41 { passive: true } 42 ); 43 } 44 45 /* --------------------------------------------------------------- 46 2. PARALLAX STARFIELD (3 layers â deep / mid / near) 47 Three independent star layers drift at different rates so 48 scrolling reveals real depth. Deep barely moves (far away), 49 mid medium, near fastest (foreground). 50 --------------------------------------------------------------- */ 51 function initParallaxStars() { 52 if (prefersReducedMotion) return; 53 // Mobile: skip parallax entirely. iOS Safari repaints fixed-position 54 // background-image layers poorly, and updating bg-position on every 55 // scroll frame for two full-viewport divs causes scroll jank on 56 // phones. The starfield divs are also display:none on <=768px via 57 // CSS, so this is just belt-and-suspenders. 58 if (window.matchMedia('(max-width: 768px)').matches) return; 59 60 const layerMid = document.querySelector('.starfield-mid'); 61 const layerNear = document.querySelector('.starfield-near'); 62 63 // Drive the deep layer (body::before) via a CSS variable so we 64 // don't have to manipulate the pseudo-element directly. 65 const deepStyle = document.createElement('style'); 66 deepStyle.textContent = 'body::before{background-position:0 var(--deep-y,0px)!important;}'; 67 document.head.appendChild(deepStyle); 68 69 let ticking = false; 70 const update = () => { 71 const y = window.scrollY; 72 document.body.style.setProperty('--deep-y', `${(-y * 0.10).toFixed(1)}px`); 73 if (layerMid) layerMid.style.backgroundPositionY = `${(-y * 0.30).toFixed(1)}px`; 74 if (layerNear) layerNear.style.backgroundPositionY = `${(-y * 0.55).toFixed(1)}px`; 75 ticking = false; 76 }; 77 78 window.addEventListener( 79 'scroll', 80 () => { 81 if (!ticking) { 82 requestAnimationFrame(update); 83 ticking = true; 84 } 85 }, 86 { passive: true } 87 ); 88 89 update(); 90 } 91 92 /* --------------------------------------------------------------- 93 3. SCROLL-REVEAL â fade-up sections / cards as they enter view 94 --------------------------------------------------------------- */ 95 function initScrollReveal() { 96 if (prefersReducedMotion) return; 97 if (!('IntersectionObserver' in window)) return; 98 99 // Tall content blocks (.pillar, .terminal-block) need an extra-low 100 // threshold or they never fire on long pages â leaving them 101 // permanently invisible. Use a tiny threshold and a fallback timer. 102 const targets = document.querySelectorAll( 103 '.paths__card, .journey__step, .dev-card, ' + 104 '.case-study-stat, .case-study-body, ' + 105 '.section-header, .why-card, .live-tool, .aria-stat' 106 ); 107 108 targets.forEach((el) => el.classList.add('rmd-reveal')); 109 110 // Failsafe: after 3 seconds, force-reveal anything still hidden so 111 // a missed observer call can never leave content invisible. 112 setTimeout(() => { 113 document.querySelectorAll('.rmd-reveal:not(.rmd-reveal--visible)').forEach((el) => { 114 el.classList.add('rmd-reveal--visible'); 115 }); 116 }, 3000); 117 118 const observer = new IntersectionObserver( 119 (entries) => {
120 entries.forEach((entry, i) => { 121 if (entry.isIntersecting) { 122 // Stagger by a small delay among siblings entering together 123 const sib = Array.from(entry.target.parentElement?.children || []).indexOf( 124 entry.target 125 ); 126 entry.target.style.transitionDelay = `${Math.min(sib, 6) * 60}ms`; 127 entry.target.classList.add('rmd-reveal--visible'); 128 observer.unobserve(entry.target); 129 } 130 }); 131 }, 132 { threshold: 0.12, rootMargin: '0px 0px -40px 0px' } 133 ); 134 135 targets.forEach((el) => observer.observe(el)); 136 } 137 138 /* --------------------------------------------------------------- 139 4. MOUSE-TILT on the hero iso illustration 140 --------------------------------------------------------------- */ 141 function initHeroTilt() { 142 if (prefersReducedMotion) return; 143 const iso = document.querySelector('.hero-iso'); 144 if (!iso) return; 145 146 const img = iso.querySelector('img'); 147 if (!img) return; 148 149 const heroSection = document.querySelector('.hero'); 150 if (!heroSection) return; 151 152 let raf = null; 153 let targetX = 0, 154 targetY = 0, 155 currentX = 0, 156 currentY = 0; 157 158 const animate = () => { 159 currentX += (targetX - currentX) * 0.08; 160 currentY += (targetY - currentY) * 0.08; 161 img.style.transform = `perspective(1200px) rotateY(${currentX}deg) rotateX(${currentY}deg)`; 162 163 if (Math.abs(targetX - currentX) > 0.05 || Math.abs(targetY - currentY) > 0.05) { 164 raf = requestAnimationFrame(animate); 165 } else { 166 raf = null; 167 } 168 }; 169 170 heroSection.addEventListener('mousemove', (e) => { 171 const r = heroSection.getBoundingClientRect(); 172 const x = (e.clientX - r.left) / r.width; 173 const y = (e.clientY - r.top) / r.height; 174 targetX = (x - 0.5) * 6; // up to ±3deg 175 targetY = (0.5 - y) * 5; 176 if (!raf) raf = requestAnimationFrame(animate); 177 }); 178 179 heroSection.addEventListener('mouseleave', () => { 180 targetX = 0; 181 targetY = 0; 182 if (!raf) raf = requestAnimationFrame(animate); 183 }); 184 } 185 186 /* --------------------------------------------------------------- 187 5. DOM ready boot 188 --------------------------------------------------------------- */ 189 const boot = () => { 190 try { initScrollProgress(); } catch (e) { console.warn('[fx] progress', e); } 191 try { initParallaxStars(); } catch (e) { console.warn('[fx] parallax', e); } 192 try { initScrollReveal(); } catch (e) { console.warn('[fx] reveal', e); } 193 try { initHeroTilt(); } catch (e) { console.warn('[fx] tilt', e); } 194 }; 195 196 if (document.readyState === 'loading') { 197 document.addEventListener('DOMContentLoaded', boot); 198 } else { 199 boot(); 200 } 201})(); 202 203/* =================================================================== 204 * EXTENDED EFFECTS LAYER (2026-04-29 wave 2) 205 * Cursor glow, count-up stats, magnetic CTAs, layered iso parallax. 206 * Each module is independent â failures don't cascade. 207 * =================================================================== */ 208(() => { 209 'use strict'; 210 211 const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 212 const fineCursor = window.matchMedia('(pointer: fine)').matches; 213 214 /* ----------------------------------------------------------------- 215 6. CURSOR GLOW â soft orange spotlight follows the mouse on dark 216 sections only. Auto-hides on touch devices. Throttled with rAF. 217 ----------------------------------------------------------------- */ 218 function initCursorGlow() { 219 if (reduced || !fineCursor) return; 220 221 const glow = document.createElement('div'); 222 glow.id = 'rmd-cursor-glow'; 223 glow.setAttribute('aria-hidden', 'true'); 224 document.body.appendChild(glow); 225 226 let tx = -200, ty = -200, cx = -200, cy = -200, raf = null, hidden = true; 227 228 const animate = () => { 229 cx += (tx - cx) * 0.18; 230 cy += (ty - cy) * 0.18; 231 glow.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`; 232 if (Math.abs(tx - cx) > 0.5 || Math.abs(ty - cy) > 0.5) { 233 raf = requestAnimationFrame(animate); 234 } else { 235 raf = null; 236 } 237 }; 238 239 document.addEventListener('mousemove', (e) => { 240 tx = e.clientX; 241 ty = e.clientY; 242 if (hidden) { 243 hidden = false;
244 glow.classList.add('rmd-cursor-glow--visible'); 245 } 246 if (!raf) raf = requestAnimationFrame(animate); 247 }, { passive: true }); 248 249 document.addEventListener('mouseleave', () => { 250 hidden = true; 251 glow.classList.remove('rmd-cursor-glow--visible'); 252 }); 253 } 254 255 /* ----------------------------------------------------------------- 256 7. COUNT-UP â animates numeric stats as they enter viewport 257 Targets: any element with [data-count-to] OR matching common 258 stat number selectors. Stops at the actual final value parsed 259 from the textContent so prefix/suffix is preserved. 260 ----------------------------------------------------------------- */ 261 function initCountUp() { 262 if (reduced) return; 263 if (!('IntersectionObserver' in window)) return; 264 265 // Find numeric tokens in case-study stats and why-section 266 const candidates = document.querySelectorAll( 267 '.case-study-stat__num, .why-number, .product-stat-number' 268 ); 269 270 const animate = (el) => { 271 const original = el.textContent.trim(); 272 // Extract first number-like token (handles "$48k+", "400+", "10+") 273 const match = original.match(/(\d+(?:[\.,]\d+)?)/); 274 if (!match) return; 275 const finalNum = parseFloat(match[1].replace(/,/g, '')); 276 if (!isFinite(finalNum) || finalNum === 0) return; 277 278 const prefix = original.slice(0, match.index); 279 const suffix = original.slice(match.index + match[0].length); 280 const duration = 1100; 281 const start = performance.now(); 282 283 const tick = (now) => { 284 const t = Math.min((now - start) / duration, 1); 285 const eased = 1 - Math.pow(1 - t, 3); 286 const v = finalNum * eased; 287 // Format like the original (preserve decimals if present) 288 const decimals = (match[1].split('.')[1] || '').length; 289 const formatted = decimals 290 ? v.toFixed(decimals) 291 : Math.round(v).toLocaleString(); 292 el.textContent = `${prefix}${formatted}${suffix}`; 293 if (t < 1) requestAnimationFrame(tick); 294 else el.textContent = original; 295 }; 296 297 el.textContent = `${prefix}0${suffix}`; 298 requestAnimationFrame(tick); 299 }; 300 301 const obs = new IntersectionObserver((entries) => { 302 entries.forEach((e) => { 303 if (e.isIntersecting) { 304 animate(e.target); 305 obs.unobserve(e.target); 306 } 307 }); 308 }, { threshold: 0.6 }); 309 310 candidates.forEach((el) => obs.observe(el)); 311 } 312 313 /* ----------------------------------------------------------------- 314 8. MAGNETIC CTAs â buttons subtly attract the cursor when near. 315 Adds a "this site feels alive" touch without being gimmicky. 316 ----------------------------------------------------------------- */ 317 function initMagnetic() { 318 if (reduced || !fineCursor) return; 319 320 const buttons = document.querySelectorAll( 321 '.hero-grader-inline__row button, .paths__cta, .lead-form-submit, .aria-cta, .case-study-cta, .aria-hero-btn' 322 ); 323 324 buttons.forEach((btn) => { 325 const radius = 80; 326 btn.style.transition = 'transform 0.3s cubic-bezier(0.2, 1, 0.3, 1)'; 327 328 btn.addEventListener('mousemove', (e) => { 329 const r = btn.getBoundingClientRect(); 330 const cx = r.left + r.width / 2; 331 const cy = r.top + r.height / 2; 332 const dx = e.clientX - cx; 333 const dy = e.clientY - cy; 334 const dist = Math.hypot(dx, dy); 335 if (dist < radius) { 336 const power = 1 - dist / radius; 337 btn.style.transform = `translate(${dx * 0.18 * power}px, ${dy * 0.18 * power}px)`; 338 } 339 }); 340 341 btn.addEventListener('mouseleave', () => { 342 btn.style.transform = ''; 343 }); 344 }); 345 } 346 347 /* ----------------------------------------------------------------- 348 9. ISO LAYER PARALLAX â the floating cards inside the hero iso 349 SVG each get their own subtle parallax offset on mousemove. 350 The SVG <g> groups have transforms applied; we wrap the image 351 so we can layer adjustments on top via CSS variables. 352 ----------------------------------------------------------------- */ 353 function initIsoParallax() { 354 if (reduced || !fineCursor) return; 355 const heroSection = document.querySelector('.hero'); 356 const iso = document.querySelector('.hero-iso'); 357 if (!heroSection || !iso) return; 358 359 heroSection.addEventListener('mousemove', (e) => { 360 const r = heroSection.getBoundingClientRect(); 361 const x = (e.clientX - r.left) / r.width - 0.5; 362 const y = (e.clientY - r.top) / r.height - 0.5; 363 // Set CSS variables; the iso container already has perspective 364 iso.style.setProperty('--iso-x', x.toFixed(3)); 365 iso.style.setProperty('--iso-y', y.toFixed(3)); 366 }); 367 368 heroSection.addEventListener('mouseleave', () => { 369 iso.style.setProperty('--iso-x', 0); 370 iso.style.setProperty('--iso-y', 0); 371 }); 372 } 373 374 const boot2 = () => {
375 try { initCursorGlow(); } catch (e) { console.warn('[fx2] cursor', e); } 376 try { initCountUp(); } catch (e) { console.warn('[fx2] count', e); } 377 try { initMagnetic(); } catch (e) { console.warn('[fx2] magnetic', e); } 378 try { initIsoParallax(); } catch (e) { console.warn('[fx2] iso-parallax', e); } 379 }; 380 381 if (document.readyState === 'loading') { 382 document.addEventListener('DOMContentLoaded', boot2); 383 } else { 384 boot2(); 385 } 386})(); 387 388/* =================================================================== 389 * STICKY CTA â auto-hide when footer is in view (2026-04-29) 390 * The user-prompted "we know you've reached the bottom, get out of 391 * the way" UX. IntersectionObserver on the <footer>; when it enters 392 * the viewport, add .sticky-cta--at-footer; remove on exit. 393 * =================================================================== */ 394(() => { 395 if (!('IntersectionObserver' in window)) return; 396 const start = () => { 397 const bar = document.getElementById('stickyCta'); 398 const footer = document.querySelector('footer.footer'); 399 if (!bar || !footer) return; 400 const obs = new IntersectionObserver((entries) => { 401 const inView = entries[0].isIntersecting; 402 bar.classList.toggle('sticky-cta--at-footer', inView); 403 }, { rootMargin: '0px 0px -40px 0px', threshold: 0 }); 404 obs.observe(footer); 405 }; 406 if (document.readyState === 'loading') { 407 document.addEventListener('DOMContentLoaded', start); 408 } else { 409 start(); 410 } 411})();
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.