/* ================================================ FRAXLE.NET — main.js ================================================ */ // --- Scroll position: always start at top unless coming from form --- if (history.scrollRestoration) { history.scrollRestoration = 'manual'; } (function () { var params = new URLSearchParams(window.location.search); if (!params.has('sent') && !params.has('error') && !params.has('package')) { window.scrollTo(0, 0); if (window.location.hash) { history.replaceState(null, '', window.location.pathname); } } })(); // --- Pre-fill contact form when arriving from a pricing package --- (function () { var params = new URLSearchParams(window.location.search); var pkg = params.get('package'); if (!pkg) return; var MESSAGES = { 'first-spark': "Hi Danny! I'm interested in The First Spark website package (from £250). A little about my project:\n\n", 'enchanted-workshop': "Hi Danny! I'm interested in The Enchanted Workshop website package (£750–£1,500). A little about my project:\n\n", 'arcane-forge': "Hi Danny! I'm interested in The Arcane Forge website package (£1,500–£3,000). A little about my project:\n\n", 'merchants-portal': "Hi Danny! I'm interested in The Merchant's Portal ecommerce package (£3,000–£5,000). A little about my project:\n\n", 'grand-emporium': "Hi Danny! I'm interested in The Grand Emporium ecommerce package (£5,000–£7,500+). A little about my project:\n\n", 'wizards-bench': "Hi Danny! I'd like some ad-hoc tech support from The Wizard's Bench (£40/hour). Here's what I need help with:\n\n", 'minor-ward': "Hi Danny! I'm interested in the Minor Ward care plan (£10/month). A little about my website:\n\n", 'greater-ward': "Hi Danny! I'm interested in the Greater Ward care plan (£25/month). A little about my website:\n\n", 'merchants-ward': "Hi Danny! I'm interested in the Merchant's Ward care plan (£50/month). A little about my store:\n\n", 'the-cantrip': "Hi Danny! I'm interested in The Cantrip web app (£750–£2,000). A little about what I need:\n\n", 'grand-working': "Hi Danny! I'm interested in The Grand Working custom app (£3,000–£10,000+). A little about my project:\n\n", 'sigil-workshop': "Hi Danny! I'm interested in The Sigil Workshop graphics & branding (from £150). A little about what I need:\n\n", 'moving-picture': "Hi Danny! I'm interested in The Moving Picture video production (from £350). A little about my project:\n\n", 'third-dimension': "Hi Danny! I'm interested in The Third Dimension 3D & animation (from £300). A little about my project:\n\n", 'sonic-alchemy': "Hi Danny! I'm interested in Sonic Alchemy audio production (from £200). A little about my project:\n\n" }; var field = document.getElementById('message'); if (field && MESSAGES[pkg]) { field.value = MESSAGES[pkg]; } var contact = document.getElementById('contact'); if (contact) { requestAnimationFrame(function () { contact.scrollIntoView(); }); } if (field && MESSAGES[pkg]) { field.focus(); var len = field.value.length; try { field.setSelectionRange(len, len); } catch (e) {} } })(); // --- Hero starfield --- (function () { const container = document.getElementById('hero-stars'); if (!container) return; const style = document.createElement('style'); style.textContent = ` @keyframes twinkle { 0%, 100% { opacity: 0.2; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1.3); } } @keyframes shootingStar { 0% { transform: translateX(0) translateY(0); opacity: 1; } 100% { transform: translateX(200px) translateY(80px); opacity: 0; } } `; document.head.appendChild(style); // Static stars for (let i = 0; i < 160; i++) { const s = document.createElement('div'); const size = Math.random() * 2.5 + 0.5; s.style.cssText = [ 'position:absolute', `width:${size}px`, `height:${size}px`, `background:rgba(255,255,255,${(Math.random() * 0.5 + 0.3).toFixed(2)})`, 'border-radius:50%', `top:${(Math.random() * 100).toFixed(2)}%`, `left:${(Math.random() * 100).toFixed(2)}%`, `animation:twinkle ${(Math.random() * 3 + 2).toFixed(1)}s ease-in-out ${(Math.random() * 5).toFixed(1)}s infinite`, ].join(';'); container.appendChild(s); } // Gold sparkles for (let i = 0; i < 28; i++) { const s = document.createElement('div'); s.style.cssText = [ 'position:absolute', 'width:2px', 'height:3px', 'background:rgba(251,191,36,0.9)', 'border-radius:50%', `top:${(Math.random() * 100).toFixed(2)}%`, `left:${(Math.random() * 100).toFixed(2)}%`, `animation:twinkle ${(Math.random() * 2 + 1.5).toFixed(1)}s ease-in-out ${(Math.random() * 4).toFixed(1)}s infinite`, ].join(';'); container.appendChild(s); } })(); // --- Nav scroll effect --- const header = document.getElementById('site-header'); window.addEventListener('scroll', function () { header.classList.toggle('scrolled', window.scrollY > 60); }, { passive: true }); // --- Hamburger menu --- const hamburger = document.getElementById('nav-hamburger'); const navLinks = document.getElementById('nav-links'); if (hamburger && navLinks) { hamburger.addEventListener('click', function () { const isOpen = navLinks.classList.toggle('open'); hamburger.classList.toggle('open', isOpen); hamburger.setAttribute('aria-expanded', isOpen); document.body.style.overflow = isOpen ? 'hidden' : ''; }); // Close on link click navLinks.querySelectorAll('a').forEach(function (link) { link.addEventListener('click', function () { navLinks.classList.remove('open'); hamburger.classList.remove('open'); hamburger.setAttribute('aria-expanded', 'false'); document.body.style.overflow = ''; }); }); // Close on outside click document.addEventListener('click', function (e) { if (!hamburger.contains(e.target) && !navLinks.contains(e.target)) { navLinks.classList.remove('open'); hamburger.classList.remove('open'); hamburger.setAttribute('aria-expanded', 'false'); document.body.style.overflow = ''; } }); } // --- Scroll reveal --- const reveals = document.querySelectorAll('.reveal'); if ('IntersectionObserver' in window) { const observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { entry.target.classList.add('visible'); observer.unobserve(entry.target); } }); }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }); reveals.forEach(function (el) { observer.observe(el); }); } else { // Fallback: show all reveals.forEach(function (el) { el.classList.add('visible'); }); } // --- Magic wand button effect --- var WAND_CHARS = ['✦', '✧', '✶', '✦', '✧']; var WAND_COLORS = [ 'rgba(255,255,255,1)', 'rgba(251,191,36,1)', 'rgba(253,224,120,1)', 'rgba(255,255,255,0.88)', ]; function spawnSparkleCluster(btn, x, btnH) { var n = 14 + Math.floor(Math.random() * 13); // 16–28 per cluster for (var i = 0; i < n; i++) { var s = document.createElement('span'); s.className = 'btn-sparkle'; var ox = (Math.random() - 0.5) * 40; // ±20px horizontal spread var fy = Math.random() * btnH - 7; // shifted up a few px var sz = (0.28 + Math.random() * 0.55).toFixed(2); var dur = (0.38 + Math.random() * 0.45).toFixed(3); var col = WAND_COLORS[Math.floor(Math.random() * WAND_COLORS.length)]; var ch = WAND_CHARS [Math.floor(Math.random() * WAND_CHARS.length)]; s.textContent = ch; s.style.cssText = [ 'left:' + (x + ox).toFixed(1) + 'px', 'top:' + fy.toFixed(1) + 'px', 'font-size:' + sz + 'rem', 'color:' + col, 'text-shadow:0 0 5px ' + col + ',0 0 11px rgba(255,255,255,0.35)', '--dur:' + dur + 's', ].join(';'); btn.appendChild(s); (function (el, d) { setTimeout(function () { if (el.parentNode) el.remove(); }, (parseFloat(d) + 0.1) * 1000); }(s, dur)); } } function triggerWandEffect(btn) { if (btn.dataset.wandActive) return; btn.dataset.wandActive = '1'; var btnW = btn.offsetWidth; var btnH = btn.offsetHeight; // Scale swipe speed and sparkle density to the button width so narrow and wide buttons // feel consistent: constant px/ms sweep speed, and constant spacing between sparkle bursts. var SPEED = 0.73; // px per ms the swipe travels var SPACING = 15; // px between sparkle bursts var duration = Math.max(160, Math.min(btnW / SPEED, 720)); // clamp for tiny / huge buttons var CLUSTERS = Math.max(3, Math.round(btnW / SPACING)); // fewer bursts on narrow buttons var start = null; var lastCluster = -1; function ease(t) { return t < 0.5 ? 2*t*t : -1 + (4 - 2*t) * t; } function frame(ts) { if (!start) start = ts; var elapsed = ts - start; var p = Math.min(elapsed / duration, 1); var x = p * btnW; var ci = Math.floor(p * CLUSTERS); if (ci > lastCluster) { lastCluster = ci; spawnSparkleCluster(btn, x, btnH); } if (p < 1) { requestAnimationFrame(frame); } else { setTimeout(function () { delete btn.dataset.wandActive; }, 60); } } requestAnimationFrame(frame); } document.querySelectorAll('.btn').forEach(function (btn) { btn.addEventListener('mouseenter', function () { triggerWandEffect(btn); }); }); // --- Stagger cards/rows within grids and lists --- document.querySelectorAll('.services-grid, .projects-list').forEach(function (grid) { grid.querySelectorAll('.reveal').forEach(function (card, i) { card.style.transitionDelay = (i * 0.1) + 's'; }); }); // --- Smooth active nav highlight on scroll --- const sections = document.querySelectorAll('section[id]'); const navAnchors = document.querySelectorAll('.nav-links a[href^="#"]'); window.addEventListener('scroll', function () { let current = ''; sections.forEach(function (sec) { if (window.scrollY >= sec.offsetTop - 120) { current = sec.getAttribute('id'); } }); navAnchors.forEach(function (a) { a.style.color = a.getAttribute('href') === '#' + current ? 'var(--white)' : ''; }); }, { passive: true }); // --- Mascot "transporter" teleport-in --- (function () { var imgs = document.querySelectorAll('.mascot-teleport'); if (!imgs.length) return; var REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (REDUCED) { imgs.forEach(function (im) { im.classList.remove('mascot-teleport'); im.style.opacity = '1'; }); return; } var T_CHARS = ['✦', '✧', '✶', '✩', '✨', '⋆']; var T_COLORS = ['rgba(255,255,255,1)', 'rgba(251,191,36,1)', 'rgba(253,224,120,1)', 'rgba(45,212,191,1)']; // white, gold, teal function teleSpark(x, y, life) { var s = document.createElement('span'); var size = (0.45 + Math.random() * 0.8).toFixed(2); var col = T_COLORS[(Math.random() * T_COLORS.length) | 0]; s.textContent = T_CHARS[(Math.random() * T_CHARS.length) | 0]; s.style.cssText = 'position:absolute;left:' + x + 'px;top:' + y + 'px;z-index:90;pointer-events:none;line-height:1;' + 'font-size:' + size + 'rem;color:' + col + ';text-shadow:0 0 4px ' + col + ',0 0 2px rgba(255,255,255,0.95);' + 'will-change:transform,opacity;'; document.body.appendChild(s); s.animate([ { transform: 'translate(-50%,-50%) scale(0)' }, { transform: 'translate(-50%,-50%) scale(' + size + ')', offset: 0.08 }, // pop in almost instantly { transform: 'translate(-50%,-50%) scale(' + size + ')', offset: 0.6 }, { transform: 'translate(-50%,-50%) scale(0)' } ], { duration: life, easing: 'ease-out' }).onfinish = function () { s.remove(); }; } // Convert a viewport rect into document-space, so absolutely-positioned sparkles // stay anchored to the page (and scroll with it) instead of following the viewport. function pageRect(rc) { var l = rc.left + window.scrollX, t = rc.top + window.scrollY; return { left: l, top: t, width: rc.width, height: rc.height, right: l + rc.width, bottom: t + rc.height }; } function teleport(img, startDelay) { var r = pageRect(img.getBoundingClientRect()); var START_DELAY = (typeof startDelay === 'number') ? startDelay : 500; // pause before anything happens var CHARGE = 1000; // sparkles gather along the bottom before the figure grows var GROW = 2200; // the bottom-up materialise var baseShadow = 'drop-shadow(0 8px 24px rgba(0,0,0,0.35))'; function ease(t) { return t * t; } // ease-in: gentle at the bottom, no slow-down toward the top img.dataset.teleBusy = '1'; // Keep it invisible (clipped) but opaque from the outset, so nothing flashes during the delay img.style.opacity = '1'; img.style.webkitClipPath = img.style.clipPath = 'inset(100% 0 0 0)'; setTimeout(function () { var start = null; function frame(ts) { if (!start) start = ts; var el = ts - start; // --- Charge phase: just sparkles building along the bottom edge --- if (el < CHARGE) { var cp = el / CHARGE; // 0..1 through the charge var cn = Math.round(1 + 5 * cp); // build up density toward the grow for (var c = 0; c < cn; c++) { var cx = r.left + Math.random() * r.width; var cy = r.bottom - Math.random() * r.height * 0.06; // hug the base teleSpark(cx, cy, 480 + Math.random() * 360); } requestAnimationFrame(frame); return; } // --- Grow phase: bottom-up reveal + rising beam --- var raw = Math.min((el - CHARGE) / GROW, 1); var p = ease(raw); var clip = ((1 - p) * 100).toFixed(2); img.style.webkitClipPath = img.style.clipPath = 'inset(' + clip + '% 0 0 0)'; // Bright near-white energy wash that holds, then settles to the normal drop-shadow var hx = 1 - p; var hold = hx * hx * hx * (hx * (hx * 6 - 15) + 10); // smootherstep(1-p): holds, then fades smoothly var bright = (1 + 1.8 * hold).toFixed(2); // up to ~2.8 — near white-hot var wash = (1 - 0.55 * hold).toFixed(2); // desaturate early for a whiter look var glow = (0.85 * hold).toFixed(2); img.style.filter = baseShadow + ' drop-shadow(0 0 16px rgba(255,255,255,' + glow + '))' + ' brightness(' + bright + ') saturate(' + wash + ')'; // Sparkle beam sitting on the rising materialisation edge var lineY = r.bottom - p * r.height; if (raw < 0.98) { var n = Math.round(1 + 8 * (1 - p)); // dense at the start (beam low), sparse as it rises for (var i = 0; i < n; i++) { var x = r.left + Math.random() * r.width; var y = (Math.random() < 0.7) ? lineY + (Math.random() - 0.5) * r.height * 0.12 // tight on the beam line : lineY - Math.random() * Math.max(0, lineY - r.top); // shimmer in the un-formed area above y = Math.max(r.top, Math.min(r.bottom, y)); var vf = (y - r.top) / r.height; // 0 at top, 1 at bottom teleSpark(x, y, (480 + Math.random() * 360) * (0.4 + 0.6 * vf)); // fade quicker higher up } } if (raw < 1) { requestAnimationFrame(frame); } else { img.style.webkitClipPath = img.style.clipPath = ''; img.style.filter = ''; // CSS drop-shadow returns img.classList.remove('mascot-teleport'); img.style.opacity = '1'; delete img.dataset.teleBusy; } } requestAnimationFrame(frame); }, START_DELAY); } // Reverse "beam out": dissolve top-down into a puff of sparkles, then stay hidden function dematerialize(img, done) { var r = pageRect(img.getBoundingClientRect()); var DUR = 800; var baseShadow = 'drop-shadow(0 8px 24px rgba(0,0,0,0.35))'; var start = null; function frame(ts) { if (!start) start = ts; var p = Math.min((ts - start) / DUR, 1); // Dissolve away from the top downwards img.style.webkitClipPath = img.style.clipPath = 'inset(' + (p * 100).toFixed(2) + '% 0 0 0)'; // Energy flash that builds as it beams out var bright = (1 + 1.2 * p).toFixed(2); var glow = (0.7 * p).toFixed(2); img.style.filter = baseShadow + ' drop-shadow(0 0 14px rgba(255,255,255,' + glow + ')) brightness(' + bright + ')'; // Sparkles riding the dissolving edge var lineY = r.top + p * r.height; if (p < 0.98) { for (var i = 0; i < 6; i++) { var x = r.left + Math.random() * r.width; var y = lineY + (Math.random() - 0.5) * r.height * 0.1; y = Math.max(r.top, Math.min(r.bottom, y)); teleSpark(x, y, 480 + Math.random() * 360); } } if (p < 1) { requestAnimationFrame(frame); } else { // A final puff of sparkles where it stood for (var j = 0; j < 16; j++) { teleSpark(r.left + Math.random() * r.width, r.top + Math.random() * r.height, 420 + Math.random() * 360); } img.style.opacity = '0'; // stay hidden during the wait img.style.webkitClipPath = img.style.clipPath = ''; img.style.filter = ''; if (done) done(); } } requestAnimationFrame(frame); } var io = new IntersectionObserver(function (entries) { entries.forEach(function (e) { if (e.isIntersecting) { io.unobserve(e.target); teleport(e.target); } }); }, { threshold: 0.25 }); imgs.forEach(function (im) { io.observe(im); // Click to beam out, then re-materialise after ~3s im.style.cursor = 'pointer'; im.addEventListener('click', function () { if (im.dataset.teleBusy) return; // ignore while an effect is already running im.dataset.teleBusy = '1'; dematerialize(im, function () { setTimeout(function () { teleport(im, 0); }, 3000); }); }); }); })();