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:
1 parent
2ae3c3ad9b
commit
99c9f77547
2 files changed
+36
-86
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
Reference in new issue
Block a user