feat(dolores): cada frase se resuelve al llegar, palabra a palabra

Prometí en la vista previa que entrarían «de una en una al bajar» y lo
que monté las traía a las cuatro a la vez: un stagger de contenedor se
dispara con la sección al 30% en pantalla, y la sección mide 1.200px, así
que el lector veía aparecer todas las frases mucho antes de llegar a
ellas.

Ahora cada cita usa TextoDesenfoque, que lleva su propio observador: cada
una espera su turno y entonces se resuelve desde un desenfoque, palabra a
palabra. Es el dolor entrando en foco, y es el mismo recurso que ya usan
el hero y la frase ancla — vocabulario del proyecto, no un invento nuevo.

Verificado midiendo la opacidad de la palabra animada de cada cita: al
asomar la sección, las dos primeras están resueltas y las dos últimas a
cero; a media sección, las cuatro. Antes de corregir el selector medía el
span envoltorio y daba unos en todas, que no significaba nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-08-03 16:07:00 -06:00
1 parent 672c567681
commit d4bff33e47
2 files changed
+23 -9

No files matched your search

+14 -7
View File
@@ -3,8 +3,9 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
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.
//
@@ -39,18 +40,24 @@ export function Dolores() {
<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 flex flex-col gap-9 md:gap-12 list-none p-0">
{/* No stagger on the list. A container stagger fires all four the moment
the section is 30% on screen, and the section is 1.200px tall — the
reader would watch every sentence appear long before scrolling to
it, which is not what "se apilan al bajar" means. TextoDesenfoque
carries its own observer, so each quote waits for its own arrival
and then resolves out of blur, word by word: the pain coming into
focus. Same device as the hero and the anchor phrase. */}
<ul 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]}`}>
<li key={quote} 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}
<TextoDesenfoque segments={[{ text: quote }]} delayMs={38} />
<span className="not-italic text-dorado">”</span>
</blockquote>
</motion.li>
</li>
))}
</motion.ul>
</ul>
{/* Flush left and still, against four sentences that are not. */}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
@@ -5,9 +5,16 @@ import { dolores } from '@/lib/content';
describe('<Dolores />', () => {
it('renders the four pains verbatim, in the client\'s first person', () => {
render(<Dolores />);
const { container } = render(<Dolores />);
// The quotes resolve word by word, so each word is its own span and no
// single element holds the whole sentence as its own text — getByText
// would never find it. The blockquote's textContent is what the reader
// ends up with, and that is what has to be verbatim.
const citas = Array.from(container.querySelectorAll('blockquote')).map((b) =>
(b.textContent ?? '').replace(/\s+/g, ' '),
);
for (const dolor of dolores.quotes) {
expect(screen.getAllByText(new RegExp(dolor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).length).toBeGreaterThan(0);
expect(citas.some((c) => c.includes(dolor))).toBe(true);
}
});