457 lines
20 KiB
JavaScript
457 lines
20 KiB
JavaScript
/* ================================================
|
||
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);
|
||
});
|
||
});
|
||
});
|
||
})();
|