Files
fraxlenet/js/main.js
T
FraxleandClaude Opus 4.8 9303dc7f4d Add estimates package modals, magical animations and pricing links
- estimates: per-package detail modals with min/max breakdown and specs,
  opening with a wand-flick spiral spring and sparkle trail, closing with a
  perimeter sparkle burst
- home: pricing teaser section linking to estimates; contact form pre-fills
  a message when arriving from a package "Enquire" link
- mascot: Star Trek-style teleport-in (delay, sparkle charge, bottom-up
  materialise with near-white energy wash and rising sparkle beam)
- buttons: wand-sparkle swipe speed and density now scale with button width
- themed scrollbars site-wide with stable gutter to stop modal scroll shift

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:38:41 +01:00

385 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ================================================
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"
};
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); // 1628 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:fixed;left:' + x + 'px;top:' + y + 'px;z-index:1200;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.3 },
{ transform: 'translate(-50%,-50%) scale(' + size + ')', offset: 0.7 },
{ transform: 'translate(-50%,-50%) scale(0)' }
], { duration: life, easing: 'ease-in-out' }).onfinish = function () { s.remove(); };
}
function teleport(img) {
var r = img.getBoundingClientRect();
var START_DELAY = 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
// 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';
}
}
requestAnimationFrame(frame);
}, START_DELAY);
}
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); });
})();