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 { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
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.
// 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.
// They were four tidy cards, which was the opposite of what they describe; then
// they were a marquee, which was worse — a loop belongs to a sale banner, and
// 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;
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>
);
}
// 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.
const SANGRIAS = ['md:ml-0', 'md:ml-[14%]', 'md:ml-[5%]', 'md:ml-[21%]'];
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">
@@ -72,40 +33,28 @@ export function Dolores() {
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 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)}
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>
</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">
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-14 flex flex-col gap-9 md:gap-12 list-none p-0">
{dolores.quotes.map((quote, i) => (
<motion.li key={quote} variants={fadeInUp} className={`max-w-3xl ${SANGRIAS[i]}`}>
<blockquote className="font-sans italic font-bold leading-snug text-azul-profundo text-2xl md:text-4xl lg:text-[2.6rem]">
<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>
)}
</blockquote>
</motion.li>
))}
</motion.ul>
<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)}
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}{' '}
<span className="text-dorado">{dolores.remate.accent}</span>
</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 />);
// 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.
// A marquee needed the four sentences painted four times over — twice per
// row for a seamless loop, twice again for the second row — and three of
// those runs had to be hidden from assistive tech or a screen reader would
// recite the whole list twice. Stacked, there is nothing to hide: one
// sentence, once, said once.
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),
);
const audibles = apariciones.filter((el) => !el.closest('[aria-hidden="true"]'));
expect(apariciones.length).toBeGreaterThan(1);
expect(audibles).toHaveLength(1);
expect(apariciones).toHaveLength(1);
expect(apariciones[0].closest('[aria-hidden="true"]')).toBeNull();
}
expect(container.querySelectorAll('li')).toHaveLength(dolores.quotes.length);
});
it('closes with the resolution, not with more effort', () => {
const { container } = render(<Dolores />);