// app.jsx — Main shell: scroll loop, scroll-driven 3D blend, HUD, rail, cursor, tweaks. const { useState, useEffect, useRef, useCallback } = React; // TWEAK_DEFAULTS lives in Hecstrim.html (so the loader can read it before // Babel transpiles this file). We just pull from window here. const TWEAK_DEFAULTS = window.TWEAK_DEFAULTS || { cursorTrail: true, neonGrain: true, loopBanner: true, magneticUI: true, loaderStyle: 'malla', }; // Hero look/feel — variants previously surfaced as tweaks. The selected // combination is baked in; tweaks panel no longer exposes these. const HERO_BAKED = { cursorReach: 'medium', // ← cursor halo size panelSeparation: 'lift', // ← how panels react to the cursor wirePulse: 'soft', // ← mesh breathing intensity aboutTransition: 'warp', // ← hero→about dive style }; const SECTION_NAMES = [ { key: 'hero', label: 'inicio', num: '01' }, { key: 'about', label: 'perfil', num: '02' }, { key: 'deliverables', label: 'descargas', num: '03' }, { key: 'shop', label: 'tienda', num: '04' }, { key: 'links', label: 'enlaces', num: '05' }, ]; // ───────────────────────────────────────────────────────── // Custom cursor — follows mouse with slight lag, scales on // pointer hover over interactive elements. // ───────────────────────────────────────────────────────── function useCustomCursor() { useEffect(() => { const cursor = document.getElementById('cursor'); if (!cursor) return; let tx = window.innerWidth / 2, ty = window.innerHeight / 2; let cx = tx, cy = ty; let raf; const onMove = (e) => { tx = e.clientX; ty = e.clientY; }; const onOver = (e) => { const interactive = e.target.closest('a, button, .btn, .chip, .card, [role="button"]'); cursor.classList.toggle('large', !!interactive); }; const onLeave = () => { cursor.style.opacity = '0'; }; const onEnter = () => { cursor.style.opacity = '1'; }; const loop = () => { cx += (tx - cx) * 0.25; cy += (ty - cy) * 0.25; cursor.style.left = cx + 'px'; cursor.style.top = cy + 'px'; raf = requestAnimationFrame(loop); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerover', onOver); window.addEventListener('pointerleave', onLeave); window.addEventListener('pointerenter', onEnter); loop(); return () => { cancelAnimationFrame(raf); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerover', onOver); window.removeEventListener('pointerleave', onLeave); window.removeEventListener('pointerenter', onEnter); }; }, []); } // ───────────────────────────────────────────────────────── // Scroll loop: render [tail-clone, ...real, head-clone]. // When user lands on a clone, silently snap to its real // counterpart. Continuously emits scroll fraction so the // 3D scene and background can crossfade. // ───────────────────────────────────────────────────────── function useScrollLoop(deckRef, count, onProgress, onLoopWrap) { const fireLoopBanner = useCallback(() => { const el = document.querySelector('.loop-banner'); if (!el) return; el.classList.remove('fire'); void el.offsetWidth; el.classList.add('fire'); }, []); useEffect(() => { const deck = deckRef.current; if (!deck) return; let scrollTimer = null; let raf; const emitProgress = () => { // Map raw scrollTop to a continuous position `p` in [0, count]. The // wrap zone [count-1, count] represents the smooth Links→Hero loop // — both clones (tail at f∈[0,1] and head at f∈[count, count+1]) // resolve into the same wrap range, so downstream consumers can // crossfade through it without ever knowing the snap happened. const f = deck.scrollTop / window.innerHeight; let p; if (f >= 1 && f <= count) { p = f - 1; // main range 0..count-1 } else if (f > count) { p = (count - 1) + Math.min(1, f - count); // head-clone → wrap } else { p = (count - 1) + Math.max(0, Math.min(1, f)); // tail-clone → wrap } // Convert continuous position to (idx, frac). In the wrap zone we // saturate at idx=count-1, frac up to 1 — Option A's contract with // __setSection / __setBgAccent / camera blend, which read idx=count-1 // & frac>0 as "Links blending toward Hero". let idx, frac; if (p >= count - 1) { idx = count - 1; frac = Math.min(1, p - idx); } else { idx = Math.floor(p); frac = p - idx; } onProgress(idx, frac); }; const handleScroll = () => { emitProgress(); clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { const settledIdx = Math.round(deck.scrollTop / window.innerHeight); if (settledIdx === 0) { // tail clone — jump to real last fireLoopBanner(); if (onLoopWrap) onLoopWrap(); requestAnimationFrame(() => { deck.classList.add('no-snap'); deck.scrollTop = count * window.innerHeight; requestAnimationFrame(() => { deck.classList.remove('no-snap'); emitProgress(); }); }); } else if (settledIdx === count + 1) { fireLoopBanner(); if (onLoopWrap) onLoopWrap(); requestAnimationFrame(() => { deck.classList.add('no-snap'); deck.scrollTop = 1 * window.innerHeight; requestAnimationFrame(() => { deck.classList.remove('no-snap'); emitProgress(); }); }); } }, 150); }; deck.addEventListener('scroll', handleScroll, { passive: true }); // Start at real section 0 (at scrollTop = 1 * vh). Wait a tick to ensure // the deck has its scroll height (sections rendered + sized) before we set. const init = () => { deck.classList.add('no-snap'); deck.scrollTop = window.innerHeight; // Use a longer timeout so the browser doesn't auto-snap back to 0 setTimeout(() => { deck.scrollTop = window.innerHeight; // re-set in case snap reset deck.classList.remove('no-snap'); emitProgress(); }, 80); }; // Ensure layout pass complete setTimeout(init, 60); return () => { deck.removeEventListener('scroll', handleScroll); clearTimeout(scrollTimer); cancelAnimationFrame(raf); }; }, [deckRef, count, onProgress, fireLoopBanner, onLoopWrap]); const scrollTo = useCallback((visualIdx) => { const deck = deckRef.current; if (!deck) return; const target = (visualIdx + 1) * window.innerHeight; deck.scrollTo({ top: target, behavior: 'smooth' }); }, [deckRef]); return { scrollTo }; } // ───────────────────────────────────────────────────────── // Keyboard navigation // ───────────────────────────────────────────────────────── function useKeyboardNav(active, count, scrollTo) { useEffect(() => { const handler = (e) => { if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return; if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); scrollTo((active + 1) % count); } else if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); scrollTo((active - 1 + count) % count); } else if (e.key === 'Home') { e.preventDefault(); scrollTo(0); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [active, count, scrollTo]); } // ───────────────────────────────────────────────────────── // HUD — top bar with brand mark, section index, live clock // ───────────────────────────────────────────────────────── function HUD({ active, fraction }) { const [time, setTime] = useState(() => new Date()); useEffect(() => { const t = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(t); }, []); const stamp = time.toISOString().slice(11, 19); const meterFill = (active + fraction) / SECTION_NAMES.length; const meterBars = 12; return (
hecstrim | gráficos en movimiento
{Array.from({ length: meterBars }).map((_, i) => ( ))} {stamp} {SECTION_NAMES[active]?.num}/{String(SECTION_NAMES.length).padStart(2, '0')}
); } // ───────────────────────────────────────────────────────── // Rail — right-side section nav // ───────────────────────────────────────────────────────── function Rail({ active, scrollTo }) { return (
{SECTION_NAMES.map((s, i) => ( ))}
); } // ───────────────────────────────────────────────────────── // App // ───────────────────────────────────────────────────────── function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const deckRef = useRef(null); const [active, setActive] = useState(0); const [fraction, setFraction] = useState(0); useCustomCursor(); // Apply tweak: grain toggle useEffect(() => { const el = document.getElementById('grain'); if (el) el.style.display = t.neonGrain ? '' : 'none'; }, [t.neonGrain]); // Apply tweak: cursor visibility useEffect(() => { const el = document.getElementById('cursor'); if (el) el.style.display = t.cursorTrail ? '' : 'none'; document.body.style.cursor = t.cursorTrail ? 'none' : 'default'; }, [t.cursorTrail]); // Sync hero→about transition variant into three-scene click handler useEffect(() => { window.__heroAboutVariant = HERO_BAKED.aboutTransition; }, []); // Orchestrate scroll position around the 3D transition. Visual handover is // entirely CSS — both sections are pinned as fixed overlays by .hx-transitioning. // We snap scrollTop to about's slot only at the very end. const cooldownRef = useRef(0); const pinScrollRef = useRef(-1); // -1 = don't pin; otherwise the scrollTop to enforce useEffect(() => { const onT = (e) => { const deck = deckRef.current; if (!deck) return; const phase = e.detail && e.detail.phase; if (phase === 'start') { deck.classList.add('hx-transitioning'); } else if (phase === 'end') { // Update the pin to the DESTINATION before snapping, so the scroll // listener doesn't immediately revert our snap back to the origin. const dest = 2 * window.innerHeight; pinScrollRef.current = dest; deck.scrollTop = dest; // Clear the captured hero offset so subsequent transitions re-measure. deck.style.removeProperty('--hx-hero-top'); cooldownRef.current = performance.now() + 600; setTimeout(() => deck.classList.remove('hx-transitioning'), 120); // Release the pin shortly after cooldown ends so the user can scroll freely. setTimeout(() => { pinScrollRef.current = -1; }, 720); } }; window.addEventListener('hxtransition', onT); return () => window.removeEventListener('hxtransition', onT); }, []); // Intercept scroll-down on hero to fire the warp transition. We use a TINY // threshold so the deck only has a chance to drift a pixel or two before we // take over — and on fire, we measure the hero's CURRENT viewport position // and pin position:fixed to that exact spot, so there is NO visual snap-back // to origin when the overlay class activates. The transition then animates // from wherever the scroll left things. useEffect(() => { const deck = deckRef.current; if (!deck) return; let touchStartY = 0; const isLocked = () => deck.classList.contains('hx-transitioning') || performance.now() < cooldownRef.current; const onScroll = () => { const pin = pinScrollRef.current; if (pin >= 0 && deck.scrollTop !== pin) { deck.scrollTop = pin; } }; const onWheel = (e) => { if (isLocked()) { e.preventDefault(); return; } // Tiny threshold: any meaningful downward intent fires immediately. if (active === 0 && e.deltaY > 0 && window.__startHeroAboutTransition) { e.preventDefault(); fire(); } }; const fire = () => { // Measure hero's live viewport offset BEFORE we add the transitioning // class. Whatever pixels the scroll has eaten get baked into a CSS // variable so position:fixed lands hero at the same visual spot. const heroEl = deck.querySelector('.screen[data-screen-label="01 Hero"]'); const heroTop = heroEl ? heroEl.getBoundingClientRect().top : 0; deck.style.setProperty('--hx-hero-top', heroTop + 'px'); pinScrollRef.current = deck.scrollTop; window.__startHeroAboutTransition(HERO_BAKED.aboutTransition); }; const onTouchStart = (e) => { touchStartY = e.touches[0].clientY; }; const onTouchMove = (e) => { if (isLocked()) { e.preventDefault(); return; } if (active !== 0) return; const dy = touchStartY - e.touches[0].clientY; if (dy > 4 && window.__startHeroAboutTransition) { e.preventDefault(); fire(); } }; const onKey = (e) => { if (!isLocked()) return; if (['ArrowDown','ArrowUp','PageDown','PageUp','Home','End',' '].includes(e.key)) { e.preventDefault(); } }; deck.addEventListener('wheel', onWheel, { passive: false }); deck.addEventListener('touchstart', onTouchStart, { passive: true }); deck.addEventListener('touchmove', onTouchMove, { passive: false }); deck.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('keydown', onKey, { capture: true }); return () => { deck.removeEventListener('wheel', onWheel); deck.removeEventListener('touchstart', onTouchStart); deck.removeEventListener('touchmove', onTouchMove); deck.removeEventListener('scroll', onScroll); window.removeEventListener('keydown', onKey, { capture: true }); }; }, [active]); // Push the baked hero look into three-scene. Runs once on mount; if the // scene loads after this effect we retry until __setHeroTweaks is ready. useEffect(() => { const reach = { cursorRadius: 1.7, cursorBoost: 1.0 }; // 'medium' const sep = { repel: 1.0, panelGap: 0.30, reactStyle: 0 }; // 'lift' const pulse = { wireBase: 0.40, wireAmp: 0.14, wireSpeed: 1.2 }; // 'soft' const payload = { ...reach, ...sep, ...pulse }; const push = () => { if (window.__setHeroTweaks) { window.__setHeroTweaks(payload); return true; } return false; }; if (!push()) { const id = setInterval(() => { if (push()) clearInterval(id); }, 80); setTimeout(() => clearInterval(id), 4000); return () => clearInterval(id); } }, []); // Tell the loader the app has mounted (one signal flips it to 'ready'). useEffect(() => { // Wait a tick so the first paint has actually rendered the deck. const id = requestAnimationFrame(() => { if (window.__signalReady) window.__signalReady('app'); }); return () => cancelAnimationFrame(id); }, []); // Scroll progress pushes into the global 3D scene + 2D background const onProgress = useCallback((idx, frac) => { setActive(idx); setFraction(frac); if (window.__setSection) window.__setSection(idx, frac); if (window.__setBgAccent) window.__setBgAccent(idx, frac); }, []); // When the loop wraps (tail-clone → real last, or head-clone → real first) // we silence the hero→about wheel handler for a moment. Otherwise the // wheel-event residue from a forceful scroll keeps firing AFTER the snap // lands us on hero, immediately triggering the dive-to-about transition. // Symptom this fixes: scrolling down from 05 links flashed past hero and // landed on 02 about instead of stopping cleanly at 01 hero. const onLoopWrap = useCallback(() => { cooldownRef.current = performance.now() + 650; }, []); const { scrollTo } = useScrollLoop(deckRef, SECTION_NAMES.length, onProgress, onLoopWrap); useKeyboardNav(active, SECTION_NAMES.length, scrollTo); const renderSection = (key, suffix = '', sectionIdx = -1) => { const isActive = active === sectionIdx; switch (key) { case 'hero': return ; case 'about': return ; case 'deliverables': return ; case 'shop': return ; case 'links': return ; default: return null; } }; return ( <>
{/* tail clone — duplicate of last section, sits before real first */} {SECTION_NAMES.map((s, i) => renderSection(s.key, '', i))} {/* head clone — duplicate of first, sits after real last */}
{t.loopBanner &&
↻ LOOP · CONTINUAR
} setTweak('loaderStyle', v)} />
se aplica al recargar la página
setTweak('cursorTrail', v)} /> setTweak('magneticUI', v)} /> setTweak('neonGrain', v)} /> setTweak('loopBanner', v)} />
{SECTION_NAMES.map((s, i) => ( ))}
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();