// Loader, title reveal and menu behaviour for the home page. export function initUI(startOrb) { const body = document.body; const loader = document.getElementById('loader'); const count = document.getElementById('loader-count'); const title = document.getElementById('title'); const canvas = document.getElementById('orb'); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; // --- title: split into letters (each wrapped per word so lines break cleanly) const text = title.textContent.trim(); title.setAttribute('aria-label', text); title.textContent = ''; let n = 0; text.split(' ').forEach((word, wi, words) => { const w = document.createElement('span'); w.className = 'word'; w.setAttribute('aria-hidden', 'true'); for (const ch of word) { const mask = document.createElement('span'); mask.className = 'ch'; const inner = document.createElement('span'); inner.textContent = ch; inner.style.transitionDelay = `${0.04 * n++}s`; mask.appendChild(inner); w.appendChild(mask); } title.appendChild(w); if (wi < words.length - 1) title.appendChild(document.createTextNode(' ')); }); // --- loader: counts up while fonts and the first orb frame get ready let orbReady = false; let fontsReady = false; let shown = 0; const started = performance.now(); const minimum = reduced ? 0 : 1100; const webgl = startOrb(canvas, { onReady: () => { orbReady = true; } }); if (!webgl) { document.documentElement.classList.add('no-webgl'); orbReady = true; } (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { fontsReady = true; }); function tick(now) { const elapsed = now - started; const real = (orbReady ? 0.5 : 0.1) + (fontsReady ? 0.5 : 0.15); const timed = Math.min(1, elapsed / (minimum || 1)); const target = Math.min(real, timed) * 100; shown += (target - shown) * 0.2; if (target - shown < 0.5) shown = target; count.textContent = String(Math.round(shown)).padStart(3, '0') + '%'; if (shown >= 99.5 && orbReady && fontsReady && elapsed >= minimum) return done(); requestAnimationFrame(tick); } requestAnimationFrame(tick); function done() { count.textContent = '100%'; body.classList.remove('is-loading'); body.classList.add('is-ready'); setTimeout(() => loader.remove(), 900); } // --- menu const menu = document.getElementById('menu'); const btn = document.getElementById('menu-btn'); const panel = document.getElementById('menu-panel'); function setOpen(open) { menu.classList.toggle('is-open', open); btn.setAttribute('aria-expanded', String(open)); panel.toggleAttribute('inert', !open); } setOpen(false); btn.addEventListener('click', () => setOpen(!menu.classList.contains('is-open'))); addEventListener('keydown', (e) => { if (e.key === 'Escape') { setOpen(false); btn.focus(); } }); document.addEventListener('pointerdown', (e) => { if (menu.classList.contains('is-open') && !menu.contains(e.target)) setOpen(false); }); }