docs(recorrido): spec y plan del recorrido de diálogo

Sale de la llamada del 31/jul: "te va contestando y te va como llevando
de uno a otro". Los seis puntos de inflexión dejan de ser tarjetas mudas
y pasan a ser una conversación.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-08-03 11:11:36 -06:00
1 parent 2383e63bc5
commit 170482a32b
2 files changed
+577

No files matched your search

@@ -0,0 +1,467 @@
# El recorrido de diálogo — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convertir `PuntosInflexion` de una rejilla de seis tarjetas mudas en una conversación: cada punto de inflexión enuncia la situación del lector y creeer contesta qué hace al respecto.
**Architecture:** Nace un primitivo `ParDialogo` que encapsula un intercambio (situación + respuesta + su separación visual) y no sabe nada de la sección que lo monta. `lib/content.ts` cambia la forma de `puntosInflexion.items` de `{ name, desc }` a `{ name, situacion, respuesta }`. `PuntosInflexion` conserva su cabecera y sustituye el cuerpo por una `<ul>` de seis `ParDialogo`. El carrusel móvil desaparece y con él el primitivo `Carrusel`, que se queda sin consumidores.
**Tech Stack:** Next.js 14 App Router (`output: 'export'`), React 18, TypeScript, Tailwind, Framer Motion 11, Vitest + jsdom + Testing Library.
**Spec:** `docs/superpowers/specs/2026-08-03-recorrido-dialogo-design.md`
## Global Constraints
- **Gestor de paquetes: `pnpm`** (`pnpm@11.7.0` vía corepack). No usar npm.
- **Rama nueva por cada cambio.** Este trabajo va en `feat/recorrido-dialogo`. Nunca trabajar directo en `main`.
- **No hacer commits sin aprobación explícita del usuario.** Los pasos de commit de este plan están escritos, pero **hay que pedir el visto bueno antes de ejecutarlos**.
- **El copy vive en `lib/content.ts`, no en el JSX.** Las comillas angulares que envuelven la situación son puntuación del componente, no copy.
- **El wordmark nunca se escribe a mano en el JSX.** Solo `<Wordmark />` dibuja `cre≡er`.
- **Proyecto en español** — copy, docs y commits. **Los comentarios de código, en inglés.**
- **Toda sección animada** combina `usePrefersReducedMotion()` con `motionProps(reduced)`. Es requisito de accesibilidad del spec.
- **Tokens de color de `tailwind.config.ts`** (`azul-profundo`, `dorado`, `gris-claro`, `gris-medio`), nunca hex sueltos.
- **Sin escasez:** ningún texto puede contener `cupos`, `cohorte` ni `generación fundadora`.
- **Ningún conector puede depender de `pathLength`** sobre un SVG con `preserveAspectRatio="none"`. Chromium recorre el guion en espacio de pantalla y normaliza la unidad desde el espacio de usuario, así que el trazo llega partido. Usar bordes o formas rellenas.
## File Structure
| Archivo | Responsabilidad |
|---------|-----------------|
| `components/primitives/ParDialogo.tsx` | **Nuevo.** Un intercambio: la situación del lector y la respuesta de creeer, con las dos voces distinguibles sin depender del color |
| `components/primitives/__tests__/ParDialogo.test.tsx` | **Nuevo.** Contrato del primitivo |
| `lib/content.ts` | `puntosInflexion.items` pasa a `{ name, situacion, respuesta }`; se añade `lead` |
| `components/sections/PuntosInflexion.tsx` | Cabecera intacta; el cuerpo pasa de rejilla + carrusel a `<ul>` de `ParDialogo` |
| `components/sections/__tests__/PuntosInflexion.test.tsx` | Se actualiza: ya no hay tarjetas ni duplicado por carrusel |
| `components/primitives/Carrusel.tsx` | **Se retira.** Sin consumidores tras el cambio |
| `components/primitives/__tests__/Carrusel.test.tsx` | **Se retira** con él |
---
### Task 0: Abrir la rama
**Files:** ninguno.
- [ ] **Step 1: Crear la rama de trabajo**
El repositorio arrastra la retirada del hilo conductor sin commitear en `feat/hilo-conductor-visual`. Antes de empezar, confirmar con el usuario qué hacer con esos cambios (commitearlos allí o arrastrarlos). Después:
```bash
git checkout -b feat/recorrido-dialogo
git status --short
```
Expected: la rama nueva existe y `git status` muestra solo lo que el usuario haya decidido arrastrar.
---
### Task 1: `ParDialogo`, el intercambio
**Files:**
- Create: `components/primitives/ParDialogo.tsx`
- Test: `components/primitives/__tests__/ParDialogo.test.tsx`
**Interfaces:**
- Consumes: `Wordmark` de `@/components/primitives/Wordmark` (props `size?: 'sm'|'md'|'lg'|'xl'`, `onDark?: boolean`, `showTagline?: boolean`); `fadeInUp` de `@/lib/motion`.
- Produces: `ParDialogo({ n, name, situacion, respuesta }: { n: number; name: string; situacion: string; respuesta: string })`. Renderiza un `<li>`, así que **el que lo monta debe darle una `<ul>` o `<ol>` como padre**. La Task 2 depende de esa firma exacta.
- [ ] **Step 1: Escribir el test que falla**
Crear `components/primitives/__tests__/ParDialogo.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ParDialogo } from '../ParDialogo';
const par = {
n: 1,
name: 'Crecer',
situacion: 'El siguiente salto no tiene mapa.',
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro.',
};
describe('<ParDialogo />', () => {
it('renders both voices of the exchange', () => {
render(<ul><ParDialogo {...par} /></ul>);
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(<ul><ParDialogo {...par} /></ul>);
// Without a speaker label the answer reads as more description of the
// problem. The wordmark is that label — and <Wordmark /> is the only
// thing allowed to draw cre≡er, never hand-typed in the JSX.
expect(screen.getByLabelText('creeer')).toBeInTheDocument();
});
it('labels the situation with its inflection point', () => {
render(<ul><ParDialogo {...par} /></ul>);
expect(screen.getByText(/01 · Crecer/)).toBeInTheDocument();
});
it('tells the two voices apart by more than colour', () => {
const { container } = render(<ul><ParDialogo {...par} /></ul>);
// 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/));
});
it('is a list item, so the section can mount six of them as a list', () => {
render(<ul><ParDialogo {...par} /></ul>);
expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `pnpm test components/primitives/__tests__/ParDialogo.test.tsx`
Expected: FAIL — `Failed to resolve import "../ParDialogo"`.
- [ ] **Step 3: Escribir el componente**
Crear `components/primitives/ParDialogo.tsx`:
```tsx
'use client';
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.
//
// 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 situación → respuesta, not left → right. Flipping sides per pair would
// cost 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.
//
// No motion.li here on purpose: Framer propagates variants through React
// context, so the two motion.div children still inherit the stagger from the
// <ul> the section wraps them in, even across this plain <li>.
export function ParDialogo({
n,
name,
situacion,
respuesta,
}: {
n: number;
name: string;
situacion: string;
respuesta: string;
}) {
return (
<li className="grid gap-5 md:grid-cols-2 md:gap-12">
<motion.div variants={fadeInUp} className="border-l-2 border-azul-profundo/25 pl-5">
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado">
{String(n).padStart(2, '0')} · {name}
</p>
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
«{situacion}»
</p>
</motion.div>
<motion.div variants={fadeInUp} className="border-l-2 border-dorado pl-5 md:mt-10">
<Wordmark size="sm" />
<p className="mt-3 text-gris-medio leading-relaxed">{respuesta}</p>
</motion.div>
</li>
);
}
```
- [ ] **Step 4: Correr el test y verificar que pasa**
Run: `pnpm test components/primitives/__tests__/ParDialogo.test.tsx`
Expected: PASS (5 tests).
- [ ] **Step 5: Typecheck**
Run: `npx tsc --noEmit`
Expected: sin salida.
- [ ] **Step 6: Commit** *(pedir aprobación primero — ver Global Constraints)*
```bash
git add components/primitives/ParDialogo.tsx components/primitives/__tests__/ParDialogo.test.tsx
git commit -m "feat(recorrido): ParDialogo — la situación y la respuesta como un intercambio"
```
---
### Task 2: El copy y la sección
**Files:**
- Modify: `lib/content.ts:35-48` (el bloque `puntosInflexion`)
- Modify: `components/sections/PuntosInflexion.tsx`
- Test: `components/sections/__tests__/PuntosInflexion.test.tsx`
**Interfaces:**
- Consumes: `ParDialogo({ n, name, situacion, respuesta })` de la Task 1.
- Produces: `puntosInflexion` con forma `{ eyebrow: string; title: string; lead: string; items: { name: string; situacion: string; respuesta: string }[] }`. **`desc` desaparece**: ningún otro archivo lo lee (verificado — `PuntosInflexion.tsx` era su único consumidor).
- [ ] **Step 1: Escribir los tests que fallan**
Reemplazar el contenido de `components/sections/__tests__/PuntosInflexion.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { PuntosInflexion } from '../PuntosInflexion';
import { puntosInflexion } from '@/lib/content';
describe('<PuntosInflexion />', () => {
it('renders the section title', () => {
render(<PuntosInflexion />);
expect(screen.getByText(/llegan a un punto de inflexión/i)).toBeInTheDocument();
});
it('renders the six inflection points from the dueña deck', () => {
render(<PuntosInflexion />);
// Each point now renders once, not twice: the mobile carousel is gone and
// the conversation stacks vertically at every breakpoint.
for (const name of ['Crecer', 'Consolidarse', 'Recuperar rentabilidad', 'Simplificar', 'Expandirse', 'Replantear el portafolio']) {
expect(screen.getByText(new RegExp(name))).toBeInTheDocument();
}
expect(screen.getAllByRole('listitem')).toHaveLength(6);
});
it('answers every situation instead of only naming it', () => {
render(<PuntosInflexion />);
// This is the whole point of the section, and the one thing that would
// silently regress back into the old mute grid: six situations, six
// answers, each spoken by creeer.
expect(screen.getAllByLabelText('creeer')).toHaveLength(6);
for (const { respuesta } of puntosInflexion.items) {
expect(screen.getByText(respuesta)).toBeInTheDocument();
}
});
it('states each situation in the second person, as the dueña reads them', () => {
render(<PuntosInflexion />);
// Her own phrasing in the 31/jul call: "estás atrapado en tu compañía",
// "creciste muy rápido pero quieres expandirte". The deck's infinitives
// ("Dar el siguiente salto") would put the reader outside their own story.
for (const { situacion } of puntosInflexion.items) {
expect(situacion).toMatch(/\b(tu|tus|te|ya|creciste|construiste|sabes|puedes|quieres|dejó|volvió|abiertos|mismo)\b/i);
}
});
it('does NOT introduce scarcity', () => {
const { container } = render(<PuntosInflexion />);
expect(container.textContent).not.toMatch(/cupos|cohorte|generación fundadora/i);
});
});
```
- [ ] **Step 2: Correr los tests y verificar que fallan**
Run: `pnpm test components/sections/__tests__/PuntosInflexion.test.tsx`
Expected: FAIL — `getAllByLabelText('creeer')` no encuentra nada y `puntosInflexion.items[0].respuesta` es `undefined`.
- [ ] **Step 3: Reescribir el copy en `lib/content.ts`**
Sustituir el bloque `puntosInflexion` entero (líneas 35–48) por:
```ts
// The six inflection points, from the dueña's deck "Rumbo Estratégico ·
// Propuesta Socio" (slide 2). The deck states each one in the infinitive
// ("Dar el siguiente salto sin saber por dónde"); here it is stated in the
// second person, the way she reads them aloud herself — "estás atrapado en tu
// compañía", "creciste muy rápido pero quieres expandirte" (call of 31/jul,
// 6:01–7:15). `respuesta` is new copy and still needs her sign-off; 01 and 05
// are the two exchanges she improvised on that call, written out.
//
// `situacion` describes the state of the business. It must not drift into how
// the reader feels — that is `dolores`, four sections down, and the two would
// collapse into each other.
export const puntosInflexion = {
eyebrow: 'el contexto',
title: 'Todos los negocios consolidados llegan a un punto de inflexión',
lead: '¿Te reconoces?',
items: [
{
name: 'Crecer',
situacion: 'Ya demostraste que sabes crecer. El siguiente salto no tiene mapa.',
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro. Sales sabiendo por dónde, no intentándolo por todos lados a la vez.',
},
{
name: 'Consolidarse',
situacion: 'Construiste rápido. Escalar así multiplicaría el desorden.',
respuesta: 'Ordenamos lo que ya existe antes de ponerle más peso encima. Escalas sobre algo que aguanta.',
},
{
name: 'Recuperar rentabilidad',
situacion: 'El esfuerzo dejó de traducirse en resultado.',
respuesta: 'Cuestiono lo que todos dan por hecho en tu operación. El margen no siempre está en vender más.',
},
{
name: 'Simplificar',
situacion: 'La operación se volvió más compleja que el negocio.',
respuesta: 'Separo lo que solo tú puedes decidir de lo que la empresa debería resolver sin ti.',
},
{
name: 'Expandirse',
situacion: 'Mercados nuevos, el mismo mapa de decisión.',
respuesta: 'Reviso tu proceso y ubico la oportunidad real antes de mover una ficha. Expandirte deja de ser una apuesta.',
},
{
name: 'Replantear el portafolio',
situacion: 'Varios frentes abiertos y ninguno con su lugar claro.',
respuesta: 'Construimos, juntos, el criterio para decidir qué sigue y qué se deja ir. Por escrito, no en tu cabeza.',
},
],
};
```
- [ ] **Step 4: Reescribir `PuntosInflexion.tsx`**
Sustituir el archivo entero por:
```tsx
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { ParDialogo } from '@/components/primitives/ParDialogo';
import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion';
import { puntosInflexion } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function PuntosInflexion() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-gris-claro relative overflow-hidden">
<FondoPuntos className="-bottom-6 -right-12 w-80 md:w-[32rem] text-azul-profundo/10" distance={32} />
<Container className="relative py-16 md:py-24">
<div className="flex flex-col md:flex-row md:items-center md:justify-between md:gap-12">
<div>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.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">{puntosInflexion.title}</motion.h2>
</div>
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-8 md:mt-0 shrink-0">
<IlustracionInflexion className="w-56 md:w-72 h-auto mx-auto" />
</motion.div>
</div>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-14 text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</motion.p>
{/* The conversation. One <li> 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. */}
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-8 list-none p-0 flex flex-col gap-12 md:gap-16">
{puntosInflexion.items.map((item, i) => (
<ParDialogo key={item.name} n={i + 1} {...item} />
))}
</motion.ul>
</Container>
</section>
);
}
```
- [ ] **Step 5: Correr los tests y verificar que pasan**
Run: `pnpm test components/sections/__tests__/PuntosInflexion.test.tsx`
Expected: PASS (5 tests).
- [ ] **Step 6: Correr la suite entera y el typecheck**
Run: `pnpm test && npx tsc --noEmit`
Expected: todo verde. `Carrusel.test.tsx` sigue pasando por su cuenta — el primitivo aún existe, solo dejó de usarse.
- [ ] **Step 7: Commit** *(pedir aprobación primero)*
```bash
git add lib/content.ts components/sections/PuntosInflexion.tsx components/sections/__tests__/PuntosInflexion.test.tsx
git commit -m "feat(recorrido): los seis puntos dejan de ser tarjetas y pasan a contestar"
```
---
### Task 3: Retirar el carrusel
**Files:**
- Delete: `components/primitives/Carrusel.tsx`
- Delete: `components/primitives/__tests__/Carrusel.test.tsx`
**Interfaces:**
- Consumes: nada.
- Produces: nada. Es una retirada.
- [ ] **Step 1: Verificar que de verdad quedó sin consumidores**
Run: `grep -rn "Carrusel" --include="*.tsx" --include="*.ts" components app lib`
Expected: solo `components/primitives/Carrusel.tsx` y su propio test. Si aparece cualquier otro archivo, **parar**: el primitivo sigue en uso y no se retira.
- [ ] **Step 2: Retirarlo**
```bash
git rm components/primitives/Carrusel.tsx components/primitives/__tests__/Carrusel.test.tsx
```
- [ ] **Step 3: Verificar que nada se rompió**
Run: `pnpm test && npx tsc --noEmit && pnpm lint`
Expected: todo verde, con la suite más corta por los tests del carrusel que se fueron.
- [ ] **Step 4: Commit** *(pedir aprobación primero)*
```bash
git add -A components/primitives
git commit -m "chore: retira Carrusel, sin consumidores desde que el recorrido se apila"
```
---
### Task 4: Verificación en navegador
**Files:** ninguno. Es la comprobación de los criterios de aceptación del spec que jsdom no puede dar, porque **jsdom no hace layout**. Dos de los peores fallos de este proyecto —una banda que alargaba el documento 5.800px y otra que llegaba partida en cuatro— pasaron toda la suite en verde.
- [ ] **Step 1: Build de producción**
Run: `pnpm build`
Expected: export estático sin errores.
- [ ] **Step 2: Levantar el servidor**
Run: `pnpm dev`
Expected: sirve en `http://localhost:3000`. **No correr `pnpm build` a la vez que `pnpm dev`**: comparten `.next` y el servidor acaba sirviendo una página corrupta (se midió un documento de 60.569px por esto).
- [ ] **Step 3: Revisar a 1440×900 y a 390×844**
Comprobar contra los criterios del spec:
- los seis pares se leen de arriba abajo como conversación;
- cada respuesta se distingue como voz de creeer sin depender solo del color;
- en 390px no hay desbordes horizontales ni carrusel;
- la sección no deja huecos verticales desproporcionados frente a sus vecinas (`Dolores` viene justo después).
- [ ] **Step 4: Revisar con `prefers-reduced-motion: reduce`**
Cargar la página con la preferencia activada y confirmar que **ningún par queda invisible** y que todas las reglas laterales se dibujan. Es el fallo clásico de este proyecto: las ramas de movimiento reducido que se quedan en `opacity: 0`.
- [ ] **Step 5: Mirar `Dolores` y `PuntosInflexion` juntas**
El spec lo pide explícitamente. Las situaciones describen el estado del negocio; los dolores, cómo se siente. Si al leerlas seguidas suenan a lo mismo, informarlo — **no reescribir `dolores` por cuenta propia**, es texto verbatim de la dueña.
- [ ] **Step 6: Informar al usuario**
Resumir qué se verificó y qué queda pendiente del visto bueno de la dueña: las seis respuestas son redacción nueva y las situaciones reescriben líneas marcadas como verbatim de su deck.