Files
Meldwork/meldwork-landing/main.js
T
Ryder Sun 4f10fd14d9 site: simplify landing messaging and refresh collaboration stepper
- Drop the 'V4' suffix from Auto Discussion branding across copy and structured data
- Remove the Capabilities section; fold its boundary/shared-context messaging into existing sections
- Rework the scenario stepper with round-by-round agent activity states (Waiting/Thinking/Working/Done) and bilingual runtime labels
- Bump asset cache versions; update landing tests (13 pass via node --test)
2026-10-28 13:43:40 +08:00

957 lines
38 KiB
JavaScript

/* ============================================================
Meldwork Landing — interaction engine
React Bits ports in vanilla JS:
· SplitText (per-character staggered rise)
· DecryptedText (scramble → resolve)
· CountUp
· ScrollReveal (IntersectionObserver)
· SpotlightCard + TiltedCard (mouse position)
· LogoLoop (CSS marquee)
· ScrollVelocity (scroll-aware marquee speed)
· Magnet (mouse-follow on CTAs)
· GlareHover (CSS-driven shine)
· Stepper (scenario timeline)
· Accordion (FAQ)
· ScrollProgress + ScrollSpy
· Mobile menu + Escape + a11y
============================================================ */
(function () {
"use strict";
var doc = document;
var win = window;
var reduced = win.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* =========================================================
1. SPLIT TEXT — wrap each char of .data-split lines in <span class="ch">
========================================================= */
function splitText(el) {
if (!el || el.dataset.splitDone) return 1;
el.dataset.splitDone = "1";
var lines = el.querySelectorAll(".hl-line, [data-split-line]");
if (!lines.length) lines = [el];
var globalIndex = 0;
var baseDelay = parseFloat(el.dataset.splitDelay || "0.04");
lines.forEach(function (line) {
// capture existing inner HTML, wrap each word/char carefully
var raw = line.textContent;
line.textContent = "";
// Preserve words so spacing stays right; per-char inside each word.
var words = doc.documentElement.lang === "zh-CN"
? raw.match(/[A-Za-z0-9]+|[^\sA-Za-z0-9]|\s+/g) || []
: raw.split(/(\s+)/);
words.forEach(function (token) {
if (/^\s+$/.test(token)) {
line.appendChild(document.createTextNode(token));
return;
}
var wordSpan = document.createElement("span");
wordSpan.className = "word";
for (var i = 0; i < token.length; i++) {
var ch = document.createElement("span");
ch.className = "ch";
ch.textContent = token[i];
var delay = (globalIndex * baseDelay).toFixed(3);
ch.style.setProperty("--cd", delay + "s");
wordSpan.appendChild(ch);
globalIndex++;
}
line.appendChild(wordSpan);
});
});
return globalIndex;
}
// Hero headline
var headline = doc.getElementById("headline");
if (headline) {
var n = splitText(headline);
function playHeadline() {
requestAnimationFrame(function () {
requestAnimationFrame(function () { headline.classList.add("play"); });
});
}
if (reduced) headline.classList.add("play");
else if (doc.readyState === "complete") setTimeout(playHeadline, 80);
else win.addEventListener("load", playHeadline, { once: true });
// safety net
setTimeout(playHeadline, 1400);
}
// Final CTA title (same .data-split API)
var finalTitle = doc.querySelector(".final-title");
if (finalTitle) {
var totalChars = splitText(finalTitle);
if ("IntersectionObserver" in win) {
var ftObs = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
finalTitle.classList.add("play");
ftObs.disconnect();
}
});
}, { threshold: 0.4 });
ftObs.observe(finalTitle);
} else {
finalTitle.classList.add("play");
}
}
doc.addEventListener("meldwork:localechange", function () {
[headline, finalTitle].forEach(function (el) {
if (!el) return;
delete el.dataset.splitDone;
splitText(el);
el.classList.add("play");
});
});
/* =========================================================
2. DECRYPTED TEXT — scramble the eyebrow on entrance
========================================================= */
var CHARS = "!<>-_\\/[]{}—=+*^?#|01█▓▒░";
function decrypt(el) {
if (!el || el.dataset.decryptDone || reduced) {
if (el) el.dataset.decryptDone = "1";
return;
}
el.dataset.decryptDone = "1";
el.classList.add("is-scrambling");
var target = el.dataset.text || el.textContent;
var duration = 900;
var start = performance.now();
function frame(now) {
if (el.dataset.text && el.dataset.text !== target) return;
var p = Math.min((now - start) / duration, 1);
var revealCount = Math.floor(target.length * p);
var out = "";
for (var i = 0; i < target.length; i++) {
var ch = target[i];
if (i < revealCount || ch === " ") { out += ch; continue; }
// random for not-yet-revealed, keep punctuation
if (/[·\-,&]/.test(ch)) { out += ch; continue; }
out += CHARS[Math.floor(Math.random() * CHARS.length)];
}
el.textContent = out;
if (p < 1) requestAnimationFrame(frame);
else { el.textContent = target; el.classList.remove("is-scrambling"); }
}
requestAnimationFrame(frame);
}
var decryptEls = doc.querySelectorAll("[data-decrypt]");
decryptEls.forEach(function (el) {
function start() { decrypt(el); }
if (doc.readyState === "complete") setTimeout(start, 220);
else win.addEventListener("load", start, { once: true });
setTimeout(start, 1600);
});
/* =========================================================
3. SCROLL REVEAL — IntersectionObserver for [data-reveal]
Section eyebrows also get the DecryptedText treatment
the first time they scroll into view.
========================================================= */
var revealEls = doc.querySelectorAll("[data-reveal]");
if ("IntersectionObserver" in win && !reduced) {
var revealObs = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add("revealed");
if (entry.target.classList.contains("eyebrow")) decrypt(entry.target);
revealObs.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: "0px 0px -8% 0px" });
revealEls.forEach(function (el) { revealObs.observe(el); });
} else {
revealEls.forEach(function (el) { el.classList.add("revealed"); });
}
/* Workflow coral line draw — triggered when the steps scroll into view */
var stepsSection = doc.querySelector("[data-steps]");
if (stepsSection) {
if ("IntersectionObserver" in win && !reduced) {
var stepsObs = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
stepsSection.classList.add("revealed");
stepsObs.disconnect();
}
});
}, { threshold: 0.3 });
stepsObs.observe(stepsSection);
} else {
stepsSection.classList.add("revealed");
}
}
/* =========================================================
4. COUNT UP — hero stats
========================================================= */
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function countUp(el, index) {
var target = parseFloat(el.dataset.target);
var decimals = parseInt(el.dataset.decimals || "0", 10);
if (reduced) { el.textContent = target.toFixed(decimals); return; }
var duration = 1500 + index * 90;
setTimeout(function () {
var start = performance.now();
function frame(now) {
var p = Math.min((now - start) / duration, 1);
el.textContent = (target * easeOutCubic(p)).toFixed(decimals);
if (p < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}, 520 + index * 90);
}
var stats = doc.getElementById("stats");
var counted = false;
function runCounts() {
if (counted || !stats) return;
counted = true;
stats.querySelectorAll(".count").forEach(countUp);
}
if (stats) {
if ("IntersectionObserver" in win) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) { runCounts(); io.disconnect(); }
});
}, { threshold: 0.25 });
io.observe(stats);
} else runCounts();
}
/* =========================================================
5. SPOTLIGHT CARD — mouse position CSS vars
========================================================= */
doc.querySelectorAll(".spotlight").forEach(function (card) {
card.addEventListener("mousemove", function (e) {
var r = card.getBoundingClientRect();
card.style.setProperty("--sx", ((e.clientX - r.left) / r.width * 100) + "%");
card.style.setProperty("--sy", ((e.clientY - r.top) / r.height * 100) + "%");
});
card.addEventListener("mouseleave", function () {
card.style.setProperty("--sx", "50%");
card.style.setProperty("--sy", "50%");
});
});
/* =========================================================
6. TILTED CARD — mouse 3D rotate (cap-card, shot)
========================================================= */
function attachTilt(sel, max) {
doc.querySelectorAll(sel).forEach(function (card) {
var rect;
function onMove(e) {
if (!rect) rect = card.getBoundingClientRect();
var cx = rect.left + rect.width / 2;
var cy = rect.top + rect.height / 2;
var dx = (e.clientX - cx) / rect.width;
var dy = (e.clientY - cy) / rect.height;
var rx = (-dy * max).toFixed(2);
var ry = (dx * max).toFixed(2);
card.style.transform = "perspective(800px) rotateX(" + rx + "deg) rotateY(" + ry + "deg) translateZ(0)";
}
function onLeave() { card.style.transform = ""; rect = null; }
card.addEventListener("mouseenter", function () { rect = card.getBoundingClientRect(); });
card.addEventListener("mousemove", onMove);
card.addEventListener("mouseleave", onLeave);
});
}
attachTilt(".cap-card", 4);
attachTilt(".shot", 3);
/* =========================================================
7. MAGNET — CTAs gently follow the cursor
========================================================= */
doc.querySelectorAll(".magnet").forEach(function (btn) {
var strength = 14;
var rect;
function onMove(e) {
if (!rect) rect = btn.getBoundingClientRect();
var cx = rect.left + rect.width / 2;
var cy = rect.top + rect.height / 2;
var tx = ((e.clientX - cx) / rect.width) * strength;
var ty = ((e.clientY - cy) / rect.height) * strength;
btn.style.transform = "translate(" + tx.toFixed(2) + "px, " + ty.toFixed(2) + "px)";
}
function reset() { btn.style.transform = ""; rect = null; }
btn.addEventListener("mouseenter", function () { rect = btn.getBoundingClientRect(); });
btn.addEventListener("mousemove", onMove);
btn.addEventListener("mouseleave", reset);
});
/* =========================================================
8. SCROLL VELOCITY — rAF-driven marquee whose speed and
direction follow the scroll velocity (React Bits port)
========================================================= */
var velocityTrack = doc.querySelector("[data-velocity] .velocity-track");
if (velocityTrack) {
if (reduced) {
velocityTrack.style.transform = "none";
} else {
var vx = 0; // signed scroll velocity (px/frame, smoothed)
var vLastY = win.scrollY;
var vLastT = performance.now();
var vPos = 0; // current translate offset in px
function vTick(now) {
var dt = Math.min((now - vLastT) / 1000, 0.05);
vLastT = now;
var dy = win.scrollY - vLastY;
vLastY = win.scrollY;
// ease toward the instantaneous scroll velocity, then decay
vx += (dy * 2.4 - vx) * 0.1;
if (vx > 620) vx = 620;
if (vx < -620) vx = -620;
// base drift is always leftward; scrolling down accelerates,
// scrolling up hard enough briefly reverses the direction
vPos += (42 + vx) * dt;
var half = velocityTrack.scrollWidth / 2;
if (half > 0) {
vPos = ((vPos % half) + half) % half; // keep in [0, half)
velocityTrack.style.transform = "translateX(" + (-vPos).toFixed(2) + "px)";
}
requestAnimationFrame(vTick);
}
requestAnimationFrame(vTick);
}
}
/* =========================================================
9. SCENARIO STEPPER — auto-cycle the timeline
========================================================= */
var scenario = doc.querySelector("[data-scenario]");
if (scenario) {
var steps = scenario.querySelectorAll(".t-step");
var total = steps.length;
var current = 0;
var isInView = false;
var paused = false;
var autoTimer = null;
var scnMode = reduced ? "order" : "auto";
var feed = scenario.querySelector("[data-timeline]");
var seats = scenario.querySelectorAll("[data-agent]");
var railStatus = scenario.querySelector("[data-status]");
var roundLabel = scenario.querySelector("[data-round]");
var pauseButton = scenario.querySelector("[data-pause]");
var runStatus = scenario.querySelector("[data-run-status]");
var segBtns = scenario.querySelectorAll(".seg-btn");
var roundNames = ["Task", "Round 1 · Request", "Round 1 · Analysis", "Round 1 · Review", "Round 2 · Synthesis"];
var runningNames = ["Codex is working…", "Hermes and OpenCodeReview are working…",
"OpenCodeReview is working…", "Codex is bringing the feedback together…", "Codex is bringing the feedback together…"];
var paragraphs = Array.from(steps, function (step) { return step.querySelector("p"); });
var originals = paragraphs.map(function (p) { return { en: p.innerHTML, zh: p.dataset.zh }; });
var typed = 0;
var stage = "typing";
function translate(text) { return win.meldworkI18n.t(text); }
function markup(i) { return originals[i][doc.documentElement.lang === "zh-CN" ? "zh" : "en"]; }
function renderText(i, count) {
var p = paragraphs[i];
p.innerHTML = markup(i);
var walker = doc.createTreeWalker(p, NodeFilter.SHOW_TEXT);
var nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function (node) {
var chars = Array.from(node.nodeValue);
node.nodeValue = chars.slice(0, count).join("");
count = Math.max(0, count - chars.length);
});
}
function textLength(i) {
var template = doc.createElement("template");
template.innerHTML = markup(i);
return Array.from(template.content.textContent).length;
}
function setStep(idx, animate) {
current = Math.max(0, Math.min(total - 1, idx));
typed = animate ? 0 : textLength(current);
stage = animate ? "typing" : "reading";
render();
}
function render() {
steps.forEach(function (step, i) {
step.classList.toggle("is-visible", i <= current);
step.classList.toggle("is-active", i === current);
step.hidden = scnMode !== "order" && i > current;
if (i === current) step.setAttribute("aria-current", "step");
else step.removeAttribute("aria-current");
renderText(i, scnMode === "order" || i !== current ? textLength(i) : typed);
});
var states = [["Working", "Waiting", "Waiting"], ["Working", "Waiting", "Waiting"],
["Waiting", "Working", "Working"], ["Waiting", "Done", "Working"], ["Working", "Done", "Done"]][current].slice();
if (current === 1 && stage !== "typing") states = ["Waiting", stage === "thinking" ? "Thinking" : "Working", stage === "thinking" ? "Thinking" : "Working"];
if (current === 2 && stage !== "typing") states[1] = "Done";
if (current === 3 && stage !== "typing") states = ["Working", "Done", "Done"];
if (current === 4 && stage !== "typing") states = ["Done", "Done", "Done"];
if (scnMode === "order") states = ["Done", "Done", "Done"];
seats.forEach(function (seat, i) {
seat.dataset.state = states[i].toLowerCase();
seat.querySelector("[data-agent-status]").textContent = translate(states[i]);
});
scenario.dataset.phase = stage;
feed.scrollTop = scnMode === "order" ? 0 : feed.scrollHeight;
railStatus.textContent = (current + 1) + " / " + total;
roundLabel.textContent = translate(roundNames[current]);
scenario.querySelector("[data-prev]").disabled = current === 0;
scenario.querySelector("[data-next]").disabled = current === total - 1;
pauseButton.textContent = translate(paused ? "Resume" : "Pause");
pauseButton.setAttribute("aria-pressed", String(paused));
}
function stopAuto() {
clearTimeout(autoTimer);
autoTimer = null;
scenario.dataset.playback = "paused";
runStatus.hidden = true;
}
function scheduleNext() {
stopAuto();
if (!isInView || paused || reduced || doc.hidden || scnMode !== "auto") return;
scenario.dataset.playback = "playing";
runStatus.textContent = translate(current === 1 && stage === "typing" ? "Codex is working…" :
current === 1 && stage === "thinking" ? "Hermes and OpenCodeReview are thinking…" : runningNames[current]);
runStatus.hidden = false;
var delay = stage === "typing" ? 35 : stage === "thinking" ? 1300 : current === total - 1 ? 4200 : current === 1 ? 1800 : 1600;
autoTimer = setTimeout(function () {
if (stage === "typing") {
typed = Math.min(textLength(current), typed + (doc.documentElement.lang === "zh-CN" ? 1 : 2));
if (typed >= textLength(current)) stage = current === 1 ? "thinking" : "reading";
render();
} else if (stage === "thinking") {
stage = "reading";
render();
} else {
setStep(current === total - 1 ? 0 : current + 1, true);
}
scheduleNext();
}, delay);
}
function manualStep(idx) { paused = true; setStep(idx, false); scheduleNext(); }
scenario.querySelector("[data-prev]").addEventListener("click", function () { manualStep(current - 1); });
scenario.querySelector("[data-next]").addEventListener("click", function () { manualStep(current + 1); });
pauseButton.addEventListener("click", function () { paused = !paused; render(); scheduleNext(); });
scenario.addEventListener("keydown", function (event) {
if (scnMode !== "auto" || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
manualStep(event.key === "Home" ? 0 : event.key === "End" ? total - 1 : current + (event.key === "ArrowRight" ? 1 : -1));
});
function setMode(mode) {
scnMode = mode;
scenario.classList.toggle("is-order", mode === "order");
segBtns.forEach(function (button) {
var selected = button.dataset.mode === mode;
button.classList.toggle("is-on", selected);
button.setAttribute("aria-pressed", String(selected));
});
if (mode === "auto") setStep(current, !paused && !reduced);
else render();
scheduleNext();
}
segBtns.forEach(function (button) {
button.addEventListener("click", function () { setMode(button.dataset.mode); });
});
doc.addEventListener("visibilitychange", scheduleNext);
doc.addEventListener("meldwork:localechange", function () {
typed = stage === "typing" ? Math.min(typed, textLength(current)) : textLength(current);
render(); scheduleNext();
});
if ("IntersectionObserver" in win) {
new IntersectionObserver(function (entries) {
isInView = entries[0].isIntersecting;
scheduleNext();
}, { threshold: 0.15 }).observe(scenario);
} else isInView = true;
setMode(scnMode);
}
/* =========================================================
10. FAQ ACCORDION
========================================================= */
doc.querySelectorAll(".acc-item").forEach(function (item) {
var trigger = item.querySelector(".acc-trigger");
var panel = item.querySelector(".acc-panel");
trigger.addEventListener("click", function () {
var isOpen = item.classList.contains("is-open");
// close siblings
doc.querySelectorAll(".acc-item.is-open").forEach(function (o) {
if (o !== item) {
o.classList.remove("is-open");
o.querySelector(".acc-trigger").setAttribute("aria-expanded", "false");
o.querySelector(".acc-panel").style.maxHeight = "";
}
});
item.classList.toggle("is-open", !isOpen);
trigger.setAttribute("aria-expanded", String(!isOpen));
panel.style.maxHeight = !isOpen ? panel.scrollHeight + "px" : "";
});
});
/* =========================================================
11. NAV — scrolled state, scroll spy, mobile menu
========================================================= */
var nav = doc.getElementById("topnav");
function setNavScrolled() {
if (!nav) return;
if (win.scrollY > 8) nav.classList.add("scrolled");
else nav.classList.remove("scrolled");
}
setNavScrolled();
win.addEventListener("scroll", setNavScrolled, { passive: true });
var spyLinks = doc.querySelectorAll("[data-spy]");
var spyTargets = {};
spyLinks.forEach(function (a) {
var id = a.dataset.spy;
var t = doc.getElementById(id);
if (t) spyTargets[id] = { link: a, el: t };
});
function spy() {
var y = win.scrollY + win.innerHeight * 0.35;
var bestId = null;
Object.keys(spyTargets).forEach(function (id) {
var el = spyTargets[id].el;
if (!el) return;
var top = el.getBoundingClientRect().top + win.scrollY;
if (top <= y) bestId = id;
});
spyLinks.forEach(function (a) {
a.classList.toggle("active", a.dataset.spy === bestId);
});
}
spy();
win.addEventListener("scroll", spy, { passive: true });
// Mobile menu
var burger = doc.getElementById("burger");
var overlay = doc.getElementById("overlay");
var menu = doc.getElementById("mobileMenu");
function setMenu(open) {
if (!burger) return;
burger.setAttribute("aria-expanded", String(open));
overlay.hidden = !open;
menu.hidden = !open;
doc.body.classList.toggle("menu-open", open);
}
burger.addEventListener("click", function () {
setMenu(burger.getAttribute("aria-expanded") !== "true");
});
overlay.addEventListener("click", function () { setMenu(false); });
menu.addEventListener("click", function (e) {
if (e.target.closest("a")) setMenu(false);
});
win.addEventListener("resize", function () {
if (win.innerWidth > 860) setMenu(false);
});
// Global Escape
win.addEventListener("keydown", function (e) {
if (e.key !== "Escape") return;
if (burger && burger.getAttribute("aria-expanded") === "true") setMenu(false);
});
/* =========================================================
12. SCROLL PROGRESS BAR
========================================================= */
var progressBar = doc.querySelector(".scroll-progress span");
function progress() {
if (!progressBar) return;
var max = doc.documentElement.scrollHeight - win.innerHeight;
var p = max > 0 ? Math.min(win.scrollY / max, 1) : 0;
progressBar.style.width = (p * 100) + "%";
}
progress();
win.addEventListener("scroll", progress, { passive: true });
win.addEventListener("resize", progress);
/* =========================================================
13. HERO VIDEO — seamless crossfade loop.
Two stacked copies of the same clip play offset by a
fade window: whenever the active copy nears its end,
the standby copy restarts and fades in while the old
one fades out — hiding the loop seam entirely.
On small screens the second copy is skipped (battery).
========================================================= */
var videoA = doc.getElementById("bgVideoA");
var videoB = doc.getElementById("bgVideoB");
if (videoA && videoB && !reduced) {
var smallScreen = win.matchMedia("(max-width: 720px)").matches;
if (smallScreen) {
// single simple loop on phones — the veil hides the cut
videoB.remove();
videoA.style.opacity = "";
function singleReady() { videoA.style.opacity = "1"; }
if (videoA.readyState >= 2) singleReady();
videoA.addEventListener("loadeddata", singleReady, { once: true });
videoA.addEventListener("canplay", singleReady, { once: true });
} else {
var fadeSec = 1.15;
var active = videoA, standby = videoB, armed = false, vReady = false;
standby.style.opacity = "0";
standby.pause();
function heroReady() {
if (vReady) return;
if (videoA.readyState < 2) return;
vReady = true;
active.style.opacity = "1";
active.play().catch(function () {});
}
videoA.addEventListener("loadeddata", heroReady, { once: true });
videoA.addEventListener("canplay", heroReady, { once: true });
if (videoA.readyState >= 2) heroReady();
function crossTick() {
if (vReady && active.duration) {
if (!armed && active.currentTime >= active.duration - fadeSec) armed = true;
if (armed) {
// catch the wrap even if rAF was throttled in a background tab
armed = false;
standby.currentTime = 0;
standby.play().catch(function () {});
standby.style.opacity = "1";
active.style.opacity = "0";
var swap = active; active = standby; standby = swap;
}
}
requestAnimationFrame(crossTick);
}
requestAnimationFrame(crossTick);
}
[videoA, videoB].forEach(function (v) {
if (!v) return;
v.addEventListener("error", function () { v.style.display = "none"; }, true);
});
} else if (videoA && reduced) {
// reduced motion: static poster frame only
videoA.removeAttribute("autoplay");
videoA.pause();
videoA.style.display = "none";
if (videoB) videoB.style.display = "none";
}
/* =========================================================
13b. HERO PARALLAX — content drifts up and fades while the
background scales slightly as you scroll away.
========================================================= */
var heroSection = doc.querySelector(".hero");
var heroInnerEl = doc.querySelector(".hero-inner");
var heroBgEl = doc.querySelector(".hero .bg");
if (heroSection && heroInnerEl && !reduced) {
var pxTicking = false;
function heroParallax() {
pxTicking = false;
var y = win.scrollY;
var h = heroSection.offsetHeight;
if (y >= h) return;
var p = Math.min(y / h, 1);
heroInnerEl.style.transform = "translateY(" + (-72 * p).toFixed(1) + "px)";
heroInnerEl.style.opacity = Math.max(1 - p * 1.08, 0).toFixed(3);
if (heroBgEl) heroBgEl.style.transform = "scale(" + (1 + p * 0.07).toFixed(4) + ")";
}
win.addEventListener("scroll", function () {
if (!pxTicking) { pxTicking = true; requestAnimationFrame(heroParallax); }
}, { passive: true });
}
/* =========================================================
13c. CLICK SPARK — coral particle burst on primary CTAs
(the "acceptance point" moment, per DESIGN.md).
========================================================= */
function clickSpark(x, y) {
if (reduced) return;
var count = 12;
for (var i = 0; i < count; i++) {
var s = doc.createElement("span");
s.className = "spark";
var ang = (Math.PI * 2 * i) / count + Math.random() * 0.6;
var dist = 16 + Math.random() * 30;
s.style.setProperty("--tx", (Math.cos(ang) * dist).toFixed(1) + "px");
s.style.setProperty("--ty", (Math.sin(ang) * dist).toFixed(1) + "px");
s.style.left = x + "px";
s.style.top = y + "px";
doc.body.appendChild(s);
(function (el) {
setTimeout(function () { el.remove(); }, 620);
})(s);
}
}
doc.querySelectorAll(".cta-primary").forEach(function (btn) {
btn.addEventListener("click", function (e) {
clickSpark(e.clientX || 0, e.clientY || 0);
});
});
/* =========================================================
14. HERO CANVAS — subtle moving dot field (ambient noise)
========================================================= */
var bgCanvas = doc.getElementById("bgCanvas");
if (bgCanvas && !reduced) {
var ctx = bgCanvas.getContext("2d");
var dpr = Math.min(win.devicePixelRatio || 1, 2);
var dots = [];
function resize() {
var r = bgCanvas.getBoundingClientRect();
bgCanvas.width = r.width * dpr;
bgCanvas.height = r.height * dpr;
ctx.scale(dpr, dpr);
}
resize();
var cw = bgCanvas.clientWidth;
var ch = bgCanvas.clientHeight;
for (var i = 0; i < 38; i++) {
dots.push({
x: Math.random() * cw,
y: Math.random() * ch,
r: Math.random() * 1.4 + 0.3,
vx: (Math.random() - 0.5) * 0.04,
vy: (Math.random() - 0.5) * 0.04,
a: Math.random() * 0.4 + 0.1,
ps: Math.random() * 0.012 + 0.004
});
}
function draw(now) {
ctx.clearRect(0, 0, cw, ch);
dots.forEach(function (d) {
d.x += d.vx; d.y += d.vy;
d.a += (Math.sin(now * d.ps) * 0.01);
if (d.x < 0 || d.x > cw) d.vx *= -1;
if (d.y < 0 || d.y > ch) d.vy *= -1;
var alpha = Math.max(0.05, Math.min(0.55, d.a));
ctx.beginPath();
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
var isLight = doc.documentElement.getAttribute("data-theme") === "light";
ctx.fillStyle = isLight ? "rgba(26,29,31," + (alpha * 0.4).toFixed(2) + ")" : "rgba(247,248,250," + alpha.toFixed(2) + ")";
ctx.fill();
});
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
win.addEventListener("resize", function () {
resize();
cw = bgCanvas.clientWidth; ch = bgCanvas.clientHeight;
});
}
/* =========================================================
15. CARD SWAP — React Bits CardSwap port (vanilla JS)
Single-rAF timeline coordinator: all cards update in one
tick per frame for buttery smooth swaps.
Front card drops+fades while rest shift forward, then
front returns to back with elastic ease.
========================================================= */
var csStage = doc.querySelector("[data-card-swap]");
if (csStage) {
var csCards = Array.prototype.slice.call(csStage.querySelectorAll(".cs-card"));
var csInfos = doc.querySelectorAll(".cs-info-item");
var csTotal = csCards.length;
var csDelay = 2000;
var csCardDist = 40; // x-axis spacing (tighter to prevent overflow)
var csVertDist = 45; // y-axis spacing
var csSkew = 5; // skewY degrees
var csDropDist = 50; // drop distance (small, combined with opacity)
var csTotalDur = 1000; // total swap duration (ms)
var csInView = false;
var csAutoTimer = null;
var csAnimating = false;
var csOrder = csCards.map(function (_, i) { return i; });
// Keep horizontal spacing proportional to the container so the
// centered stack always fits inside overflow:hidden bounds.
function csComputeDist() {
var w = csStage.clientWidth || 560;
csCardDist = Math.max(22, Math.min(40, Math.round(w * 0.07)));
}
function elasticOut(t) {
if (t <= 0) return 0;
if (t >= 1) return 1;
var p = 0.4;
return Math.pow(2, -9 * t) * Math.sin((t - p / 4) * (2 * Math.PI) / p) + 1;
}
function powerInOut(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// makeSlot — centered stack: cards fan symmetrically around midpoint
function makeSlot(i, distX, distY, total) {
var centerX = -(total - 1) * distX / 2;
return {
x: centerX + i * distX,
y: -i * distY,
z: -i * distX * 1.5,
zIndex: total - i
};
}
// Set card transform + opacity in one shot
function placeCard(el, slot, opacity) {
el.style.transform =
"translate(-50%, -50%) " +
"translate3d(" + slot.x.toFixed(1) + "px, " + slot.y.toFixed(1) + "px, " + slot.z.toFixed(1) + "px) " +
"skewY(" + csSkew + "deg)";
el.style.zIndex = String(slot.zIndex);
if (opacity !== undefined) el.style.opacity = opacity.toFixed(2);
}
// Initialize card positions
csComputeDist();
csCards.forEach(function (card, i) {
var slot = makeSlot(i, csCardDist, csVertDist, csTotal);
placeCard(card, slot, 1);
});
function csUpdateInfo(activeIdx) {
csInfos.forEach(function (info, i) {
info.classList.toggle("is-active", i === activeIdx);
});
}
// Single-rAF swap: all cards update in one rAF tick per frame
function csSwap() {
if (csAnimating || csOrder.length < 2) return;
csAnimating = true;
var front = csOrder[0];
var rest = csOrder.slice(1);
var elFront = csCards[front];
var frontSlot = makeSlot(0, csCardDist, csVertDist, csTotal);
var backSlot = makeSlot(csTotal - 1, csCardDist, csVertDist, csTotal);
var startT = performance.now();
// Update info immediately for snappy visual feedback
csUpdateInfo(rest[0]);
function tick(now) {
var elapsed = now - startT;
var p = Math.min(elapsed / csTotalDur, 1);
// --- Front card: drop+fade (0→0.42), then return to back (0.42→1) ---
var fX, fY, fZ, fO;
if (p < 0.42) {
var dp = p / 0.42;
var de = powerInOut(dp);
fX = frontSlot.x;
fY = frontSlot.y + csDropDist * de;
fZ = frontSlot.z;
fO = 1 - de * 0.85; // fade to 0.15
elFront.style.zIndex = "0"; // go behind all
} else {
var rp = (p - 0.42) / 0.58;
var re = elasticOut(rp);
var dropY = frontSlot.y + csDropDist;
fX = frontSlot.x + (backSlot.x - frontSlot.x) * re;
fY = dropY + (backSlot.y - dropY) * re;
fZ = frontSlot.z + (backSlot.z - frontSlot.z) * re;
fO = 0.15 + 0.85 * re; // fade back to 1
elFront.style.zIndex = String(backSlot.zIndex);
}
elFront.style.transform =
"translate(-50%, -50%) " +
"translate3d(" + fX.toFixed(1) + "px, " + fY.toFixed(1) + "px, " + fZ.toFixed(1) + "px) " +
"skewY(" + csSkew + "deg)";
elFront.style.opacity = fO.toFixed(2);
// --- Rest cards: shift forward (0→0.72) ---
var mp = Math.min(p / 0.72, 1);
var me = elasticOut(mp);
rest.forEach(function (cardIdx, i) {
var el = csCards[cardIdx];
var oldSlot = makeSlot(i + 1, csCardDist, csVertDist, csTotal);
var newSlot = makeSlot(i, csCardDist, csVertDist, csTotal);
var x = oldSlot.x + (newSlot.x - oldSlot.x) * me;
var y = oldSlot.y + (newSlot.y - oldSlot.y) * me;
var z = oldSlot.z + (newSlot.z - oldSlot.z) * me;
el.style.zIndex = String(newSlot.zIndex);
el.style.transform =
"translate(-50%, -50%) " +
"translate3d(" + x.toFixed(1) + "px, " + y.toFixed(1) + "px, " + z.toFixed(1) + "px) " +
"skewY(" + csSkew + "deg)";
el.style.opacity = "1";
});
if (p < 1) {
requestAnimationFrame(tick);
} else {
csAnimating = false;
csOrder = rest.concat([front]);
}
}
requestAnimationFrame(tick);
}
function csStartAuto() {
csStopAuto();
if (!reduced) csAutoTimer = setInterval(csSwap, csDelay);
}
function csStopAuto() {
if (csAutoTimer) { clearInterval(csAutoTimer); csAutoTimer = null; }
}
csUpdateInfo(0);
if ("IntersectionObserver" in win) {
var csObs = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
csInView = entry.isIntersecting;
if (csInView) csStartAuto();
else csStopAuto();
});
}, { threshold: 0.25 });
csObs.observe(csStage);
}
csStage.addEventListener("mouseenter", csStopAuto);
csStage.addEventListener("mouseleave", function () { if (csInView) csStartAuto(); });
// Re-flow the stack when the container width changes
win.addEventListener("resize", function () {
if (csAnimating) return;
csComputeDist();
csCards.forEach(function (card, i) {
placeCard(card, makeSlot(i, csCardDist, csVertDist, csTotal), 1);
});
});
}
/* =========================================================
16. THEME TOGGLE — dark/light switch with localStorage
========================================================= */
var themeToggle = doc.getElementById("themeToggle");
var themeRoot = doc.documentElement;
var THEME_KEY = "meldwork-theme";
function getStoredTheme() {
try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; }
}
function storeTheme(t) {
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
}
// Capture dark-theme srcs at init (before any theme is applied)
doc.querySelectorAll("[data-theme-src]").forEach(function (img) {
img.setAttribute("data-dark-src", img.getAttribute("src"));
});
function applyTheme(t) {
if (t === "light") themeRoot.setAttribute("data-theme", "light");
else themeRoot.removeAttribute("data-theme");
// Swap logo srcs
doc.querySelectorAll("[data-theme-src]").forEach(function (img) {
var lightSrc = img.getAttribute("data-theme-src");
var darkSrc = img.getAttribute("data-dark-src");
img.setAttribute("src", t === "light" ? lightSrc : darkSrc);
});
doc.dispatchEvent(new CustomEvent("meldwork:themechange"));
}
// Dark is the default theme — always start dark; the toggle
// switches themes for the current session only.
try { localStorage.removeItem(THEME_KEY); } catch (e) {}
applyTheme("dark");
if (themeToggle) {
themeToggle.addEventListener("click", function () {
var current = themeRoot.getAttribute("data-theme") === "light" ? "light" : "dark";
var next = current === "light" ? "dark" : "light";
applyTheme(next);
storeTheme(next);
});
}
})();