feat(dolores): las cuatro frases se apilan en vez de desfilar

El rótulo en bucle era idea mía y era peor que las tarjetas que sustituía.
Medido contra el tono bloqueado de la marca —«seguridad + cercanía»,
«simple, directo, limpio»— un marquee es lo contrario de los tres:
arrastra la asociación con banner de rebajas y teletipo. Y cortaba cada
frase por los dos bordes, así que ninguna llegaba entera.

Ahora entran de una en una al bajar y se quedan. El peso lo da el montón,
no el recorrido: cada una cae con una sangría distinta —irregular a
propósito, una regular se lee como lista y una irregular como cuatro
voces en la misma habitación— y al llegar la cuarta el lector las ve
todas juntas. Debajo, el remate alineado a la izquierda y quieto.

Se acabó la duplicación: el bucle necesitaba pintar cada frase cuatro
veces y esconder tres de los lectores de pantalla. Una frase, una vez.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-08-03 15:50:17 -06:00
1 parent 2ae3c3ad9b
commit 99c9f77547
2 files changed
+36 -86

No files matched your search

+26 -77
View File
@@ -4,66 +4,27 @@ import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo'; import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
import { dolores } from '@/lib/content'; import { dolores } from '@/lib/content';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
// The four quotes are the client's own words, from slide 3 of the dueña's deck. // 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 // They were four tidy cards, which was the opposite of what they describe; then
// better texture and thrown away the client's own voice; the murmur is carried // they were a marquee, which was worse — a loop belongs to a sale banner, and
// by the motion, not by hiding the words. // the brand's locked tone is "seguridad + cercanía", "simple, directo, limpio".
// It also sliced every sentence at both edges, so none was ever fully framed.
//
// Now they arrive one at a time as the reader comes down and they stay. What
// carries the weight is the pile, not the travel: each one lands further in or
// further out than the last, the way overheard sentences do, and by the fourth
// the reader is looking at everything at once. Then the resolution, flush left
// and gold, with nothing moving.
const VUELTA_SEGUNDOS = 58; // Deliberately not a staircase. A regular indent reads as a list; an irregular
// one reads as four separate voices that happen to be in the same room.
function Rotulo({ const SANGRIAS = ['md:ml-0', 'md:ml-[14%]', 'md:ml-[5%]', 'md:ml-[21%]'];
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() { export function Dolores() {
const reduced = usePrefersReducedMotion(); 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 ( return (
<section className="bg-white relative overflow-hidden"> <section className="bg-white relative overflow-hidden">
@@ -72,40 +33,28 @@ export function Dolores() {
knot. Stays centred. */} 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]" /> <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 pt-16 md:pt-24"> <Container className="relative py-16 md:py-24">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <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> 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)} <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> className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-3xl">{dolores.title}</motion.h2>
</Container>
{/* Full-bleed on purpose: the noise does not respect the column. */} <motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
{reduced ? ( className="mt-14 flex flex-col gap-9 md:gap-12 list-none p-0">
<Container className="relative mt-12"> {dolores.quotes.map((quote, i) => (
<ul className="grid gap-6 md:grid-cols-2 list-none p-0"> <motion.li key={quote} variants={fadeInUp} className={`max-w-3xl ${SANGRIAS[i]}`}>
{dolores.quotes.map((quote) => ( <blockquote className="font-sans italic font-bold leading-snug text-azul-profundo text-2xl md:text-4xl lg:text-[2.6rem]">
<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> <span className="not-italic text-dorado">“</span>
{quote} {quote}
<span className="not-italic text-dorado">”</span> <span className="not-italic text-dorado">”</span>
</li> </blockquote>
))} </motion.li>
</ul> ))}
</Container> </motion.ul>
) : (
<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"> {/* Flush left and still, against four sentences that are not. */}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <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"> className="mt-16 md:mt-20 font-sans font-black text-ancla text-azul-profundo max-w-4xl">
{dolores.remate.before}{' '} {dolores.remate.before}{' '}
<span className="text-dorado">{dolores.remate.accent}</span> <span className="text-dorado">{dolores.remate.accent}</span>
</motion.p> </motion.p>
+10 -9
View File
@@ -11,20 +11,21 @@ describe('<Dolores />', () => {
} }
}); });
it('says each pain only once out loud, however many times it is painted', () => { it('says each pain exactly once', () => {
const { container } = render(<Dolores />); const { container } = render(<Dolores />);
// The drifting rows repeat the four sentences four times over: twice per // A marquee needed the four sentences painted four times over — twice per
// row so the loop has no seam, and twice again because there are two rows. // row for a seamless loop, twice again for the second row — and three of
// Only one of those runs may reach assistive tech — otherwise a screen // those runs had to be hidden from assistive tech or a screen reader would
// reader recites the whole list of pains, and then recites it again. // recite the whole list twice. Stacked, there is nothing to hide: one
// sentence, once, said once.
for (const dolor of dolores.quotes) { for (const dolor of dolores.quotes) {
const apariciones = Array.from(container.querySelectorAll('span')).filter( const apariciones = Array.from(container.querySelectorAll('blockquote')).filter(
(el) => el.textContent?.includes(dolor), (el) => el.textContent?.includes(dolor),
); );
const audibles = apariciones.filter((el) => !el.closest('[aria-hidden="true"]')); expect(apariciones).toHaveLength(1);
expect(apariciones.length).toBeGreaterThan(1); expect(apariciones[0].closest('[aria-hidden="true"]')).toBeNull();
expect(audibles).toHaveLength(1);
} }
expect(container.querySelectorAll('li')).toHaveLength(dolores.quotes.length);
}); });
it('closes with the resolution, not with more effort', () => { it('closes with the resolution, not with more effort', () => {
const { container } = render(<Dolores />); const { container } = render(<Dolores />);