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:
1 parent
c1f611e9ec
commit
7af36e2a93
2 files changed
+108
-24
No files matched your search
@@ -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}{' '}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
Reference in new issue
Block a user