'use client'; import { useEffect, useRef, useState } from 'react'; import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion'; import { Container } from '@/components/primitives/Container'; import { FondoPuntos } from '@/components/primitives/FondoPuntos'; import { FondoFoto } from '@/components/primitives/FondoFoto'; import { ParDialogo } from '@/components/primitives/ParDialogo'; import { EscenaDialogo } from '@/components/primitives/EscenaDialogo'; import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion'; import { puntosInflexion } from '@/lib/content'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; const items = puntosInflexion.items; const TOTAL = items.length; // One photograph per inflection point, in the order the points are declared. // Asset wiring, not copy — `lib/content.ts` stays the source of truth for words // only. Every file is from Wikimedia Commons under a licence that allows reuse, // and every one is credited in `creditosFotos`; the footer renders that list. // // They are read through FondoFoto, which pushes them through // mix-blend-luminosity over the brand's navy→gold gradient, so no photograph // ever brings its own colour onto the page. Under the veils below they read as // texture, not as stock photography. const FOTOS = [ '/images/recorrido/crecer.jpg', // a mountain road with no visible end '/images/recorrido/consolidarse.jpg', // a building still open to the weather '/images/recorrido/rentabilidad.jpg', // the harvest: effort turned into result '/images/recorrido/simplificar.jpg', // wires crossing a sky, lines multiplying '/images/recorrido/expandirse.jpg', // a container port, new markets '/images/recorrido/portafolio.jpg', // a whole city at once, nothing prioritised ]; function Cabecera({ reduced }: { reduced: boolean }) { return (
{puntosInflexion.eyebrow} {puntosInflexion.title}
); } // Reduced motion, and the server's first paint, get the whole conversation laid // out at once. A pinned section that advances on scroll is exactly the thing // someone asking for less movement is asking to be spared, and hiding five // sixths of the copy behind a scroll position is not an option either way. function Estatica() { return (

{puntosInflexion.lead}

    {items.map((item, i) => ( {}} /> ))}
); } function Escenificado() { const contenedor = useRef(null); // How far through the pinned stage the reader is, 0 → 1, measured by hand. // `useScroll({ target })` sat at exactly 0 for the whole 5.400px of scroll — // measured, not guessed: every scene stayed at its initial opacity from the // top of the section to the bottom. Reading the container's own rect on each // frame is a dozen lines, needs no measurement pass from the library, and can // be verified in the browser, which the black box could not. const bruto = useMotionValue(0); useEffect(() => { let pendiente = 0; const medir = () => { pendiente = 0; const el = contenedor.current; if (!el) return; const { top, height } = el.getBoundingClientRect(); const recorrido = height - window.innerHeight; bruto.set(recorrido > 0 ? Math.min(1, Math.max(0, -top / recorrido)) : 0); }; const alScroll = () => { if (!pendiente) pendiente = requestAnimationFrame(medir); }; medir(); window.addEventListener('scroll', alScroll, { passive: true }); window.addEventListener('resize', alScroll); return () => { if (pendiente) cancelAnimationFrame(pendiente); window.removeEventListener('scroll', alScroll); window.removeEventListener('resize', alScroll); }; }, [bruto]); // Takes the jitter out of a trackpad without lagging behind the reader; the // hilo conductor taught this project what an overdamped spring feels like. const progreso = useSpring(bruto, { stiffness: 260, damping: 42, restDelta: 0.0005 }); // Twelve beats, not a continuum: question, answer, question, answer… Scroll // picks the beat and nothing else. When the cards were driven straight off // the scroll value, every position in between was a frame you could stop on, // and creeer's card sat there as a half-transparent grey slab. const TIEMPOS = TOTAL * 2; const [tiempo, setTiempo] = useState(0); useMotionValueEvent(progreso, 'change', (v) => { setTiempo(Math.min(TIEMPOS - 1, Math.max(0, Math.floor(v * TIEMPOS)))); }); const activo = Math.floor(tiempo / 2); const respondida = tiempo % 2 === 1; const barra = useTransform(progreso, [0, 1], [0, 1]); const fondoY = useTransform(progreso, [0, 1], [0, -140]); // A slower drift than the dots, so the photograph sits behind them in depth. const fotoY = useTransform(progreso, [0, 1], [0, -70]); return (
{/* One viewport of stage, six viewports of scroll to move through it. */}
{/* The photograph of whichever point is on screen, crossfading with the beat. Only the active one is opaque; the rest sit at zero, so the browser still holds them decoded and the swap costs nothing. */} {items.map((item, i) => ( ))} {/* "Respetando la capa azul y blanco": the photograph never carries the frame, it only gives it grain. The light veil holds the question legible in azul-profundo type; the navy veil takes the whole stage over for the answer. Both are heavy on purpose — anything thinner and the copy starts fighting the photograph. */}
); } export function PuntosInflexion() { const reduced = usePrefersReducedMotion(); return reduced ? : ; }