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:
1 parent
253ff26fa2
commit
3f65a2a2c3
3 files changed
+68
-48
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user