Files
Creeer-Web/docs/superpowers/plans/2026-08-03-recorrido-dialogo.md
T
erikghandClaude Opus 5 170482a32b 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>
2026-08-03 11:11:36 -06:00

21 KiB
Raw Blame History

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:

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:

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 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:

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 (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.