// 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 */}
{renderSection(SECTION_NAMES[SECTION_NAMES.length - 1].key, '-tail', -1)}
{SECTION_NAMES.map((s, i) => renderSection(s.key, '', i))}
{/* head clone — duplicate of first, sits after real last */}
{renderSection(SECTION_NAMES[0].key, '-head', -1)}
{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();