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:
1 parent
a200346780
commit
90a35714f2
3 files changed
+194
-33
No files matched your search
@@ -1,11 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||||
import { fadeInUp } from '@/lib/motion';
|
import { fadeInUp } from '@/lib/motion';
|
||||||
|
|
||||||
// One exchange of the page's conversation: the reader's situation, then what
|
// 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
|
// 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
|
// 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.
|
// the reader the one cue that tells them who is speaking.
|
||||||
//
|
//
|
||||||
// Three cues carry that distinction, never colour alone: the speaker label, the
|
// 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
|
// column, and the rule hanging off each block.
|
||||||
// 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.
|
// 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
|
// No motion.li here on purpose: Framer propagates variants through React
|
||||||
// context, so the two motion.div children still inherit the stagger from the
|
// 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
|
// <ul> the section wraps them in, even across this plain <li>.
|
||||||
// itself a motion.li would add a third beat nobody asked for.
|
|
||||||
|
|
||||||
export function ParDialogo({
|
export function ParDialogo({
|
||||||
n,
|
n,
|
||||||
name,
|
name,
|
||||||
situacion,
|
situacion,
|
||||||
respuesta,
|
respuesta,
|
||||||
|
activo,
|
||||||
|
reducido,
|
||||||
|
onActivar,
|
||||||
}: {
|
}: {
|
||||||
n: number;
|
n: number;
|
||||||
name: string;
|
name: string;
|
||||||
situacion: string;
|
situacion: string;
|
||||||
respuesta: 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 (
|
return (
|
||||||
<li className="grid gap-5 md:grid-cols-2 md:gap-12">
|
<li
|
||||||
<motion.div variants={fadeInUp} className="border-l-2 border-azul-profundo/25 pl-5">
|
ref={ref}
|
||||||
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado">
|
data-activo={resuelto ? 'true' : 'false'}
|
||||||
{String(n).padStart(2, '0')} · {name}
|
onMouseEnter={() => onActivar(n)}
|
||||||
</p>
|
onFocus={() => onActivar(n)}
|
||||||
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
|
className="grid gap-5 md:grid-cols-2 md:gap-12"
|
||||||
«{situacion}»
|
>
|
||||||
</p>
|
{/* 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>
|
||||||
<motion.div variants={fadeInUp} className="border-l-2 border-dorado pl-5 md:mt-10">
|
|
||||||
<Wordmark size="sm" />
|
<motion.div variants={fadeInUp} className="md:mt-10">
|
||||||
<p className="mt-3 text-gris-medio leading-relaxed">{respuesta}</p>
|
<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>
|
</motion.div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { ParDialogo } from '../ParDialogo';
|
import { ParDialogo } from '../ParDialogo';
|
||||||
|
|
||||||
const par = {
|
const par = {
|
||||||
@@ -9,15 +9,22 @@ const par = {
|
|||||||
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro.',
|
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 />', () => {
|
describe('<ParDialogo />', () => {
|
||||||
it('renders both voices of the exchange', () => {
|
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(/El siguiente salto no tiene mapa/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/)).toBeInTheDocument();
|
expect(screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attributes the answer to creeer, not to the reader', () => {
|
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
|
// Without a speaker label the answer reads as more description of the
|
||||||
// problem. The wordmark is that label — and <Wordmark /> is the only thing
|
// problem. The wordmark is that label — and <Wordmark /> is the only thing
|
||||||
// allowed to draw cre≡er, never hand-typed in the JSX.
|
// 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', () => {
|
it('labels the situation with its inflection point', () => {
|
||||||
render(<ul><ParDialogo {...par} /></ul>);
|
montar();
|
||||||
expect(screen.getByText(/01 · Crecer/)).toBeInTheDocument();
|
expect(screen.getByText(/01 · Crecer/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tells the two voices apart by more than colour', () => {
|
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,
|
// 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 column, and a rule down the side of each block. Colour alone would
|
||||||
// the spec's accessibility criterion.
|
// fail the spec's accessibility criterion.
|
||||||
const bloques = container.querySelectorAll('li > div');
|
expect(container.querySelector('.border-l-2')).toBeInTheDocument();
|
||||||
expect(bloques).toHaveLength(2);
|
expect(container.querySelector('span[aria-hidden="true"]')?.className).toMatch(/bg-dorado/);
|
||||||
bloques.forEach((b) => expect(b.className).toMatch(/border-l-2/));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a list item, so the section can mount six of them as a list', () => {
|
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);
|
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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
|
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
|
||||||
@@ -10,6 +11,15 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
|
|||||||
|
|
||||||
export function PuntosInflexion() {
|
export function PuntosInflexion() {
|
||||||
const reduced = usePrefersReducedMotion();
|
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 (
|
return (
|
||||||
<section className="bg-gris-claro relative overflow-hidden">
|
<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} />
|
<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
|
{/* The conversation. One <li> per exchange, read top to bottom — no
|
||||||
carousel at any breakpoint, because "te bajas" is the whole shape
|
carousel at any breakpoint, because "te bajas" is the whole shape
|
||||||
the dueña asked for. The stagger reaches ParDialogo's two halves
|
the dueña asked for. Each answer arrives when the reader reaches its
|
||||||
through React context, so each pair arrives in two beats: the
|
pair, so the section answers as you go down instead of sitting there
|
||||||
situation, then the answer. */}
|
already answered. The stagger reaches ParDialogo's two halves
|
||||||
|
through React context. */}
|
||||||
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
<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) => (
|
{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>
|
</motion.ul>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
Reference in new issue
Block a user