diff --git a/components/primitives/ParDialogo.tsx b/components/primitives/ParDialogo.tsx
index a3b2d21..21d068a 100644
--- a/components/primitives/ParDialogo.tsx
+++ b/components/primitives/ParDialogo.tsx
@@ -1,11 +1,14 @@
'use client';
+import { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark';
import { fadeInUp } from '@/lib/motion';
// One exchange of the page's conversation: the reader's situation, then what
-// creeer does about it.
+// creeer does about it. The answer arrives as you scroll into the pair — its
+// gold rule draws down and the block comes up to full contrast — so the section
+// reads as being answered rather than as a list that happens to have answers.
//
// The sides never swap from one pair to the next. Each voice keeps its own
// margin, the way a conversation reads on a page; what alternates going down is
@@ -13,39 +16,114 @@ import { fadeInUp } from '@/lib/motion';
// the reader the one cue that tells them who is speaking.
//
// Three cues carry that distinction, never colour alone: the speaker label, the
-// column, and the rule hanging off each block. The answer also drops half a
-// step below the situation on wide screens, so the eye reads it as a reply
-// rather than as a second column of the same thought.
+// column, and the rule hanging off each block.
+//
+// The answer text is in the DOM at all times, at every breakpoint — opacity,
+// never `display: none` or `hidden`, so screen readers still announce it and
+// the static export still ships it to search engines.
+//
+// `activo` latches: the section turns it on when the reader reaches the pair
+// and never turns it off again. An answer that faded back out once you scrolled
+// past would leave the page emptier the further you read, and would re-create
+// the failure this project has already hit twice — content stranded at opacity
+// zero with every test green, because jsdom has no layout and no scrolling.
//
// No motion.li here on purpose: Framer propagates variants through React
// context, so the two motion.div children still inherit the stagger from the
-//
the section wraps them in, even across this plain - . Making the item
-// itself a motion.li would add a third beat nobody asked for.
+//
the section wraps them in, even across this plain - .
export function ParDialogo({
n,
name,
situacion,
respuesta,
+ activo,
+ reducido,
+ onActivar,
}: {
n: number;
name: string;
situacion: string;
respuesta: string;
+ activo: boolean;
+ reducido: boolean;
+ onActivar: (n: number) => void;
}) {
+ const ref = useRef(null);
+
+ // The pair crossing the middle of the viewport is the one being answered.
+ // The negative margins collapse the root to a band across the centre, so
+ // "in view" means "where the reader is actually looking", not "anywhere on
+ // screen" — which with six pairs would fire all of them at once.
+ useEffect(() => {
+ const el = ref.current;
+ if (!el || reducido) return;
+ const observer = new IntersectionObserver(
+ ([entrada]) => {
+ if (entrada.isIntersecting) onActivar(n);
+ },
+ { rootMargin: '-45% 0px -45% 0px' },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [n, reducido, onActivar]);
+
+ // Reduced motion gets the resolved state outright: everything at full
+ // contrast, every rule drawn. Never the dimmed half of an animation that
+ // will not run.
+ const resuelto = reducido || activo;
+
return (
-
-
-
-
- {String(n).padStart(2, '0')} · {name}
-
-
- «{situacion}»
-
+ - onActivar(n)}
+ onFocus={() => onActivar(n)}
+ className="grid gap-5 md:grid-cols-2 md:gap-12"
+ >
+ {/* Two nested layers on purpose, and they must stay nested. Framer writes
+ the resolved variant into the inline `style` — `opacity: 1` — and an
+ inline style beats a Tailwind class. Putting the reveal on the same
+ element as `variants` looked right in the markup and did nothing in the
+ browser: the answers were readable from the moment the section entered,
+ and only the gold rule, a transform Framer never touches, animated.
+ So: motion.div owns the entrance, the plain child owns the reveal. */}
+
+
+
+ {String(n).padStart(2, '0')} · {name}
+
+
+ «{situacion}»
+
+
-
-
- {respuesta}
+
+
+
+ {/* The rule draws itself down as the answer arrives. A transform, not
+ a stroke: a dash pattern on a non-uniformly scaled SVG is exactly
+ what broke the hilo conductor, and this has to survive any
+ container it lands in. */}
+
+
+
{respuesta}
+
);
diff --git a/components/primitives/__tests__/ParDialogo.test.tsx b/components/primitives/__tests__/ParDialogo.test.tsx
index aa01985..d4ca31d 100644
--- a/components/primitives/__tests__/ParDialogo.test.tsx
+++ b/components/primitives/__tests__/ParDialogo.test.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { ParDialogo } from '../ParDialogo';
const par = {
@@ -9,15 +9,22 @@ const par = {
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro.',
};
+const montar = (extra: Partial[0]> = {}) =>
+ render(
+ ,
+ );
+
describe('', () => {
it('renders both voices of the exchange', () => {
- render();
+ montar();
expect(screen.getByText(/El siguiente salto no tiene mapa/)).toBeInTheDocument();
expect(screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/)).toBeInTheDocument();
});
it('attributes the answer to creeer, not to the reader', () => {
- render();
+ montar();
// Without a speaker label the answer reads as more description of the
// problem. The wordmark is that label — and is the only thing
// allowed to draw cre≡er, never hand-typed in the JSX.
@@ -25,22 +32,80 @@ describe('', () => {
});
it('labels the situation with its inflection point', () => {
- render();
+ montar();
expect(screen.getByText(/01 · Crecer/)).toBeInTheDocument();
});
it('tells the two voices apart by more than colour', () => {
- const { container } = render();
+ const { container } = montar();
// A reader who cannot separate gold from navy still has the speaker label,
- // the column, and the rule hanging off each block. Colour alone would fail
- // the spec's accessibility criterion.
- const bloques = container.querySelectorAll('li > div');
- expect(bloques).toHaveLength(2);
- bloques.forEach((b) => expect(b.className).toMatch(/border-l-2/));
+ // the column, and a rule down the side of each block. Colour alone would
+ // fail the spec's accessibility criterion.
+ expect(container.querySelector('.border-l-2')).toBeInTheDocument();
+ expect(container.querySelector('span[aria-hidden="true"]')?.className).toMatch(/bg-dorado/);
});
it('is a list item, so the section can mount six of them as a list', () => {
- render();
+ montar();
expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
+
+ it('keeps the answer in the DOM before the reader reaches it', () => {
+ montar({ activo: false });
+ // The answer waits its turn by going transparent, never by leaving the
+ // document. `hidden` or `display: none` would take it away from screen
+ // readers and from the static export the crawler sees — half the section's
+ // message, gone, to buy an animation.
+ const respuesta = screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/);
+ expect(respuesta).toBeInTheDocument();
+ expect(respuesta.closest('[hidden]')).toBeNull();
+ expect(screen.getByLabelText(/Respuesta de creeer a: Crecer/).className).toMatch(/opacity-0\b/);
+ });
+
+ it('keeps the reveal off any element Framer writes inline styles onto', () => {
+ montar({ activo: false });
+ // Framer resolves `fadeInUp` into the inline style — `opacity: 1` — and an
+ // inline style beats a Tailwind class. When the reveal lived on the same
+ // element as `variants`, the markup read correctly and the browser ignored
+ // it: every answer was legible from the moment the section scrolled in,
+ // and only the gold rule animated. The reveal must sit on a plain child
+ // that Framer never writes to.
+ const bloque = screen.getByLabelText(/Respuesta de creeer a: Crecer/);
+ expect(bloque.getAttribute('style') ?? '').not.toMatch(/opacity/);
+ });
+
+ it('arrives already answered under reduced motion, whatever the scroll did', () => {
+ montar({ activo: false, reducido: true });
+ // These readers get no scroll reveal at all. If the resolved state depended
+ // on the animation having run, the six answers would sit at opacity zero
+ // for them — the exact failure this project has already shipped twice.
+ const bloque = screen.getByLabelText(/Respuesta de creeer a: Crecer/);
+ expect(bloque.className).toMatch(/opacity-100/);
+ expect(bloque.querySelector('span[aria-hidden="true"]')?.className).toMatch(/scale-y-100/);
+ });
+
+ it('lets the reader drive the conversation, not only the scroll', () => {
+ const activar = vi.fn();
+ const { container } = montar({ onActivar: activar });
+ // Hover and keyboard focus reach the same state scroll does, so the pair
+ // is not the exclusive property of whoever scrolls with a mouse wheel.
+ const item = container.querySelector('li')!;
+ item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
+ expect(activar).toHaveBeenCalledWith(1);
+ });
+
+ it('does not observe the viewport at all under reduced motion', () => {
+ const observar = vi.fn();
+ const original = window.IntersectionObserver;
+ window.IntersectionObserver = vi.fn(() => ({
+ observe: observar,
+ unobserve: () => {},
+ disconnect: () => {},
+ })) as unknown as typeof window.IntersectionObserver;
+
+ montar({ reducido: true });
+ expect(observar).not.toHaveBeenCalled();
+
+ window.IntersectionObserver = original;
+ });
});
diff --git a/components/sections/PuntosInflexion.tsx b/components/sections/PuntosInflexion.tsx
index 139f68e..5fe0326 100644
--- a/components/sections/PuntosInflexion.tsx
+++ b/components/sections/PuntosInflexion.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useCallback, useState } from 'react';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
@@ -10,6 +11,15 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
export function PuntosInflexion() {
const reduced = usePrefersReducedMotion();
+
+ // How far down the conversation the reader has got. It only ever moves
+ // forward: an answer that faded back out on the way past would leave the page
+ // emptier the further you read. Scroll drives it, and so do hover and
+ // keyboard focus, so the reader can also run the conversation themselves
+ // instead of only being carried through it.
+ const [alcanzado, setAlcanzado] = useState(0);
+ const activar = useCallback((n: number) => setAlcanzado((previo) => Math.max(previo, n)), []);
+
return (
@@ -32,13 +42,21 @@ export function PuntosInflexion() {
{/* The conversation. One - per exchange, read top to bottom — no
carousel at any breakpoint, because "te bajas" is the whole shape
- the dueña asked for. The stagger reaches ParDialogo's two halves
- through React context, so each pair arrives in two beats: the
- situation, then the answer. */}
+ the dueña asked for. Each answer arrives when the reader reaches its
+ pair, so the section answers as you go down instead of sitting there
+ already answered. The stagger reaches ParDialogo's two halves
+ through React context. */}
+ className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
{puntosInflexion.items.map((item, i) => (
-
+
))}