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>
21 KiB
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.0vía corepack). No usar npm. - Rama nueva por cada cambio. Este trabajo va en
feat/recorrido-dialogo. Nunca trabajar directo enmain. - 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 />dibujacre≡er. - Proyecto en español — copy, docs y commits. Los comentarios de código, en inglés.
- Toda sección animada combina
usePrefersReducedMotion()conmotionProps(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,cohortenigeneración fundadora. - Ningún conector puede depender de
pathLengthsobre un SVG conpreserveAspectRatio="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:
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:
Wordmarkde@/components/primitives/Wordmark(propssize?: 'sm'|'md'|'lg'|'xl',onDark?: boolean,showTagline?: boolean);fadeInUpde@/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:
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:
'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)
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 bloquepuntosInflexion) - Modify:
components/sections/PuntosInflexion.tsx - Test:
components/sections/__tests__/PuntosInflexion.test.tsx
Interfaces:
-
Consumes:
ParDialogo({ n, name, situacion, respuesta })de la Task 1. -
Produces:
puntosInflexioncon forma{ eyebrow: string; title: string; lead: string; items: { name: string; situacion: string; respuesta: string }[] }.descdesaparece: ningún otro archivo lo lee (verificado —PuntosInflexion.tsxera su único consumidor). -
Step 1: Escribir los tests que fallan
Reemplazar el contenido de components/sections/__tests__/PuntosInflexion.test.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:
// 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:
'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)
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
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)
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 (
Doloresviene 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
DoloresyPuntosInflexionjuntas
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.