feat(recorrido): el fotograma es el mensaje, y la respuesta llega en azul

Fuera tarjetas: la situación va a tamaño de titular sobre el fondo claro,
y al bajar el escenario entero se vuelve azul profundo para la respuesta
de creeer, en blanco y del mismo tamaño. No es una tarjeta sobre la
página del lector, es el terreno de creeer.

El rótulo del punto se repite en la respuesta a propósito: como solo hay
una cosa en pantalla, es lo único que la ata a la pregunta que contesta.
El test lo fija con esa razón escrita.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-08-03 12:26:32 -06:00
1 parent 253ff26fa2
commit 3f65a2a2c3
3 files changed
+68 -48

No files matched your search

+43 -42
View File
@@ -3,19 +3,23 @@
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.
// One beat of the pinned conversation. Not a card on a page — the whole frame
// is the message: the reader's situation set large on the light ground, then
// creeer's answer set large on azul-profundo, the section's own background
// turning over with it.
//
// One thing per frame, never both. The two halves share a grid cell so the
// answer takes the stage the question leaves instead of stacking under it, and
// they arrive from opposite sides so the swap reads as a relay.
//
// The cards are driven by STATE, not by scroll position. Mapping opacity
// straight onto scroll made every intermediate position a valid frame, so
// stopping mid-swipe left creeer's card as a half-transparent grey rectangle —
// it read as a rendering fault. Scroll now only decides which beat we are on;
// the cards animate to that beat over time and always land somewhere finished,
// wherever the reader lets go.
// stopping mid-swipe left the answer as a half-transparent slab that read as a
// rendering fault. Scroll now only picks the beat; the frame animates to it
// over time and always lands somewhere finished, wherever the reader lets go.
//
// Every scene stays mounted and stacked in the same place. Mounting only the
// active one would animate more cheaply and would strip five sixths of the
// section out of the static export.
// Every scene stays mounted. Rendering only the active one would animate more
// cheaply and would strip five sixths of the section out of the static export.
const EASE = [0.22, 1, 0.36, 1] as const;
@@ -44,66 +48,63 @@ export function EscenaDialogo({
// The one thing still tied to scroll. A drifting numeral never looks broken
// at any position it can be caught in, so it costs nothing and keeps the
// stage from feeling frozen between beats.
const cifraY = useTransform(progreso, [i * paso - paso, (i + 1) * paso + paso], [60, -60]);
const cifraY = useTransform(progreso, [i * paso - paso, (i + 1) * paso + paso], [70, -70]);
return (
<motion.li
data-activa={activa ? 'true' : 'false'}
animate={{ opacity: activa ? 1 : 0, y: activa ? 0 : 26 }}
transition={{ duration: 0.45, ease: EASE }}
className="absolute inset-x-0 top-1/2 -translate-y-1/2"
animate={{ opacity: activa ? 1 : 0 }}
transition={{ duration: 0.4, ease: EASE }}
className="absolute inset-0 flex items-center"
style={{ pointerEvents: activa ? 'auto' : 'none' }}
>
<motion.span
aria-hidden="true"
style={{ y: cifraY }}
className="pointer-events-none absolute -top-32 right-0 select-none font-sans font-black leading-none text-azul-profundo/[0.045] text-[9rem] md:text-[17rem]"
animate={{ color: respondida ? 'rgba(255,255,255,0.06)' : 'rgba(10,29,61,0.05)' }}
transition={{ duration: 0.5, ease: EASE }}
className="pointer-events-none absolute right-0 top-8 select-none font-sans font-black leading-none text-[11rem] md:text-[22rem]"
>
{n}
</motion.span>
{/* One card per frame — never both. Both live in the same grid cell so
the second can take the stage the first leaves, instead of stacking
under it: the question is asked, it goes, and then the answer is the
only thing on screen. They come in from opposite sides so the swap
reads as one card replacing another, not as one fading into the same
spot. */}
<div className="grid">
<div className="relative grid w-full">
{/* The reader's situation. */}
<motion.div
animate={{
opacity: respondida ? 0 : 1,
y: respondida ? -52 : 0,
rotate: respondida ? -3.4 : -0.8,
scale: respondida ? 0.96 : 1,
}}
animate={{ opacity: respondida ? 0 : 1, y: respondida ? -60 : 0 }}
transition={{ duration: 0.5, ease: EASE }}
style={{ pointerEvents: respondida ? 'none' : 'auto' }}
className="col-start-1 row-start-1 self-center justify-self-start max-w-xl rounded-xl border border-azul-profundo/10 bg-white p-7 shadow-[0_28px_70px_-32px_rgba(10,29,61,0.5)] md:p-9"
className="col-start-1 row-start-1 max-w-5xl"
>
<p className="text-[0.65rem] font-black uppercase tracking-[0.34em] text-dorado">
<p className="text-[0.7rem] font-black uppercase tracking-[0.34em] text-dorado md:text-xs">
{n} · {name}
</p>
<p className="mt-4 font-sans font-black leading-[1.12] text-azul-profundo text-2xl md:text-4xl">
<p className="mt-6 font-sans font-black leading-[0.98] text-azul-profundo text-4xl md:text-6xl lg:text-7xl">
«{situacion}»
</p>
</motion.div>
{/* creeer's card, handed back: navy against the reader's white, tilted
the other way, and coming in from the opposite margin. */}
{/* creeer's answer, on the navy the whole frame turns to. The point
label repeats here on purpose: with the question gone from the
screen, it is the only thing tying the answer to what it answers. */}
<motion.div
animate={{
opacity: respondida ? 1 : 0,
y: respondida ? 0 : 64,
rotate: respondida ? 1.6 : 5,
scale: respondida ? 1 : 0.95,
}}
animate={{ opacity: respondida ? 1 : 0, y: respondida ? 0 : 60 }}
transition={{ duration: 0.55, ease: EASE }}
style={{ pointerEvents: respondida ? 'auto' : 'none' }}
className="col-start-1 row-start-1 self-center justify-self-end max-w-xl rounded-xl bg-azul-profundo p-7 shadow-[0_34px_80px_-30px_rgba(10,29,61,0.75)] md:mr-6 md:p-9"
className="col-start-1 row-start-1 max-w-5xl"
>
<span aria-hidden="true" className="absolute left-0 top-7 h-10 w-[3px] rounded-r bg-dorado md:top-9" />
<Wordmark size="sm" onDark />
<p className="mt-4 text-base leading-relaxed text-white/75 md:text-xl">{respuesta}</p>
<div className="flex items-center gap-4">
<span aria-hidden="true" className="h-[3px] w-10 rounded bg-dorado" />
<p className="text-[0.7rem] font-black uppercase tracking-[0.34em] text-dorado md:text-xs">
{n} · {name}
</p>
</div>
<p className="mt-6 font-sans font-bold leading-[1.06] text-white text-3xl md:text-5xl lg:text-[3.6rem]">
{respuesta}
</p>
<div className="mt-10">
<Wordmark size="sm" onDark />
</div>
</motion.div>
</div>
</motion.li>
+18 -2
View File
@@ -111,11 +111,21 @@ function Escenificado() {
{/* One viewport of stage, six viewports of scroll to move through it. */}
<div ref={contenedor} className="relative" style={{ height: `${TOTAL * 100}vh` }}>
<div data-escena={activo} data-tiempo={tiempo} className="sticky top-0 h-screen overflow-hidden">
<div data-escena={activo} data-tiempo={tiempo} className="sticky top-0 h-screen overflow-hidden bg-gris-claro">
<motion.div style={{ y: fondoY }} className="absolute inset-0">
<FondoPuntos className="-bottom-10 -right-16 w-80 md:w-[34rem] text-azul-profundo/10" distance={32} />
</motion.div>
{/* The frame itself turns over on the answer. The whole stage goes to
azul-profundo, not a panel inside it — the answer is not a card on
the reader's page, it is creeer's ground. */}
<motion.div
aria-hidden="true"
animate={{ opacity: respondida ? 1 : 0 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0 bg-azul-profundo"
/>
<Container className="relative h-full">
{/* left-6 / md:left-12, not left-0: an absolutely positioned child
resolves against Container's padding box, so `left-0` lands on
@@ -133,7 +143,13 @@ function Escenificado() {
<li key={item.name}>
<span
className={`block h-px origin-right transition-all duration-500 ${
i === activo ? 'w-10 bg-dorado' : i < activo ? 'w-6 bg-dorado/40' : 'w-4 bg-azul-profundo/20'
i === activo
? 'w-10 bg-dorado'
: i < activo
? 'w-6 bg-dorado/40'
: respondida
? 'w-4 bg-white/25'
: 'w-4 bg-azul-profundo/20'
}`}
/>
</li>
@@ -9,12 +9,15 @@ describe('<PuntosInflexion />', () => {
expect(screen.getByText(/llegan a un punto de inflexión/i)).toBeInTheDocument();
});
it('renders the six inflection points from the dueña deck', () => {
it('labels both halves of every exchange with its inflection point', () => {
render(<PuntosInflexion />);
// Each point now renders once, not twice: the mobile carousel is gone and
// the conversation stacks vertically at every breakpoint.
// Twice per point, once on each frame. The pinned sequence shows one thing
// at a time, so when creeer's answer is on screen the question that
// prompted it is gone — the repeated label is the only thing left tying the
// two together. Dropping it from the answer would leave six statements
// floating with nothing to answer.
for (const name of ['Crecer', 'Consolidarse', 'Recuperar rentabilidad', 'Simplificar', 'Expandirse', 'Replantear el portafolio']) {
expect(screen.getByText(new RegExp(name))).toBeInTheDocument();
expect(screen.getAllByText(new RegExp(name))).toHaveLength(2);
}
expect(screen.getAllByRole('listitem')).toHaveLength(6);
});