/* ============================================================ MIX — "Ink & Ember" creative layer for arsamsabbagh.ir - Preloader with counter + word cycle + wipe - Three.js domain-warped "ink & ember" field behind the hero, riding the site's theme variables (light/dark + accent tweaks) - Choreographed hero intro (Web Animations API, no GSAP needed) - Marquee speeds up with scroll velocity The React app is untouched: this layer only reads its DOM and re-attaches the canvas when React remounts the hero (lang flip). ============================================================ */ import * as THREE from "three"; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const EASE_OUT = "cubic-bezier(0.16, 1, 0.3, 1)"; /* ------------------------------------------------------------ Theme palette — read the live CSS variables ------------------------------------------------------------ */ function cssColor(name, fallback) { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return new THREE.Color(v || fallback); } function readPalette() { return { bg: cssColor("--bg", "#F4F0E8"), accent: cssColor("--accent", "#0F6B73"), terra: cssColor("--terra", "#B7593C"), dark: document.documentElement.classList.contains("dark") ? 1 : 0, }; } /* ------------------------------------------------------------ Shaders ------------------------------------------------------------ */ const VERT = `void main() { gl_Position = vec4(position, 1.0); }`; const FRAG = ` precision highp float; uniform float uTime, uScroll, uDark; uniform vec2 uRes, uMouse; uniform vec3 uBg, uAccent, uTerra; vec2 hash(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return -1.0 + 2.0 * fract(sin(p) * 43758.5453123); } float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix( mix(dot(hash(i), f), dot(hash(i + vec2(1, 0)), f - vec2(1, 0)), u.x), mix(dot(hash(i + vec2(0, 1)), f - vec2(0, 1)), dot(hash(i + vec2(1, 1)), f - vec2(1, 1)), u.x), u.y); } // cheap 3-octave fbm for the warp passes float fbm3(vec2 p) { float v = 0.0, a = 0.55; for (int i = 0; i < 3; i++) { v += a * noise(p); p = p * 2.05 + vec2(11.3, 7.7); a *= 0.5; } return v; } // richer 5-octave fbm only for the final field float fbm(vec2 p) { float v = 0.0, a = 0.55; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.05 + vec2(11.3, 7.7); a *= 0.5; } return v; } void main() { vec2 uv = gl_FragCoord.xy / uRes; vec2 p = uv; p.x *= uRes.x / uRes.y; float t = uTime * 0.045; vec2 m = (uMouse - 0.5) * 0.3; // domain-warped flow: slow ink currents vec2 q = vec2(fbm3(p * 1.3 + t + m), fbm3(p * 1.3 - t * 0.7)); vec2 r = vec2(fbm3(p * 1.7 + q * 1.6 + vec2(1.7, 9.2) + t * 0.6), fbm3(p * 1.7 + q * 1.6 + vec2(8.3, 2.8) - t * 0.4)); float f = fbm(p * 1.9 + r * 1.8); float vein = smoothstep(0.42, 0.5, f) * smoothstep(0.62, 0.5, f); // art direction: pool the energy toward the lower-right, // keep the type area (upper-left) close to clean paper float pool = 0.35 + 0.65 * smoothstep(0.1, 1.1, uv.x + (1.0 - uv.y)); vein *= pool; // light mode: subtle ink-wash on paper · dark mode: glowing currents float washAmt = mix(0.085, 0.30, uDark); float veinAmt = mix(0.22, 0.80, uDark); float coreAmt = mix(0.30, 0.65, uDark); vec3 col = uBg; col = mix(col, mix(uBg, uAccent, 0.85), smoothstep(0.2, 0.85, f) * washAmt); col = mix(col, uAccent, vein * veinAmt); col = mix(col, uTerra, pow(vein, 3.0) * coreAmt); col += uAccent * vein * 0.22 * uDark; // soft pool around the pointer float md = distance(uv, uMouse); col = mix(col, uAccent, smoothstep(0.4, 0.0, md) * mix(0.05, 0.10, uDark) * (0.5 + 0.5 * f)); // keep edges close to paper so type always reads float vig = smoothstep(1.3, 0.4, length(uv - vec2(0.45, 0.5))); col = mix(uBg, col, mix(0.55, 1.0, vig)); // fade out as the hero scrolls away col = mix(col, uBg, uScroll * 0.9); gl_FragColor = vec4(col, 1.0); } `; /* ------------------------------------------------------------ WebGL ember field behind the hero ------------------------------------------------------------ */ function initEmber(st) { const canvas = document.createElement("canvas"); canvas.className = "mix-ember"; canvas.setAttribute("aria-hidden", "true"); let renderer; try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: "high-performance", }); } catch { return; // no WebGL: the original SVG mesh hero stays as-is } // the field is soft by nature — render at a fraction of CSS pixels // and let the GPU upscale; this is the main perf lever const isMobile = window.matchMedia("(max-width: 760px)").matches; const RENDER_SCALE = isMobile ? 0.5 : 0.62; renderer.setPixelRatio(1); const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const p = readPalette(); const uniforms = { uTime: { value: 0 }, uRes: { value: new THREE.Vector2(1, 1) }, uMouse: { value: new THREE.Vector2(0.5, 0.5) }, uScroll: { value: 0 }, uBg: { value: p.bg.clone() }, uAccent: { value: p.accent.clone() }, uTerra: { value: p.terra.clone() }, uDark: { value: p.dark }, }; scene.add(new THREE.Mesh( new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG }) )); const target = readPalette(); const mouseTarget = new THREE.Vector2(0.5, 0.5); let hero = null; let wrap = null; let heroH = 1; let lastProgress = -1; const ro = new ResizeObserver(() => resize()); function mount() { const h = document.querySelector(".hero"); if (!h) return; hero = h; wrap = h.querySelector(".hero-wrap"); if (canvas.parentElement !== h) { hero.prepend(canvas); hero.classList.add("has-ember"); } ro.disconnect(); ro.observe(hero); // tracks the element box itself — window resize // events can fire while layout is still settling resize(); } function resize() { if (!hero) return; const w = hero.clientWidth, h = hero.clientHeight; if (w && h) { heroH = h; renderer.setSize(Math.round(w * RENDER_SCALE), Math.round(h * RENDER_SCALE), false); canvas.style.width = "100%"; canvas.style.height = "100%"; renderer.getDrawingBufferSize(uniforms.uRes.value); } } mount(); // React remounts
on language switch — re-attach the canvas const rootEl = document.getElementById("root"); if (rootEl) { new MutationObserver(() => { if (!canvas.isConnected) mount(); }) .observe(rootEl, { childList: true, subtree: true }); } // theme / accent changes (mode toggle, tweaks panel) new MutationObserver(() => Object.assign(target, readPalette())) .observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] }); if (window.matchMedia("(hover: hover)").matches) { window.addEventListener("pointermove", (e) => { mouseTarget.set(e.clientX / innerWidth, 1 - e.clientY / innerHeight); }); } const start = performance.now(); st.tick.push(() => { if (!hero || !canvas.isConnected) return; // hero sits at the top of the page: cached height tells visibility const progress = Math.min(window.scrollY / heroH, 1); if (progress >= 1) return; // fully scrolled past — skip GPU work // scroll-exit parallax: the hero content drifts up slower than // the page and quietly lets go if (wrap && !reduced && Math.abs(progress - lastProgress) > 0.0005) { wrap.style.transform = `translate3d(0, ${(progress * heroH * 0.22).toFixed(1)}px, 0)`; wrap.style.opacity = (1 - progress * 0.8).toFixed(3); } lastProgress = progress; if (!reduced) uniforms.uTime.value = (performance.now() - start) / 1000; uniforms.uMouse.value.lerp(mouseTarget, 0.05); uniforms.uScroll.value = progress; uniforms.uBg.value.lerp(target.bg, 0.07); uniforms.uAccent.value.lerp(target.accent, 0.07); uniforms.uTerra.value.lerp(target.terra, 0.07); uniforms.uDark.value += (target.dark - uniforms.uDark.value) * 0.07; renderer.render(scene, camera); }); } /* ------------------------------------------------------------ Scroll-velocity effects: marquee speeds up, big wordmark leans into the scroll. Node + animation caches are refreshed on a slow interval so React remounts (lang flip) are picked up without per-frame DOM queries. ------------------------------------------------------------ */ function initScrollFx(st) { if (reduced) return; let last = window.scrollY; let rate = 1; let vel = 0; let mqAnims = []; let bw = null; function recache() { mqAnims = []; document.querySelectorAll(".mq-track").forEach((el) => { mqAnims.push(...el.getAnimations()); }); bw = document.querySelector(".bw-text"); } recache(); setInterval(recache, 1200); st.tick.push(() => { const y = window.scrollY; const dy = y - last; last = y; vel += (dy - vel) * 0.1; const targetRate = Math.min(4, 1 + Math.abs(vel) * 0.12); const next = rate + (targetRate - rate) * 0.08; if (Math.abs(next - rate) > 0.005) { rate = next; for (const a of mqAnims) { try { a.playbackRate = rate; } catch {} } } else { rate = next; } if (bw) { const skew = Math.max(-5, Math.min(5, -vel * 0.12)); bw.style.transform = Math.abs(skew) > 0.05 ? `skewX(${skew.toFixed(2)}deg)` : ""; } }); } /* ------------------------------------------------------------ Hero intro ------------------------------------------------------------ */ function runHeroIntro() { const html = document.documentElement; const lines = document.querySelectorAll(".hero-name .hero-line"); const groups = [".hero-kicker", ".hero-meta", ".hero-ctas", ".hero-foot"] .map((s) => document.querySelector(s)).filter(Boolean); // hidden tab: rAF/WAAPI are suspended — reveal instantly if (!lines.length || document.hidden) { html.classList.remove("mix-intro-wait"); return; } const anims = []; lines.forEach((line, i) => { anims.push(line.animate( [ { opacity: 0, transform: "translateY(0.35em)", clipPath: "inset(0 0 100% 0)" }, { opacity: 1, transform: "translateY(0)", clipPath: "inset(0 0 -15% 0)" }, ], { duration: 1100, delay: i * 140, easing: EASE_OUT, fill: "both" } )); }); groups.forEach((el, i) => { anims.push(el.animate( [ { opacity: 0, transform: "translateY(18px)" }, { opacity: 1, transform: "translateY(0)" }, ], { duration: 800, delay: 350 + i * 110, easing: EASE_OUT, fill: "both" } )); }); Promise.all(anims.map((a) => a.finished)).catch(() => {}).finally(() => { html.classList.remove("mix-intro-wait"); anims.forEach((a) => a.cancel()); }); } /* ------------------------------------------------------------ Preloader ------------------------------------------------------------ */ function initPreloader() { const pre = document.getElementById("mix-preloader"); if (!pre) return; if (reduced) { pre.remove(); return; } document.documentElement.classList.add("mix-intro-wait"); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const countEl = pre.querySelector("[data-mix-count]"); const barEl = pre.querySelector("[data-mix-bar]"); const wordEl = pre.querySelector("[data-mix-word]"); const words = ["intelligence", "evaluation", "inference", "interfaces"]; const reactReady = new Promise((res) => { const check = () => { if (document.querySelector(".hero-name")) res(); else setTimeout(check, 40); }; check(); }); const fontsReady = document.fonts ? document.fonts.ready : Promise.resolve(); const assetsReady = Promise.all([reactReady, fontsReady]); const T = 1500; const t0 = performance.now(); let done = false; function update(now) { const lin = Math.min((now - t0) / T, 1); const eased = 1 - Math.pow(1 - lin, 3); const v = Math.round(eased * 100); countEl.textContent = v; barEl.style.width = v + "%"; wordEl.textContent = words[Math.min(Math.floor(v / 26), words.length - 1)]; if (lin >= 1) assetsReady.then(finish); return lin >= 1; } function frame(now) { if (done) return; if (!update(now)) requestAnimationFrame(frame); } requestAnimationFrame(frame); // rAF is suspended in hidden tabs — keep the clock moving with a // throttled interval so a background load still completes const fallback = setInterval(() => { if (done) { clearInterval(fallback); return; } update(performance.now()); }, 300); function finish() { if (done) return; done = true; clearInterval(fallback); const reveal = () => { pre.remove(); document.body.style.overflow = prevOverflow; runHeroIntro(); }; if (document.hidden) { reveal(); return; } // skip the wipe off-screen const wipe = pre.animate( [{ clipPath: "inset(0 0 0% 0)" }, { clipPath: "inset(0 0 100% 0)" }], { duration: 750, easing: "cubic-bezier(0.87, 0, 0.13, 1)", fill: "forwards", delay: 120 } ); wipe.finished.catch(() => {}).finally(reveal); } } /* ------------------------------------------------------------ Boot ------------------------------------------------------------ */ const state = { tick: [] }; function rafLoop() { state.tick.forEach((fn) => fn()); requestAnimationFrame(rafLoop); } function boot() { initPreloader(); initEmber(state); initScrollFx(state); requestAnimationFrame(rafLoop); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot, { once: true }); } else { boot(); }