From 81c36bb657b8ce1712f7b17d38eff09f1c5223dd Mon Sep 17 00:00:00 2001 From: erikgh Date: Mon, 3 Aug 2026 11:52:52 -0600 Subject: [PATCH] =?UTF-8?q?feat(recorrido):=20la=20conversaci=C3=B3n=20com?= =?UTF-8?q?o=20tarjetas,=20fijada=20al=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sección se ancla una pantalla y avanza con el scroll: llega la tarjeta de la pregunta, se te devuelve la de creeer, y al seguir bajando entra la siguiente. Cifra fantasma, raíl de posición y barra de avance para saber dónde estás. Con prefers-reduced-motion no hay anclaje ni escenas: las seis parejas se leen seguidas. Dos fallos corregidos en el camino: - las escenas se cruzaban superpuestas y quedaban ilegibles las dos; entrada y salida ya no se solapan. - el rótulo se posicionaba con left-0 dentro de Container, que resuelve contra la caja de padding e ignora su px-6; en móvil cortaba la primera letra. Co-Authored-By: Claude Opus 5 --- components/primitives/EscenaDialogo.tsx | 107 ++++++++++++++ components/sections/PuntosInflexion.tsx | 189 ++++++++++++++++++------ 2 files changed, 251 insertions(+), 45 deletions(-) create mode 100644 components/primitives/EscenaDialogo.tsx diff --git a/components/primitives/EscenaDialogo.tsx b/components/primitives/EscenaDialogo.tsx new file mode 100644 index 0000000..8fcc9c4 --- /dev/null +++ b/components/primitives/EscenaDialogo.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { motion, useTransform, type MotionValue } from 'framer-motion'; +import { Wordmark } from '@/components/primitives/Wordmark'; + +// One beat of the pinned conversation, staged as two calling cards laid on a +// table: the reader's, in white, and creeer's, in navy, handed back. +// +// Every scene is mounted at all times and stacked in the same place; what +// changes is opacity and offset, driven by the section's scroll position. +// Mounting only the active scene would animate more cheaply and would strip +// five sixths of the section out of the static export. +// +// Each scene owns a slice of that scroll: the question card arrives at the top +// of its slice, creeer's card is handed over around the middle, and both leave +// as the next question comes up. + +export function EscenaDialogo({ + i, + total, + progreso, + n, + name, + situacion, + respuesta, +}: { + i: number; + total: number; + progreso: MotionValue; + n: string; + name: string; + situacion: string; + respuesta: string; +}) { + const paso = 1 / total; + const inicio = i * paso; + const fin = inicio + paso; + + // Entrance and exit must not overlap. Every scene is stacked in the same + // place, so a cross-dissolve puts two full cards of type on top of each other + // and both become unreadable — it looked like a rendering fault, not like a + // transition. One card leaves, a beat of empty stage, the next arrives. + const entrada = i === 0 ? [-1, inicio] : [inicio + paso * 0.04, inicio + paso * 0.17]; + const opacidad = useTransform( + progreso, + [entrada[0], entrada[1], fin - paso * 0.17, fin - paso * 0.04], + [0, 1, 1, 0], + ); + // Nothing sits perfectly still: static type on a pinned screen reads as a + // stuck page rather than as a held moment. + const deriva = useTransform(progreso, [inicio - paso, fin + paso], [70, -70]); + + // Where creeer's card is handed over inside this scene's slice. + const revelado = useTransform(progreso, [inicio + paso * 0.32, inicio + paso * 0.6], [0, 1]); + const respY = useTransform(revelado, [0, 1], [70, 0]); + const respEscala = useTransform(revelado, [0, 1], [0.94, 1]); + const respGiro = useTransform(revelado, [0, 1], [5.5, 1.6]); + // The reader's card gives way a little as the answer lands: it stays + // legible, but it stops being the thing you are looking at. + const pregOpacidad = useTransform(revelado, [0, 1], [1, 0.72]); + const pregGiro = useTransform(revelado, [0, 1], [-0.6, -1.8]); + const pregY = useTransform(revelado, [0, 1], [0, -14]); + + const cifraY = useTransform(progreso, [inicio - paso, fin + paso], [60, -60]); + + return ( + + + + + {/* The reader's card. */} + +

+ {n} · {name} +

+

+ «{situacion}» +

+
+ + {/* creeer's card, handed back. Navy against the reader's white, tilted + the other way, overlapping the corner — two cards on a table, not + two paragraphs in a column. */} + + +
+
+ ); +} diff --git a/components/sections/PuntosInflexion.tsx b/components/sections/PuntosInflexion.tsx index 5fe0326..6919836 100644 --- a/components/sections/PuntosInflexion.tsx +++ b/components/sections/PuntosInflexion.tsx @@ -1,65 +1,164 @@ 'use client'; -import { useCallback, useState } from 'react'; -import { motion } from 'framer-motion'; +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'; -export function PuntosInflexion() { - const reduced = usePrefersReducedMotion(); +const items = puntosInflexion.items; +const TOTAL = items.length; - // How far down the conversation the reader has got. It only ever moves - // forward: an answer that faded back out on the way past would leave the page - // emptier the further you read. Scroll drives it, and so do hover and - // keyboard focus, so the reader can also run the conversation themselves - // instead of only being carried through it. - const [alcanzado, setAlcanzado] = useState(0); - const activar = useCallback((n: number) => setAlcanzado((previo) => Math.max(previo, n)), []); +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.eyebrow} - {puntosInflexion.title} -
- - - -
- - {puntosInflexion.lead} - - {/* The conversation. One
  • per exchange, read top to bottom — no - carousel at any breakpoint, because "te bajas" is the whole shape - the dueña asked for. Each answer arrives when the reader reaches its - pair, so the section answers as you go down instead of sitting there - already answered. The stagger reaches ParDialogo's two halves - through React context. */} - - {puntosInflexion.items.map((item, i) => ( - + +

    {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 ? : ; +}