feat(dolores): las cuatro frases dejan de estar ordenaditas y pasan a moverse

Eran cuatro tarjetas alineadas describiendo desorden — lo contrario de lo
que dicen. Ahora cruzan la sección en dos filas lentas y en direcciones
opuestas, a sangre, mientras el remate en dorado no se mueve. Ese
contraste es el argumento de la sección.

Siguen legibles a propósito. Bajarlas a un susurro habría dado mejor
textura y habría tirado a la basura la voz del cliente: son palabras
suyas, verbatim de la diapositiva 3. El murmullo lo hace el movimiento.

Cada frase se pinta cuatro veces —dos por fila para que el bucle no tenga
costura, y dos filas— pero solo una llega a los lectores de pantalla. Sin
eso recitarían la lista entera de dolores y luego la recitarían otra vez.
Hay una prueba que lo exige.

Con prefers-reduced-motion no hay rótulo: las cuatro frases se leen
quietas a dos columnas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-08-03 15:01:37 -06:00
1 parent c1f611e9ec
commit 7af36e2a93
2 files changed
+108 -24

No files matched your search

+88 -19
View File
@@ -3,38 +3,107 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { dolores } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
// The four quotes are the client's own words, from slide 3 of the dueña's deck.
// They used to sit in four tidy cards — the opposite of what they describe. Here
// they drift past instead: the noise you carry around, still going, while the
// resolution underneath does not move at all. The contrast between the two is
// the argument.
//
// They stay legible on purpose. Fading them to a whisper would have made a
// better texture and thrown away the client's own voice; the murmur is carried
// by the motion, not by hiding the words.
const VUELTA_SEGUNDOS = 58;
function Rotulo({
frases,
haciaIzquierda,
mudo = false,
}: {
frases: string[];
haciaIzquierda: boolean;
mudo?: boolean;
}) {
const tanda = (oculta: boolean) => (
<div aria-hidden={oculta || mudo || undefined} className="flex shrink-0 items-center gap-12 pr-12 md:gap-20 md:pr-20">
{frases.map((frase) => (
<span
key={frase}
className="whitespace-nowrap font-sans italic font-bold leading-none text-azul-profundo/75 text-2xl md:text-4xl lg:text-5xl"
>
<span className="not-italic text-dorado">“</span>
{frase}
<span className="not-italic text-dorado">”</span>
</span>
))}
</div>
);
return (
<div className="flex overflow-hidden">
{/* Two identical runs inside one track: translating the track by exactly
half its own width lands the second run where the first began, so the
loop has no seam. The duplicate is hidden from assistive tech — it is
the same four sentences twice. */}
<motion.div
className="flex"
animate={{ x: haciaIzquierda ? ['0%', '-50%'] : ['-50%', '0%'] }}
transition={{ duration: VUELTA_SEGUNDOS, ease: 'linear', repeat: Infinity }}
>
{tanda(false)}
{tanda(true)}
</motion.div>
</div>
);
}
export function Dolores() {
const reduced = usePrefersReducedMotion();
// Rotated so the two rows never show the same sentence side by side.
const segunda = [...dolores.quotes.slice(2), ...dolores.quotes.slice(0, 2)];
return (
<section className="bg-white relative overflow-hidden">
{/* Smaller and a touch stronger than it was: sprawling at 8% across the
whole title it read as a smudge over the headline rather than as a
knot. Stays centred so the conductor thread runs through it. */}
knot. Stays centred. */}
<IlustracionNudo className="pointer-events-none select-none absolute top-0 left-1/2 -translate-x-1/2 w-56 md:w-80 text-azul-profundo/[0.11]" />
<Container className="relative py-16 md:py-24">
<Container className="relative pt-16 md:pt-24">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{dolores.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-3xl">{dolores.title}</motion.h2>
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-14 grid gap-8 md:grid-cols-2 list-none p-0">
{dolores.quotes.map((quote) => (
<motion.li key={quote} variants={fadeInUp}
className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none">
<TarjetaSpotlight className="h-full rounded-lg bg-gris-claro shadow-lg shadow-azul-profundo/5 border-l-4 border-dorado p-7">
<blockquote className="font-sans italic text-xl md:text-2xl leading-snug text-azul-profundo">
<span className="text-dorado not-italic">“</span>
{quote}
<span className="text-dorado not-italic">”</span>
</blockquote>
</TarjetaSpotlight>
</motion.li>
))}
</motion.ul>
</Container>
{/* Full-bleed on purpose: the noise does not respect the column. */}
{reduced ? (
<Container className="relative mt-12">
<ul className="grid gap-6 md:grid-cols-2 list-none p-0">
{dolores.quotes.map((quote) => (
<li key={quote} className="font-sans italic font-bold text-xl md:text-2xl leading-snug text-azul-profundo/75">
<span className="not-italic text-dorado">“</span>
{quote}
<span className="not-italic text-dorado">”</span>
</li>
))}
</ul>
</Container>
) : (
<div className="relative mt-12 flex flex-col gap-4 md:mt-16 md:gap-7">
<Rotulo frases={dolores.quotes} haciaIzquierda />
{/* The second row is the same four sentences again, rotated so no
pair lines up. It is a visual echo, so it is silent: without
`mudo` a screen reader would read all four pains, then read them
all over again. */}
<Rotulo frases={segunda} haciaIzquierda={false} mudo />
</div>
)}
<Container className="relative pb-16 md:pb-24">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-16 font-sans font-black text-ancla text-azul-profundo max-w-4xl">
{dolores.remate.before}{' '}
+20 -5
View File
@@ -1,15 +1,30 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Dolores } from '../Dolores';
import { dolores } from '@/lib/content';
describe('<Dolores />', () => {
it('renders the four pains verbatim, in the client\'s first person', () => {
render(<Dolores />);
expect(screen.getByText(/Trabajo más y gano casi lo mismo que hace dos años\./)).toBeInTheDocument();
expect(screen.getByText(/no sé en cuál enfocar mi tiempo y mi dinero\./)).toBeInTheDocument();
expect(screen.getByText(/no puede crecer si yo no estoy en cada decisión\./)).toBeInTheDocument();
expect(screen.getByText(/ya no me sirve para el siguiente nivel\./)).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(4);
for (const dolor of dolores.quotes) {
expect(screen.getAllByText(new RegExp(dolor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).length).toBeGreaterThan(0);
}
});
it('says each pain only once out loud, however many times it is painted', () => {
const { container } = render(<Dolores />);
// The drifting rows repeat the four sentences four times over: twice per
// row so the loop has no seam, and twice again because there are two rows.
// Only one of those runs may reach assistive tech — otherwise a screen
// reader recites the whole list of pains, and then recites it again.
for (const dolor of dolores.quotes) {
const apariciones = Array.from(container.querySelectorAll('span')).filter(
(el) => el.textContent?.includes(dolor),
);
const audibles = apariciones.filter((el) => !el.closest('[aria-hidden="true"]'));
expect(apariciones.length).toBeGreaterThan(1);
expect(audibles).toHaveLength(1);
}
});
it('closes with the resolution, not with more effort', () => {
const { container } = render(<Dolores />);