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>
);
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ParDialogo } from '../ParDialogo';
const par = {
@@ -9,15 +9,22 @@ const par = {
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro.',
};
const montar = (extra: Partial<Parameters<typeof ParDialogo>[0]> = {}) =>
render(
<ul>
<ParDialogo {...par} activo reducido={false} onActivar={() => {}} {...extra} />
</ul>,
);
describe('<ParDialogo />', () => {
it('renders both voices of the exchange', () => {
render(<ul><ParDialogo {...par} /></ul>);
montar();
expect(screen.getByText(/El siguiente salto no tiene mapa/)).toBeInTheDocument();
expect(screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/)).toBeInTheDocument();
});
it('attributes the answer to creeer, not to the reader', () => {
render(<ul><ParDialogo {...par} /></ul>);
montar();
// Without a speaker label the answer reads as more description of the
// problem. The wordmark is that label — and <Wordmark /> is the only thing
// allowed to draw cre≡er, never hand-typed in the JSX.
@@ -25,22 +32,80 @@ describe('<ParDialogo />', () => {
});
it('labels the situation with its inflection point', () => {
render(<ul><ParDialogo {...par} /></ul>);
montar();
expect(screen.getByText(/01 · Crecer/)).toBeInTheDocument();
});
it('tells the two voices apart by more than colour', () => {
const { container } = render(<ul><ParDialogo {...par} /></ul>);
const { container } = montar();
// A reader who cannot separate gold from navy still has the speaker label,
// the column, and the rule hanging off each block. Colour alone would fail
// the spec's accessibility criterion.
const bloques = container.querySelectorAll('li > div');
expect(bloques).toHaveLength(2);
bloques.forEach((b) => expect(b.className).toMatch(/border-l-2/));
// the column, and a rule down the side of each block. Colour alone would
// fail the spec's accessibility criterion.
expect(container.querySelector('.border-l-2')).toBeInTheDocument();
expect(container.querySelector('span[aria-hidden="true"]')?.className).toMatch(/bg-dorado/);
});
it('is a list item, so the section can mount six of them as a list', () => {
render(<ul><ParDialogo {...par} /></ul>);
montar();
expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
it('keeps the answer in the DOM before the reader reaches it', () => {
montar({ activo: false });
// The answer waits its turn by going transparent, never by leaving the
// document. `hidden` or `display: none` would take it away from screen
// readers and from the static export the crawler sees — half the section's
// message, gone, to buy an animation.
const respuesta = screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/);
expect(respuesta).toBeInTheDocument();
expect(respuesta.closest('[hidden]')).toBeNull();
expect(screen.getByLabelText(/Respuesta de creeer a: Crecer/).className).toMatch(/opacity-0\b/);
});
it('keeps the reveal off any element Framer writes inline styles onto', () => {
montar({ activo: false });
// Framer resolves `fadeInUp` into the inline style — `opacity: 1` — and an
// inline style beats a Tailwind class. When the reveal lived on the same
// element as `variants`, the markup read correctly and the browser ignored
// it: every answer was legible from the moment the section scrolled in,
// and only the gold rule animated. The reveal must sit on a plain child
// that Framer never writes to.
const bloque = screen.getByLabelText(/Respuesta de creeer a: Crecer/);
expect(bloque.getAttribute('style') ?? '').not.toMatch(/opacity/);
});
it('arrives already answered under reduced motion, whatever the scroll did', () => {
montar({ activo: false, reducido: true });
// These readers get no scroll reveal at all. If the resolved state depended
// on the animation having run, the six answers would sit at opacity zero
// for them — the exact failure this project has already shipped twice.
const bloque = screen.getByLabelText(/Respuesta de creeer a: Crecer/);
expect(bloque.className).toMatch(/opacity-100/);
expect(bloque.querySelector('span[aria-hidden="true"]')?.className).toMatch(/scale-y-100/);
});
it('lets the reader drive the conversation, not only the scroll', () => {
const activar = vi.fn();
const { container } = montar({ onActivar: activar });
// Hover and keyboard focus reach the same state scroll does, so the pair
// is not the exclusive property of whoever scrolls with a mouse wheel.
const item = container.querySelector('li')!;
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
expect(activar).toHaveBeenCalledWith(1);
});
it('does not observe the viewport at all under reduced motion', () => {
const observar = vi.fn();
const original = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(() => ({
observe: observar,
unobserve: () => {},
disconnect: () => {},
})) as unknown as typeof window.IntersectionObserver;
montar({ reducido: true });
expect(observar).not.toHaveBeenCalled();
window.IntersectionObserver = original;
});
});
+23 -5
View File
@@ -1,5 +1,6 @@
'use client';
import { useCallback, useState } from 'react';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
@@ -10,6 +11,15 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
export function PuntosInflexion() {
const reduced = usePrefersReducedMotion();
// 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)), []);
return (
<section className="bg-gris-claro relative overflow-hidden">
<FondoPuntos className="-bottom-6 -right-12 w-80 md:w-[32rem] text-azul-profundo/10" distance={32} />
@@ -32,13 +42,21 @@ export function PuntosInflexion() {
{/* The conversation. One <li> per exchange, read top to bottom — no
carousel at any breakpoint, because "te bajas" is the whole shape
the dueña asked for. The stagger reaches ParDialogo's two halves
through React context, so each pair arrives in two beats: the
situation, then the answer. */}
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. */}
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-8 list-none p-0 flex flex-col gap-12 md:gap-16">
className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
{puntosInflexion.items.map((item, i) => (
<ParDialogo key={item.name} n={i + 1} {...item} />
<ParDialogo
key={item.name}
n={i + 1}
{...item}
activo={i + 1 <= alcanzado}
reducido={reduced}
onActivar={activar}
/>
))}
</motion.ul>
</Container>