feat(recorrido): la conversación se responde al bajar, y el lector puede gobernarla

Cada respuesta llega cuando su par alcanza el centro de la pantalla: la
regla dorada se dibuja y el bloque entra. El estado solo avanza, así que
nada se apaga por detrás del lector. El scroll no es el único mando —
hover y foco de teclado llevan al mismo sitio.

Corrige un fallo que las pruebas no podían ver: Framer resuelve la
variante escribiendo `opacity: 1` en el style inline, que gana a la clase
opacity-0 de Tailwind. El revelado no ocurría; solo se animaba la regla,
que es un transform que Framer no toca. La entrada y el revelado viven
ahora en elementos distintos, con una prueba que lo fija.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-08-03 11:33:56 -06:00
1 parent a200346780
commit 90a35714f2
3 files changed
+194 -33

No files matched your search

+95 -17
View File
@@ -1,11 +1,14 @@
'use client';
import { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark';
import { fadeInUp } from '@/lib/motion';
// One exchange of the page's conversation: the reader's situation, then what
// creeer does about it.
// creeer does about it. The answer arrives as you scroll into the pair — its
// gold rule draws down and the block comes up to full contrast — so the section
// reads as being answered rather than as a list that happens to have answers.
//
// The sides never swap from one pair to the next. Each voice keeps its own
// margin, the way a conversation reads on a page; what alternates going down is
@@ -13,39 +16,114 @@ import { fadeInUp } from '@/lib/motion';
// the reader the one cue that tells them who is speaking.
//
// Three cues carry that distinction, never colour alone: the speaker label, the
// column, and the rule hanging off each block. The answer also drops half a
// step below the situation on wide screens, so the eye reads it as a reply
// rather than as a second column of the same thought.
// column, and the rule hanging off each block.
//
// The answer text is in the DOM at all times, at every breakpoint — opacity,
// never `display: none` or `hidden`, so screen readers still announce it and
// the static export still ships it to search engines.
//
// `activo` latches: the section turns it on when the reader reaches the pair
// and never turns it off again. An answer that faded back out once you scrolled
// past would leave the page emptier the further you read, and would re-create
// the failure this project has already hit twice — content stranded at opacity
// zero with every test green, because jsdom has no layout and no scrolling.
//
// No motion.li here on purpose: Framer propagates variants through React
// context, so the two motion.div children still inherit the stagger from the
// <ul> the section wraps them in, even across this plain <li>. Making the item
// itself a motion.li would add a third beat nobody asked for.
// <ul> the section wraps them in, even across this plain <li>.
export function ParDialogo({
n,
name,
situacion,
respuesta,
activo,
reducido,
onActivar,
}: {
n: number;
name: string;
situacion: string;
respuesta: string;
activo: boolean;
reducido: boolean;
onActivar: (n: number) => void;
}) {
const ref = useRef<HTMLLIElement>(null);
// The pair crossing the middle of the viewport is the one being answered.
// The negative margins collapse the root to a band across the centre, so
// "in view" means "where the reader is actually looking", not "anywhere on
// screen" — which with six pairs would fire all of them at once.
useEffect(() => {
const el = ref.current;
if (!el || reducido) return;
const observer = new IntersectionObserver(
([entrada]) => {
if (entrada.isIntersecting) onActivar(n);
},
{ rootMargin: '-45% 0px -45% 0px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [n, reducido, onActivar]);
// Reduced motion gets the resolved state outright: everything at full
// contrast, every rule drawn. Never the dimmed half of an animation that
// will not run.
const resuelto = reducido || activo;
return (
<li className="grid gap-5 md:grid-cols-2 md:gap-12">
<motion.div variants={fadeInUp} className="border-l-2 border-azul-profundo/25 pl-5">
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado">
{String(n).padStart(2, '0')} · {name}
</p>
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
«{situacion}»
</p>
<li
ref={ref}
data-activo={resuelto ? 'true' : 'false'}
onMouseEnter={() => onActivar(n)}
onFocus={() => onActivar(n)}
className="grid gap-5 md:grid-cols-2 md:gap-12"
>
{/* Two nested layers on purpose, and they must stay nested. Framer writes
the resolved variant into the inline `style` — `opacity: 1` — and an
inline style beats a Tailwind class. Putting the reveal on the same
element as `variants` looked right in the markup and did nothing in the
browser: the answers were readable from the moment the section entered,
and only the gold rule, a transform Framer never touches, animated.
So: motion.div owns the entrance, the plain child owns the reveal. */}
<motion.div variants={fadeInUp}>
<div
className={`border-l-2 border-azul-profundo/25 pl-5 transition-opacity duration-500 motion-reduce:transition-none ${
resuelto ? 'opacity-100' : 'opacity-55'
}`}
>
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado">
{String(n).padStart(2, '0')} · {name}
</p>
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
«{situacion}»
</p>
</div>
</motion.div>
<motion.div variants={fadeInUp} className="border-l-2 border-dorado pl-5 md:mt-10">
<Wordmark size="sm" />
<p className="mt-3 text-gris-medio leading-relaxed">{respuesta}</p>
<motion.div variants={fadeInUp} className="md:mt-10">
<div
tabIndex={0}
aria-label={`Respuesta de creeer a: ${name}`}
className={`relative pl-5 transition-opacity duration-500 motion-reduce:transition-none ${
resuelto ? 'opacity-100' : 'opacity-0'
}`}
>
{/* The rule draws itself down as the answer arrives. A transform, not
a stroke: a dash pattern on a non-uniformly scaled SVG is exactly
what broke the hilo conductor, and this has to survive any
container it lands in. */}
<span
aria-hidden="true"
className={`absolute left-0 top-0 h-full w-0.5 bg-dorado origin-top transition-transform duration-700 ease-out motion-reduce:transition-none ${
resuelto ? 'scale-y-100' : 'scale-y-0'
}`}
/>
<Wordmark size="sm" />
<p className="mt-3 text-gris-medio leading-relaxed">{respuesta}</p>
</div>
</motion.div>
</li>
);