fix(recorrido): no se puede quedar entre dos estados
Las tarjetas iban atadas a la posición del scroll, así que cualquier punto intermedio era un fotograma válido: parando a media rueda, la tarjeta de creeer se quedaba como una losa gris translúcida y parecía un fallo de render. Ahora el scroll solo elige el tiempo —doce: pregunta, respuesta, pregunta…— y las tarjetas animan hacia ese tiempo por duración. Pares donde pares, aterrizan. Verificado en 41 paradas repartidas por la sección: cero opacidades a medias. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81c36bb657
commit
379d80b915
2 files changed
+54
-49
No files matched your search
@@ -6,19 +6,25 @@ import { Wordmark } from '@/components/primitives/Wordmark';
|
|||||||
// One beat of the pinned conversation, staged as two calling cards laid on a
|
// 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.
|
// 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
|
// The cards are driven by STATE, not by scroll position. Mapping opacity
|
||||||
// changes is opacity and offset, driven by the section's scroll position.
|
// straight onto scroll made every intermediate position a valid frame, so
|
||||||
// Mounting only the active scene would animate more cheaply and would strip
|
// stopping mid-swipe left creeer's card as a half-transparent grey rectangle —
|
||||||
// five sixths of the section out of the static export.
|
// 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.
|
||||||
//
|
//
|
||||||
// Each scene owns a slice of that scroll: the question card arrives at the top
|
// Every scene stays mounted and stacked in the same place. Mounting only the
|
||||||
// of its slice, creeer's card is handed over around the middle, and both leave
|
// active one would animate more cheaply and would strip five sixths of the
|
||||||
// as the next question comes up.
|
// section out of the static export.
|
||||||
|
|
||||||
|
const EASE = [0.22, 1, 0.36, 1] as const;
|
||||||
|
|
||||||
export function EscenaDialogo({
|
export function EscenaDialogo({
|
||||||
i,
|
i,
|
||||||
total,
|
total,
|
||||||
progreso,
|
progreso,
|
||||||
|
activa,
|
||||||
|
respondida,
|
||||||
n,
|
n,
|
||||||
name,
|
name,
|
||||||
situacion,
|
situacion,
|
||||||
@@ -27,46 +33,26 @@ export function EscenaDialogo({
|
|||||||
i: number;
|
i: number;
|
||||||
total: number;
|
total: number;
|
||||||
progreso: MotionValue<number>;
|
progreso: MotionValue<number>;
|
||||||
|
activa: boolean;
|
||||||
|
respondida: boolean;
|
||||||
n: string;
|
n: string;
|
||||||
name: string;
|
name: string;
|
||||||
situacion: string;
|
situacion: string;
|
||||||
respuesta: string;
|
respuesta: string;
|
||||||
}) {
|
}) {
|
||||||
const paso = 1 / total;
|
const paso = 1 / total;
|
||||||
const inicio = i * paso;
|
// The one thing still tied to scroll. A drifting numeral never looks broken
|
||||||
const fin = inicio + paso;
|
// at any position it can be caught in, so it costs nothing and keeps the
|
||||||
|
// stage from feeling frozen between beats.
|
||||||
// Entrance and exit must not overlap. Every scene is stacked in the same
|
const cifraY = useTransform(progreso, [i * paso - paso, (i + 1) * paso + paso], [60, -60]);
|
||||||
// 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 (
|
return (
|
||||||
<motion.li
|
<motion.li
|
||||||
style={{ opacity: opacidad }}
|
data-activa={activa ? 'true' : 'false'}
|
||||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 will-change-[opacity]"
|
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"
|
||||||
|
style={{ pointerEvents: activa ? 'auto' : 'none' }}
|
||||||
>
|
>
|
||||||
<motion.span
|
<motion.span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -76,10 +62,15 @@ export function EscenaDialogo({
|
|||||||
{n}
|
{n}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
|
||||||
<motion.div style={{ y: deriva }} className="relative">
|
{/* The reader's card. It gives way a little once the answer lands: still
|
||||||
{/* The reader's card. */}
|
legible, no longer the thing you are looking at. */}
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{ opacity: pregOpacidad, rotate: pregGiro, y: pregY }}
|
animate={{
|
||||||
|
opacity: respondida ? 0.72 : 1,
|
||||||
|
rotate: respondida ? -1.8 : -0.6,
|
||||||
|
y: respondida ? -14 : 0,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.5, ease: EASE }}
|
||||||
className="relative z-10 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="relative z-10 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"
|
||||||
>
|
>
|
||||||
<p className="text-[0.65rem] font-black uppercase tracking-[0.34em] text-dorado">
|
<p className="text-[0.65rem] font-black uppercase tracking-[0.34em] text-dorado">
|
||||||
@@ -90,18 +81,23 @@ export function EscenaDialogo({
|
|||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* creeer's card, handed back. Navy against the reader's white, tilted
|
{/* creeer's card, handed back. Navy against the reader's white, tilted the
|
||||||
the other way, overlapping the corner — two cards on a table, not
|
other way, overlapping the corner — two cards on a table, not two
|
||||||
two paragraphs in a column. */}
|
paragraphs in a column. */}
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{ opacity: revelado, y: respY, scale: respEscala, rotate: respGiro }}
|
animate={{
|
||||||
|
opacity: respondida ? 1 : 0,
|
||||||
|
y: respondida ? 0 : 70,
|
||||||
|
scale: respondida ? 1 : 0.94,
|
||||||
|
rotate: respondida ? 1.6 : 5.5,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.55, ease: EASE }}
|
||||||
className="relative z-20 -mt-5 ml-auto max-w-lg rounded-xl bg-azul-profundo p-7 shadow-[0_34px_80px_-30px_rgba(10,29,61,0.75)] md:-mt-8 md:mr-6 md:p-9"
|
className="relative z-20 -mt-5 ml-auto max-w-lg rounded-xl bg-azul-profundo p-7 shadow-[0_34px_80px_-30px_rgba(10,29,61,0.75)] md:-mt-8 md:mr-6 md:p-9"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="absolute left-0 top-7 h-10 w-[3px] rounded-r bg-dorado md:top-9" />
|
<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 />
|
<Wordmark size="sm" onDark />
|
||||||
<p className="mt-4 text-base leading-relaxed text-white/75 md:text-xl">{respuesta}</p>
|
<p className="mt-4 text-base leading-relaxed text-white/75 md:text-xl">{respuesta}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
</motion.li>
|
</motion.li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,10 +88,17 @@ function Escenificado() {
|
|||||||
// hilo conductor taught this project what an overdamped spring feels like.
|
// hilo conductor taught this project what an overdamped spring feels like.
|
||||||
const progreso = useSpring(bruto, { stiffness: 260, damping: 42, restDelta: 0.0005 });
|
const progreso = useSpring(bruto, { stiffness: 260, damping: 42, restDelta: 0.0005 });
|
||||||
|
|
||||||
const [activo, setActivo] = useState(0);
|
// 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) => {
|
useMotionValueEvent(progreso, 'change', (v) => {
|
||||||
setActivo(Math.min(TOTAL - 1, Math.max(0, Math.floor(v * TOTAL))));
|
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 barra = useTransform(progreso, [0, 1], [0, 1]);
|
||||||
const fondoY = useTransform(progreso, [0, 1], [0, -140]);
|
const fondoY = useTransform(progreso, [0, 1], [0, -140]);
|
||||||
@@ -104,7 +111,7 @@ function Escenificado() {
|
|||||||
|
|
||||||
{/* One viewport of stage, six viewports of scroll to move through it. */}
|
{/* One viewport of stage, six viewports of scroll to move through it. */}
|
||||||
<div ref={contenedor} className="relative" style={{ height: `${TOTAL * 100}vh` }}>
|
<div ref={contenedor} className="relative" style={{ height: `${TOTAL * 100}vh` }}>
|
||||||
<div data-escena={activo} className="sticky top-0 h-screen overflow-hidden">
|
<div data-escena={activo} data-tiempo={tiempo} className="sticky top-0 h-screen overflow-hidden">
|
||||||
<motion.div style={{ y: fondoY }} className="absolute inset-0">
|
<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} />
|
<FondoPuntos className="-bottom-10 -right-16 w-80 md:w-[34rem] text-azul-profundo/10" distance={32} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -140,6 +147,8 @@ function Escenificado() {
|
|||||||
i={i}
|
i={i}
|
||||||
total={TOTAL}
|
total={TOTAL}
|
||||||
progreso={progreso}
|
progreso={progreso}
|
||||||
|
activa={i === activo}
|
||||||
|
respondida={i === activo && respondida}
|
||||||
n={String(i + 1).padStart(2, '0')}
|
n={String(i + 1).padStart(2, '0')}
|
||||||
{...item}
|
{...item}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user