'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 { 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; 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 }); const [activo, setActivo] = useState(0); useMotionValueEvent(progreso, 'change', (v) => { setActivo(Math.min(TOTAL - 1, Math.max(0, Math.floor(v * TOTAL)))); }); const barra = useTransform(progreso, [0, 1], [0, 1]); const fondoY = useTransform(progreso, [0, 1], [0, -140]); return (
{/* One viewport of stage, six viewports of scroll to move through it. */}
{/* left-6 / md:left-12, not left-0: an absolutely positioned child resolves against Container's padding box, so `left-0` lands on the inside of the border and ignores the px-6 the rest of the page is aligned to. On a phone that clipped the first glyph. */}

{puntosInflexion.lead}

{/* The rail. Where you are in the conversation, and how much is left — a pinned section without it feels like a page that stopped responding. */}
    {items.map((item, i) => ( ))}
); } export function PuntosInflexion() { const reduced = usePrefersReducedMotion(); return reduced ? : ; }