Files
Creeer-Web/docs/superpowers/plans/2026-08-19-creeer-rediseno-documento.md
T
erikghandClaude Opus 5.5 89e54a6e84 feat: rediseño «El documento» en curso, contacto real y enlaces externos
Primitivas y contenido del rediseño (escala tipográfica, Revelar,
MarcaSeccion, Seccion, Antetitulo, SimboloMarca, Resplandor, ClaridadHero,
sección Proceso de ocho etapas, lib/content.ts reestructurado en seis
secciones con alias para las viejas). Mecanismo y FormaTrabajar se retiran.

Encima, lo de esta sesión:
- recorrido: creeer vuelve a responder cada situación en vez de repetirla
  (el alias apuntaba respuesta a situacion); respuestas en plural. Créditos
  de las seis fotos de México.
- contacto: WhatsApp, email (con mailto:) y LinkedIn reales; iconos de las
  redes en el cierre y el footer (IconoRed, trazos de Simple Icons, CC0).
- enlaces: lo externo abre en otra pestaña con noopener y aviso para
  lectores de pantalla (lib/enlaces.ts); mailto y anclas se quedan.
- Proceso: las ocho tarjetas no aparecían nunca en móvil (amount 0.3 sobre
  una lista de ~3000px); ahora cada una se revela sola. El ≡ de la 08 ya no
  tapa un entregable, título equilibrado, etiqueta a 12px, sin elevar al
  hover lo que no es clicable.
- Disciplinas: la frase «No son la promesa — son los medios» gana peso y
  se agrupa con título e ilustración; sin cortes huérfanos en móvil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:27:20 -06:00

89 KiB
Raw Blame History

Rediseño «El documento» · Plan de implementación

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: Rehacer la landing de creeer como un documento estratégico de seis secciones, conservando paleta, wordmark y Open Sans, y eliminando el aparato gráfico y de movimiento que hoy contradice el posicionamiento.

Architecture: Las secciones vuelven a ser server components sobre una primitiva Seccion que monta una retícula de columna de margen (número + marca) y columna de contenido. El único gesto de movimiento es Revelar, montado al revés del actual: el estado oculto lo pone el CSS y solo bajo html.js, que un script inline añade antes del primer pintado, de modo que sin JavaScript la página se lee entera. MarcaSeccion deriva su geometría de lib/simbolo.ts e interpola estáticamente entre nueve líneas dispersas y las tres barras del ≡, un paso por sección.

Tech Stack: Next.js 14 App Router (output: 'export'), React 18, Tailwind 3.4, TypeScript, Vitest + jsdom + Testing Library, pnpm.

Global Constraints

  • Gestor de paquetes pnpm, nunca npm.
  • Copy y docs en español.
  • Comentarios de código en inglés, tests incluidos y lib/content.ts incluido — es lo que hace hoy todo components/, todo __tests__/ y el propio content.ts ("Source of truth for all copy. Edit here, not in JSX."). La única excepción es tailwind.config.ts, que está en español desde antes de este plan. ⚠️ Varios bloques de código de este plan traen comentarios en español; manda esta regla, no el bloque: tradúcelos al inglés al transcribirlos, conservando lo que dicen.
  • lib/content.ts lleva notas de procedencia que no se pueden perder. Documentan de qué diapositiva del deck sale cada texto, que las ocho etapas son una adaptación de la lámina de un cliente real con todo lo identificativo retirado, y que siguen pendientes de la firma de la dueña. No están en git —eran trabajo sin commitear—, así que sobrescribir el archivo las destruye. Al reestructurar, muévelas al objeto que pase a poseer ese copy; nunca las tires.
  • No commitear sin aprobación explícita del usuario. Los pasos de commit de este plan quedan pendientes de esa aprobación.
  • Se conservan intactos: la paleta de tailwind.config.ts, el wordmark cre≡er (components/primitives/Wordmark.tsx) y Open Sans.
  • Pesos tipográficos permitidos: 400, 600, 700, 800. font-black prohibido — Open Sans no tiene 900 y layout.tsx carga hasta 800.
  • Contraste mínimo: 4,5:1 texto normal, 3:1 texto grande y gráficos con significado.
  • text-dorado solo sobre azul profundo. Sobre fondo claro, text-dorado-texto (#8A6520).
  • Ningún componente puede renderizar contenido en opacity: 0 en el HTML del export.
  • lib/content.ts es el único source of truth del copy. Los TODO dueña (WhatsApp, email, LinkedIn, dominio, retrato) siguen siendo placeholders; no inventar valores.
  • Prohibida la escasez en la sección 01: cupos|cohorte|generación fundadora.
  • El árbol queda verde (pnpm test, npx tsc --noEmit, pnpm lint) al final de cada tarea.

Rama: crear rediseno/documento desde fix/contraste-y-hero-visible antes de la Tarea 1.


Task 1: Escala tipográfica

Añade los cinco roles nuevos. No borra ancla ni seccion: las secciones viejas siguen vivas hasta la Tarea 13 y el árbol tiene que quedar verde en cada tarea.

Files:

  • Modify: tailwind.config.ts
  • Test: lib/__tests__/escala.test.ts (crear)

Interfaces:

  • Produces: las clases text-display, text-titulo, text-sumario, text-cuerpo, text-rotulo, y max-w-medida (68ch). Todas las tareas siguientes las usan.

  • Step 1: Write the failing test

Crear lib/__tests__/escala.test.ts:

import { describe, it, expect } from 'vitest';
import config from '../../tailwind.config';

// La escala es un contrato, no una preferencia: el problema que este rediseño
// arregla es que había cinco elementos empatados como «lo más grande de la
// página», y por eso ninguno lo era. Cinco roles, uno solo por encima de 4rem.
const escala = () => (config.theme?.extend?.fontSize ?? {}) as Record<string, [string, object]>;

describe('escala tipográfica', () => {
  it('define los cinco roles del documento', () => {
    expect(Object.keys(escala())).toEqual(
      expect.arrayContaining(['display', 'titulo', 'sumario', 'cuerpo', 'rotulo']),
    );
  });

  it('display es el único que llega a 7rem', () => {
    const conSieteRem = Object.entries(escala()).filter(([, v]) => v[0].includes('7rem'));
    expect(conSieteRem.map(([k]) => k)).toEqual(['display']);
  });

  it('el cuerpo se compone holgado y el display apretado', () => {
    const lh = (rol: string) => (escala()[rol][1] as { lineHeight: string }).lineHeight;
    expect(Number(lh('display'))).toBeLessThan(1);
    expect(Number(lh('cuerpo'))).toBeGreaterThan(1.6);
  });

  it('reserva una medida de 68 caracteres para el texto corrido', () => {
    const anchos = (config.theme?.extend?.maxWidth ?? {}) as Record<string, string>;
    expect(anchos.medida).toBe('68ch');
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test lib/__tests__/escala.test.ts Expected: FAIL — expected [ 'ancla', 'seccion' ] to include 'display'.

  • Step 3: Write minimal implementation

En tailwind.config.ts, dentro de theme.extend, añadir a maxWidth y sustituir el bloque fontSize por:

      maxWidth: {
        content: '1280px',
        // Medida de texto corrido. La tensión entre reglas anchas (1280px) y
        // texto angosto (68ch) es lo que hace que la composición se lea
        // editorial en vez de como un bloque de landing.
        medida: '68ch',
      },
      fontSize: {
        // Cinco roles. `display` se gasta UNA sola vez en toda la página.
        display: ['clamp(3.5rem, 9vw, 7rem)', { lineHeight: '0.94', letterSpacing: '-0.03em' }],
        titulo: ['clamp(2.25rem, 4.5vw, 3.5rem)', { lineHeight: '1', letterSpacing: '-0.02em' }],
        sumario: ['clamp(1.375rem, 2vw, 1.75rem)', { lineHeight: '1.35', letterSpacing: '-0.01em' }],
        cuerpo: ['1.0625rem', { lineHeight: '1.7' }],
        rotulo: ['0.75rem', { lineHeight: '1.2', letterSpacing: '0.3em' }],
        // Se van con la limpieza de la Tarea 13, cuando ya no queden secciones
        // viejas que los usen.
        ancla: ['clamp(2.5rem, 6.5vw, 4.5rem)', { lineHeight: '0.98', letterSpacing: '-0.025em' }],
        seccion: ['clamp(2rem, 4vw, 3.25rem)', { lineHeight: '1.02', letterSpacing: '-0.02em' }],
      },

maxWidth.content pasa de 1152px a 1280px.

  • Step 4: Run tests to verify they pass

Run: pnpm test lib/__tests__/escala.test.ts && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add tailwind.config.ts lib/__tests__/escala.test.ts
git commit -m "feat(escala): cinco roles tipográficos y medida de 68ch"

Task 2: Revelar y el mecanismo de movimiento

El único gesto de la página, montado de forma que sin JavaScript no oculte nada.

Files:

  • Create: components/primitives/Revelar.tsx
  • Create: components/primitives/__tests__/Revelar.test.tsx
  • Modify: app/globals.css
  • Modify: app/layout.tsx

Interfaces:

  • Produces: <Revelar indice?: number className?: string>{children}</Revelar>. Envuelve un bloque y lo revela al entrar en viewport. indice escalona el retardo 60ms por unidad; por defecto 0. Todas las secciones lo consumen.

  • Step 1: Write the failing test

Crear components/primitives/__tests__/Revelar.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Revelar } from '../Revelar';

// El stub de vitest.setup.ts es inerte: observa y no dispara nunca. Aquí hace
// falta uno que guarde el callback para poder simular la entrada en viewport.
let disparar: (entradas: Partial<IntersectionObserverEntry>[]) => void;

beforeEach(() => {
  vi.stubGlobal(
    'IntersectionObserver',
    class {
      constructor(cb: IntersectionObserverCallback) {
        disparar = (entradas) => cb(entradas as IntersectionObserverEntry[], this as never);
      }
      observe() {}
      unobserve() {}
      disconnect() {}
    },
  );
});

describe('<Revelar />', () => {
  it('no oculta nada por sí mismo', () => {
    // El estado oculto lo pone el CSS y solo bajo `html.js`. Si el componente
    // escribiera opacity 0 en el estilo inline volveríamos al fallo que este
    // rediseño arregla: 112 elementos invisibles en el export y una página en
    // blanco cuando el JS no llega.
    const { container } = render(<Revelar>Tu negocio no necesita más esfuerzo.</Revelar>);
    const el = container.firstElementChild as HTMLElement;
    expect(el.style.opacity).toBe('');
    expect(screen.getByText('Tu negocio no necesita más esfuerzo.')).toBeInTheDocument();
  });

  it('marca el bloque para que el CSS lo pueda gobernar', () => {
    const { container } = render(<Revelar>x</Revelar>);
    expect(container.firstElementChild).toHaveAttribute('data-revelar');
  });

  it('escalona el retardo con el índice', () => {
    const { container } = render(<Revelar indice={3}>x</Revelar>);
    expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--i')).toBe('3');
  });

  it('se marca visible al entrar en viewport', () => {
    const { container } = render(<Revelar>x</Revelar>);
    const el = container.firstElementChild as HTMLElement;
    expect(el).not.toHaveAttribute('data-visible');
    disparar([{ isIntersecting: true, target: el }]);
    expect(el).toHaveAttribute('data-visible');
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/primitives/__tests__/Revelar.test.tsx Expected: FAIL — Failed to resolve import "../Revelar".

  • Step 3: Write minimal implementation

Crear components/primitives/Revelar.tsx:

'use client';

import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';

// The page's only motion, and it is built the opposite way round from the
// system it replaces.
//
// The hidden state lives in CSS and applies only under `html.js`, a class an
// inline script in <head> adds before first paint. Nothing here writes
// `opacity: 0` into the markup, so the static export ships a page that reads in
// full — the previous architecture shipped 112 elements at opacity zero and
// went blank whenever the JavaScript failed to arrive.
//
// One module-level observer for the whole page rather than one per block.

let observador: IntersectionObserver | null = null;

function observadorCompartido() {
  if (observador) return observador;
  observador = new IntersectionObserver(
    (entradas) => {
      for (const entrada of entradas) {
        if (!entrada.isIntersecting) continue;
        entrada.target.setAttribute('data-visible', '');
        observador?.unobserve(entrada.target);
      }
    },
    // Fires a touch before the block is fully in, so the gesture has finished
    // by the time the reader's eye lands on it.
    { rootMargin: '0px 0px -10% 0px' },
  );
  return observador;
}

export function Revelar({
  children,
  indice = 0,
  className = '',
}: {
  children: ReactNode;
  indice?: number;
  className?: string;
}) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const obs = observadorCompartido();
    obs.observe(el);
    return () => obs.unobserve(el);
  }, []);

  return (
    <div ref={ref} data-revelar className={className} style={{ '--i': indice } as CSSProperties}>
      {children}
    </div>
  );
}

En app/globals.css, añadir al final:

/* El único gesto de la página. El estado oculto depende de `.js`, que un
   script inline del <head> pone antes del primer pintado: sin JavaScript la
   clase nunca llega, nada se oculta y la página se lee completa. Quien pide
   menos movimiento tampoco entra aquí — la media query lo deja fuera del todo
   en vez de neutralizarlo después. */
@media (prefers-reduced-motion: no-preference) {
  .js [data-revelar] {
    opacity: 0;
    transform: translateY(12px);
  }
  .js [data-revelar][data-visible] {
    animation: revelar 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
    animation-delay: calc(var(--i, 0) * 60ms);
  }
}

@keyframes revelar {
  to {
    opacity: 1;
    transform: none;
  }
}

Y sustituir el bloque de foco que ya existe en app/globals.css:

@layer base {
  /* El anillo tiene que contrastar contra el fondo sobre el que aparece, no
     contra la marca. El dorado global daba 2,76:1 sobre blanco, por debajo del
     3:1 que pide WCAG 2.2 para el indicador de foco. Azul profundo sobre
     blanco da 16,7:1; y sobre las dos secciones navy, donde el navy
     desaparecería, manda el dorado, que ahí da 6,07:1. */
  a:focus-visible,
  button:focus-visible {
    outline: 3px solid #0a1d3d;
    outline-offset: 3px;
    border-radius: 2px;
  }

  .bg-azul-profundo a:focus-visible,
  .bg-azul-profundo button:focus-visible {
    outline-color: #c4942e;
  }
}

En app/layout.tsx, dentro de <html> y antes de <body>:

      <head>
        {/* Bloqueante y antes del primer pintado a propósito: es lo que decide
            si el CSS puede ocultar algo. Si esto no corre, no hay estado
            oculto que revelar. */}
        <script dangerouslySetInnerHTML={{ __html: "document.documentElement.classList.add('js')" }} />
      </head>
  • Step 4: Run tests to verify they pass

Run: pnpm test components/primitives/__tests__/Revelar.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/primitives/Revelar.tsx components/primitives/__tests__/Revelar.test.tsx app/globals.css app/layout.tsx
git commit -m "feat(movimiento): un solo gesto, y que sin JS no oculte nada"

Task 3: MarcaSeccion

La progresión del ≡ a lo largo del documento. Sin animación, sin estado, sin JS: un SVG calculado en render.

Files:

  • Create: components/primitives/MarcaSeccion.tsx
  • Create: components/primitives/__tests__/MarcaSeccion.test.tsx

Interfaces:

  • Consumes: VIEWBOX_SIMBOLO, LINEAS_SIMBOLO de lib/simbolo.ts (ya existen y tienen test).

  • Produces: <MarcaSeccion paso={0..5} className?: string /> y la constante PASOS = 6. La consume Seccion (Tarea 4).

  • Step 1: Write the failing test

Crear components/primitives/__tests__/MarcaSeccion.test.tsx:

import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MarcaSeccion, PASOS } from '../MarcaSeccion';
import { LINEAS_SIMBOLO } from '@/lib/simbolo';

const lineas = (paso: number) => {
  const { container } = render(<MarcaSeccion paso={paso} />);
  return Array.from(container.querySelectorAll('line'));
};

const visibles = (paso: number) => lineas(paso).filter((l) => l.getAttribute('opacity') !== '0');

const coords = (paso: number) =>
  lineas(paso).map((l) =>
    (['x1', 'y1', 'x2', 'y2'] as const).map((a) => Number(l.getAttribute(a))),
  );

describe('<MarcaSeccion />', () => {
  it('abre el documento con la maraña entera', () => {
    expect(visibles(0)).toHaveLength(9);
  });

  it('lo cierra resolviéndose exactamente en el ≡ del manual', () => {
    // No en un parecido: en las mismas tres barras que dibuja el wordmark.
    // Las cuatro coordenadas, no tres: `y2` se interpola aparte de `y1`, y una
    // regresión que se dejara el `mezcla` en `y2` daría una marca torcida con
    // todos los tests en verde.
    const finales = visibles(PASOS - 1);
    expect(finales).toHaveLength(3);
    finales.forEach((l, i) => {
      (['x1', 'y1', 'x2', 'y2'] as const).forEach((attr) => {
        expect(Number(l.getAttribute(attr)), attr).toBeCloseTo(LINEAS_SIMBOLO[i][attr], 5);
      });
      expect(Number(l.getAttribute('stroke-width'))).toBeCloseTo(LINEAS_SIMBOLO[i].grosor, 5);
    });
  });

  it('converge paso a paso: cada uno acerca las líneas a su posición final', () => {
    // Contar líneas visibles no prueba nada: las seis que se apagan lo hacen de
    // forma continua como `1 - t` y solo llegan al "0" literal en el último
    // paso, así que la serie es [9,9,9,9,9,3] — ya ordenada por construcción, y
    // la aserción no podría fallar. Lo que sí se mueve en cada paso es cuánto
    // le falta a cada línea para llegar a donde acaba.
    const destino = coords(PASOS - 1);
    const desvio = (paso: number) =>
      coords(paso).reduce(
        (total, linea, i) => total + Math.hypot(...linea.map((v, j) => v - destino[i][j])),
        0,
      );
    const serie = Array.from({ length: PASOS }, (_, p) => desvio(p));
    serie.slice(1).forEach((d, i) => expect(d, `paso ${i + 1}`).toBeLessThan(serie[i]));
    expect(serie[PASOS - 1]).toBe(0);
  });

  it('es decorativa para lectores de pantalla', () => {
    const { container } = render(<MarcaSeccion paso={2} />);
    expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/primitives/__tests__/MarcaSeccion.test.tsx Expected: FAIL — Failed to resolve import "../MarcaSeccion".

  • Step 3: Write minimal implementation

Crear components/primitives/MarcaSeccion.tsx:

import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo';

// The manual's philosophy — Complejidad → Enfoque → Claridad — as the
// document's own furniture instead of as an illustration parked in a corner.
// Each section carries one step of it in the margin column, so the reader walks
// the tangle resolving into the ≡ by scrolling.
//
// Static: the interpolation happens at render, there is no animation and no
// client JavaScript. The geometry it resolves into comes from lib/simbolo.ts,
// the same source the wordmark traces, so the mark it produces is provably the
// brand's and not a lookalike.

export const PASOS = 6;

// Hand-placed rather than random, so the mark is identical on every render and
// between server and client. Three lines per bar of the ≡.
const DISPERSAS = [
  { x1: 4, y1: 10, x2: 44, y2: 4, barra: 0 },
  { x1: 18, y1: 3, x2: 58, y2: 22, barra: 0 },
  { x1: 6, y1: 24, x2: 50, y2: 12, barra: 0 },
  { x1: 3, y1: 30, x2: 56, y2: 26, barra: 1 },
  { x1: 14, y1: 40, x2: 52, y2: 20, barra: 1 },
  { x1: 8, y1: 34, x2: 60, y2: 38, barra: 1 },
  { x1: 5, y1: 46, x2: 47, y2: 60, barra: 2 },
  { x1: 22, y1: 62, x2: 58, y2: 44, barra: 2 },
  { x1: 10, y1: 56, x2: 54, y2: 52, barra: 2 },
];

// One line per group survives to become a bar; the other six fade out as the
// page converges — many/disordered becomes few/aligned, per the manual.
const SOBREVIVE = [0, 3, 6];

const GROSOR_DISPERSO = 2;

export function MarcaSeccion({ paso, className = '' }: { paso: number; className?: string }) {
  const t = Math.min(1, Math.max(0, paso / (PASOS - 1)));
  const mezcla = (a: number, b: number) => a + (b - a) * t;

  return (
    <svg viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
      {DISPERSAS.map((d, i) => {
        const destino = LINEAS_SIMBOLO[d.barra];
        const sobrevive = SOBREVIVE.includes(i);
        return (
          <line
            key={i}
            x1={mezcla(d.x1, destino.x1)}
            y1={mezcla(d.y1, destino.y1)}
            x2={mezcla(d.x2, destino.x2)}
            y2={mezcla(d.y2, destino.y2)}
            stroke="currentColor"
            strokeWidth={sobrevive ? mezcla(GROSOR_DISPERSO, destino.grosor) : GROSOR_DISPERSO}
            strokeLinecap="round"
            opacity={sobrevive ? 1 : 1 - t}
          />
        );
      })}
    </svg>
  );
}

Nota para quien implemente: SOBREVIVE = [0, 3, 6] toma la primera línea de cada grupo de tres, de modo que las tres supervivientes van a barras distintas (0, 1 y 2). El test de resolución falla si se rompe esa correspondencia.

  • Step 4: Run tests to verify they pass

Run: pnpm test components/primitives/__tests__/MarcaSeccion.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/primitives/MarcaSeccion.tsx components/primitives/__tests__/MarcaSeccion.test.tsx
git commit -m "feat(marca): el ≡ como mobiliario del documento, un paso por sección"

Task 4: Seccion

La retícula de columna de margen, el ritmo vertical y el fondo. Es la primitiva que da forma a las seis secciones.

Files:

  • Create: components/primitives/Seccion.tsx
  • Create: components/primitives/__tests__/Seccion.test.tsx
  • Modify: components/primitives/Antetitulo.tsx
  • Modify: components/primitives/__tests__/Antetitulo.test.tsx

Antetitulo entra aquí porque es la otra pieza de mobiliario del documento y hoy está fuera del sistema por dos motivos: escribe a mano text-xs tracking-[0.3em], que es exactamente lo que declara el token rotulo de la Tarea 1 — el token quedaría sin usar —, y usa font-black, un peso que Open Sans no tiene y que la guarda de la Tarea 13 prohíbe. Si no se arregla aquí, la Tarea 13 falla.

Interfaces:

  • Consumes: Container, MarcaSeccion.

  • Produces:

    <Seccion
      numero: string        // '01'…'06'
      paso: number          // 0…5, el que pasa a MarcaSeccion
      ritmo?: 'compacta' | 'normal' | 'momento'   // por defecto 'compacta'
      fondo?: 'claro' | 'oscuro'                  // por defecto 'claro'
      id?: string
    >{children}</Seccion>
    

    Las seis secciones (Tareas 6–11) la consumen.

  • Step 1: Write the failing test

Crear components/primitives/__tests__/Seccion.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Seccion } from '../Seccion';

describe('<Seccion />', () => {
  it('pinta su contenido y su número', () => {
    render(<Seccion numero="02" paso={1}>Dónde estás</Seccion>);
    expect(screen.getByText('Dónde estás')).toBeInTheDocument();
    expect(screen.getByText('02')).toBeInTheDocument();
  });

  it('lleva su marca en el paso que le toca', () => {
    const { container } = render(<Seccion numero="06" paso={5}>x</Seccion>);
    // paso 5 resuelve el ≡: quedan tres líneas visibles de las nueve.
    const visibles = Array.from(container.querySelectorAll('line')).filter(
      (l) => l.getAttribute('opacity') !== '0',
    );
    expect(visibles).toHaveLength(3);
  });

  it('sobre fondo oscuro usa el dorado del manual, sobre claro el legible', () => {
    const { container: oscuro } = render(<Seccion numero="03" paso={2} fondo="oscuro">x</Seccion>);
    expect(oscuro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado');
    expect(oscuro.querySelector('svg')?.getAttribute('class')).not.toContain('text-dorado-texto');

    const { container: claro } = render(<Seccion numero="04" paso={3}>x</Seccion>);
    expect(claro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado-texto');
  });

  it('el número no lo anuncian los lectores de pantalla', () => {
    // Es mobiliario del documento, no contenido: el encabezado de la sección ya
    // dice de qué va.
    const { container } = render(<Seccion numero="05" paso={4}>x</Seccion>);
    expect(container.querySelector('[aria-hidden="true"]')?.textContent).toContain('05');
  });

  it('solo se hace destino de ancla cuando tiene id', () => {
    const { container } = render(<Seccion numero="06" paso={5} id="contacto">x</Seccion>);
    const seccion = container.querySelector('section');
    expect(seccion).toHaveAttribute('id', 'contacto');
    expect(seccion?.className).toContain('scroll-mt-24');
  });
});

Y añadir a components/primitives/__tests__/Antetitulo.test.tsx:

  it('lee su tamaño del token rotulo en vez de recomponerlo a mano', () => {
    // `rotulo` ya declara 0.75rem con 0.3em de tracking. Repetirlo en el
    // className dejaba el token sin usar y las dos definiciones libres de
    // divergir.
    render(<Antetitulo>el contexto</Antetitulo>);
    const classes = screen.getByText('el contexto').className.split(/\s+/);
    expect(classes).toContain('text-rotulo');
    expect(classes).not.toContain('text-xs');
    expect(classes).not.toContain('tracking-[0.3em]');
  });

  it('usa un peso que la tipografía tiene', () => {
    // Open Sans llega a 800 y layout.tsx carga hasta 800: `font-black` (900)
    // caía a 800 de todos modos. El rótulo va en 600, que es el peso que la
    // escala reserva para los rótulos.
    render(<Antetitulo>el contexto</Antetitulo>);
    const classes = screen.getByText('el contexto').className.split(/\s+/);
    expect(classes).toContain('font-semibold');
    expect(classes).not.toContain('font-black');
  });
  • Step 2: Run test to verify it fails

Run: pnpm test components/primitives/__tests__/Seccion.test.tsx Expected: FAIL — Failed to resolve import "../Seccion".

  • Step 3: Write minimal implementation

Crear components/primitives/Seccion.tsx:

import type { ReactNode } from 'react';
import { Container } from '@/components/primitives/Container';
import { MarcaSeccion } from '@/components/primitives/MarcaSeccion';

// The document's grid: a narrow margin column carrying the section number and
// its mark, and a wide column carrying everything else. That asymmetry is what
// makes the page read as a document rather than as a stack of landing blocks.
//
// Three vertical rhythms rather than one. Every section used to share
// `py-16 md:py-24`, which made ten sections go past like a metronome; the page
// needs somewhere to breathe and somewhere to hurry.

type Ritmo = 'compacta' | 'normal' | 'momento';
type Fondo = 'claro' | 'oscuro';

const RITMO: Record<Ritmo, string> = {
  compacta: 'py-20 md:py-28',
  normal: 'py-28 md:py-40',
  momento: 'py-36 md:py-56',
};

const FONDO: Record<Fondo, string> = {
  claro: 'bg-white text-azul-profundo',
  oscuro: 'bg-azul-profundo bg-hondo-navy text-white',
};

// Gold on navy is 6,07:1; on white it is 2,76:1 and the mark is a graphic that
// carries meaning, so it needs 3:1.
const MARCA: Record<Fondo, string> = {
  claro: 'text-dorado-texto',
  oscuro: 'text-dorado',
};

export function Seccion({
  numero,
  paso,
  ritmo = 'compacta',
  fondo = 'claro',
  id,
  children,
}: {
  numero: string;
  paso: number;
  ritmo?: Ritmo;
  fondo?: Fondo;
  id?: string;
  children: ReactNode;
}) {
  return (
    <section id={id} className={`${FONDO[fondo]}${id ? ' scroll-mt-24' : ''}`}>
      <Container className={RITMO[ritmo]}>
        <div className="grid gap-y-8 md:grid-cols-[7rem_minmax(0,1fr)] md:gap-x-10">
          <div
            aria-hidden="true"
            className="flex items-center gap-4 md:flex-col md:items-start md:gap-5"
          >
            <span className="font-sans font-semibold text-xs tracking-[0.2em] tabular-nums opacity-50">
              {numero}
            </span>
            <MarcaSeccion paso={paso} className={`w-10 shrink-0 ${MARCA[fondo]}`} />
          </div>
          <div className="min-w-0">{children}</div>
        </div>
      </Container>
    </section>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test components/primitives/__tests__/Seccion.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/primitives/Seccion.tsx components/primitives/__tests__/Seccion.test.tsx
git commit -m "feat(seccion): retícula de margen y tres ritmos verticales"

Task 5: Reestructurar lib/content.ts

Los objetos nuevos pasan a poseer las cadenas verbatim de la dueña; los objetos viejos quedan como alias derivados para que las secciones viejas sigan compilando hasta la Tarea 13. Cero duplicación de texto.

Files:

  • Modify: lib/content.ts
  • Modify: lib/__tests__/content.test.ts

Interfaces:

  • Produces: apertura, dondeEstas, queCambia, elTrabajo, paraQuienQuien, empezar. Las consumen las Tareas 6–11.

  • Mantiene: hero, puntosInflexion, dolores, disciplinas, proceso, paraQuien, estratega, comoEmpezar, cierre, header, footer, brandPillars, creditosFotos como alias hasta la Tarea 13.

  • Step 1: Write the failing test

Añadir a lib/__tests__/content.test.ts:

import * as contenido from '../content';
import {
  apertura, dondeEstas, queCambia, elTrabajo, paraQuienQuien, empezar,
  puntosInflexion, dolores, fraseAncla,
} from '../content';

describe('la estructura de seis secciones', () => {
  it('numera el documento del 01 al 06 sin saltos', () => {
    expect(
      [apertura, dondeEstas, queCambia, elTrabajo, paraQuienQuien, empezar].map((s) => s.numero),
    ).toEqual(['01', '02', '03', '04', '05', '06']);
  });

  it('conserva verbatim las palabras de la dueña', () => {
    // Su doc es explícito: «ya están escritos en su voz — usarlos, no
    // reescribirlos». Estos tests fallan si alguien los reescribe.
    expect(dondeEstas.dolores).toContain('Trabajo más y gano casi lo mismo que hace dos años.');
    expect(dondeEstas.remate.accent).toBe('Se resuelve con una estrategia clara.');
    expect(queCambia.ancla.accent).toBe('dejan de crecer por falta de claridad.');
    expect(dondeEstas.puntos.map((p) => p.name)).toEqual([
      'Crecer', 'Consolidarse', 'Recuperar rentabilidad',
      'Simplificar', 'Expandirse', 'Replantear el portafolio',
    ]);
  });

  it('los alias viejos siguen leyendo de la misma fuente, no de una copia', () => {
    // Mientras conviven las dos estructuras, una divergencia silenciosa entre
    // ellas sería copy distinto en la misma página.
    expect(puntosInflexion.items[0].situacion).toBe(dondeEstas.puntos[0].situacion);
    expect(dolores.quotes).toEqual(dondeEstas.dolores);
    expect(fraseAncla.quote).toBe(queCambia.ancla);
  });

  it('mantiene en pie todo lo que las secciones viejas importan', () => {
    // Las diez secciones viejas siguen montadas hasta la Tarea 13, y una sola
    // ausencia aquí rompe el build entero. Ésta es la lista real de nombres que
    // el código importa hoy de `@/lib/content`.
    const modulo = contenido as Record<string, unknown>;
    [
      'brandPillars', 'cierre', 'comoEmpezar', 'creditosFotos', 'disciplinas',
      'dolores', 'estratega', 'footer', 'fraseAncla', 'header', 'hero',
      'paraQuien', 'proceso', 'puntosInflexion',
    ].forEach((nombre) => expect(modulo[nombre], nombre).toBeDefined());
  });

  it('no promete escasez en la apertura', () => {
    expect(JSON.stringify(apertura)).not.toMatch(/cupos|cohorte|generación fundadora/i);
  });

  it('deja los contactos como placeholders', () => {
    expect(empezar.contacto.whatsapp.href).toContain('0000000000');
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test lib/__tests__/content.test.ts Expected: FAIL — does not provide an export named 'apertura'.

  • Step 3: Write minimal implementation

Reestructurar lib/content.ts. Los seis objetos nuevos van antes del bloque de alias:

// ── 01 · Apertura ──────────────────────────────────────────────────────────
export const apertura = {
  numero: '01',
  eyebrow: 'tu estratega de crecimiento',
  headline: {
    line1: 'Tu negocio no necesita',
    acento: 'más esfuerzo.',
    line3: 'Necesita',
    cierre: 'más claridad.',
  },
  quien: 'Claridad estratégica para empresarios con tracción que llegaron a su punto de inflexión.',
  cta: 'Obtener claridad ahora',
  ctaSecundario: 'Ver el trabajo completo',
};

// ── 02 · Dónde estás ───────────────────────────────────────────────────────
// Los seis puntos y los cuatro dolores son de las diapositivas 2 y 3 del deck
// de la dueña. Verbatim. `respuesta` desaparece: existía para llenar seis
// pantallas fijadas y el recorrido ya no existe.
const PUNTOS = [
  { name: 'Crecer', situacion: 'Ya demostraste que sabes crecer. El siguiente salto no tiene mapa.' },
  { name: 'Consolidarse', situacion: 'Construiste rápido. Escalar así multiplicaría el desorden.' },
  { name: 'Recuperar rentabilidad', situacion: 'Tu esfuerzo dejó de traducirse en resultado.' },
  { name: 'Simplificar', situacion: 'Tu operación se volvió más compleja que tu negocio.' },
  { name: 'Expandirse', situacion: 'Vas a mercados nuevos con el mismo mapa de decisión.' },
  { name: 'Replantear el portafolio', situacion: 'Tienes varios frentes abiertos y ninguno con su lugar claro.' },
];

export const dondeEstas = {
  numero: '02',
  eyebrow: 'dónde estás',
  title: 'Todos los negocios consolidados llegan a un punto de inflexión',
  sumario: 'Seis situaciones. Reconoce la tuya.',
  puntos: PUNTOS,
  etiquetaDolores: 'Y por dentro suena así',
  dolores: [
    'Trabajo más y gano casi lo mismo que hace dos años.',
    'Tengo varios proyectos y no sé en cuál enfocar mi tiempo y mi dinero.',
    'Mi empresa no puede crecer si yo no estoy en cada decisión.',
    'Lo que me trajo hasta aquí ya no me sirve para el siguiente nivel.',
  ],
  remate: {
    before: 'Esto no se resuelve con más esfuerzo.',
    accent: 'Se resuelve con una estrategia clara.',
  },
};

// ── 03 · Qué cambia ────────────────────────────────────────────────────────
export const queCambia = {
  numero: '03',
  eyebrow: 'qué cambia',
  ancla: {
    before: 'Las empresas no dejan de crecer por falta de talento;',
    accent: 'dejan de crecer por falta de claridad.',
  },
  insight: 'No vendemos marketing. Vendemos claridad estratégica.',
  // Las tres disciplinas, degradadas de tres tarjetas con icono a una línea de
  // texto: el propio deck dice que son los medios, no la promesa.
  medios: 'Comunicación, digital y experiencias son las herramientas que usamos. No son lo que vendemos.',
};

// ── 04 · El trabajo ────────────────────────────────────────────────────────
export const elTrabajo = {
  numero: '04',
  eyebrow: 'el trabajo',
  title: 'De tu operación de hoy a tu plan a un año',
  sumario: 'Ocho etapas. Cada una deja algo por escrito.',
  fases: ['Inmersión', 'Desafiar supuestos', 'Estrategia ejecutable'],
  etiquetaEntregables: 'Entregables',
  etapas: [
    { n: '01', name: 'Análisis de compañía y FODA', entregables: ['FODA', 'Diagnóstico ejecutivo', 'Prioridades', 'Quick wins'] },
    { n: '02', name: 'Análisis interno', entregables: ['Mapa de capacidades', 'Restricciones del negocio', 'Oportunidades de crecimiento'] },
    { n: '03', name: 'Consumer Knowledge', entregables: ['Segmentación', 'Buyer persona', 'Customer journey', 'Necesidades no cubiertas'] },
    { n: '04', name: 'Estrategia de marca', entregables: ['Posicionamiento', 'Arquitectura de marcas', 'Manual estratégico'] },
    { n: '05', name: 'Comunicación y branding', entregables: ['Plataforma de comunicación', 'Campaña anual', 'Estrategia de medios', 'Plan de contenido'] },
    { n: '06', name: 'Marketing y ventas', entregables: ['Dashboard CEO', 'Dashboard comercial', 'Comité de crecimiento'] },
    { n: '07', name: 'Innovación comercial', entregables: ['Roadmap de innovación', 'Go to market', 'Prioridades de innovación'] },
    { n: '08', name: 'One Year Plan', entregables: ['Roadmap a 12 meses', 'Iniciativas prioritarias', 'Gobernanza del crecimiento'], remate: true },
  ],
};

// ── 05 · Para quién · Quién ────────────────────────────────────────────────
export const paraQuienQuien = {
  numero: '05',
  eyebrow: 'para quién',
  title: '¿Llegaste a tu punto de inflexión?',
  si: {
    lead: 'Esto es para ti si…',
    criterios: [
      'tienes tracción y ya demostraste que sabes crecer',
      'trabajar más ya no rinde como antes',
      'necesitas claridad para decidir el siguiente movimiento',
      'quieres profesionalizar sin perder tu esencia',
    ],
  },
  no: {
    lead: 'Esto no es para ti si…',
    criterios: [
      'el negocio está en fase inicial o sin tracción',
      'buscas una solución rápida de venta',
      'buscas un curso, no una transformación',
      'la empresa está en crisis de supervivencia, no en transición',
    ],
  },
  quien: {
    eyebrow: 'quién está detrás',
    title: 'Quiénes somos',
    cifra: '30+',
    cifraPie: 'años construyendo marcas',
    bio: 'Más de 30 años construyendo marcas. Hoy acompañamos a empresarios que ya lograron tracción y necesitan claridad para su siguiente movimiento.',
    quote: 'El tiempo no se compra, se invierte.',
    // TODO dueña: retrato real. El hueco está listo para recibirlo.
    retratoAlt: 'Retrato de la estratega — pendiente',
  },
};

// ── 06 · Empezar ───────────────────────────────────────────────────────────
export const empezar = {
  numero: '06',
  eyebrow: 'empezar',
  title: 'Tu siguiente movimiento empieza con una conversación.',
  pasos: [
    { n: '01', name: 'Escríbenos', desc: 'Cuéntanos dónde está tu negocio hoy.' },
    { n: '02', name: 'Sesión de diagnóstico', desc: 'Una conversación para ubicar tu punto de inflexión.' },
    { n: '03', name: 'Plan en mano', desc: 'Sales con claridad y tu siguiente movimiento definido.' },
  ],
  cta: 'Agenda tu sesión de diagnóstico',
  whatsappText: 'Hola, quiero agendar una sesión de diagnóstico',
  quote: 'Devuélvele claridad al rumbo de tu empresa.',
  contacto: {
    whatsapp: { label: 'WhatsApp', href: 'https://wa.me/520000000000' },   // TODO dueña: número real
    email: { label: 'Email', href: 'mailto:hola@creeer.com' },             // TODO dueña: email real
    linkedin: { label: 'LinkedIn', href: 'https://linkedin.com/in/...' },  // TODO dueña: URL real
  },
  legal: '© 2026 creeer',
};

Después, sustituir los objetos viejos por alias derivados, con este encabezado:

// ── Alias de compatibilidad ────────────────────────────────────────────────
// Los consumen las diez secciones viejas mientras conviven con las seis
// nuevas. Derivan de los objetos de arriba, nunca duplican sus cadenas, y se
// van enteros en la limpieza final. No añadir usos nuevos.

export const hero = {
  eyebrow: `— ${apertura.eyebrow} —`,
  headline: {
    line1: apertura.headline.line1,
    effort: apertura.headline.acento,
    line3: apertura.headline.line3,
    clarity: apertura.headline.cierre,
  },
  cta: apertura.cta,
  ctaSecundario: 'Ver cómo trabajamos',
};

export const puntosInflexion = {
  eyebrow: 'el contexto',
  title: dondeEstas.title,
  lead: '¿Te reconoces?',
  items: PUNTOS.map((p) => ({ ...p, respuesta: p.situacion })),
};

export const dolores = {
  eyebrow: 'lo que realmente sientes',
  title: 'Los dolores detrás del punto de inflexión',
  quotes: dondeEstas.dolores,
  remate: dondeEstas.remate,
};

export const disciplinas = {
  eyebrow: 'las herramientas',
  title: 'Las disciplinas que usamos',
  lead: 'No son la promesa — son los medios.',
  items: [
    { name: 'Comunicación', desc: 'Mensaje que alinea a tu equipo y a tu mercado.' },
    { name: 'Digital', desc: 'Presencia y procesos que escalan sin ti.' },
    { name: 'Experiencias', desc: 'Cada toque con tu marca construye confianza.' },
  ],
};

export const proceso = {
  eyebrow: 'el trabajo completo',
  title: elTrabajo.title,
  lead: elTrabajo.sumario,
  fases: elTrabajo.fases,
  etiquetaEntregables: elTrabajo.etiquetaEntregables,
  etapas: elTrabajo.etapas.map(({ n, name, entregables, remate }) =>
    remate ? { n, name, entregables, destacada: true } : { n, name, entregables },
  ),
};

export const paraQuien = {
  title: paraQuienQuien.title,
  lead: paraQuienQuien.si.lead,
  criteria: paraQuienQuien.si.criterios,
  leadNo: paraQuienQuien.no.lead,
  criteriaNo: paraQuienQuien.no.criterios,
};

export const estratega = {
  eyebrow: { before: 'quiénes estamos detrás de', brand: 'creeer' },
  title: paraQuienQuien.quien.title,
  bio: paraQuienQuien.quien.bio,
  quote: paraQuienQuien.quien.quote,
};

export const comoEmpezar = {
  eyebrow: 'cómo empezar',
  title: empezar.title,
  steps: empezar.pasos,
  cta: empezar.cta,
  whatsappText: empezar.whatsappText,
};

export const cierre = {
  quote: empezar.quote,
  cta: apertura.cta,
  contact: empezar.contacto,
};

export const fraseAncla = { quote: queCambia.ancla };

brandPillars, header, footer, wordmark y creditosFotos se quedan exactamente como están.

Comprobación obligatoria antes de dar la tarea por hecha. El árbol tiene que seguir compilando con las diez secciones viejas vivas, así que el bloque de alias debe cubrir todos los nombres que el código importa hoy de @/lib/content. Son catorce: brandPillars, cierre, comoEmpezar, creditosFotos, disciplinas, dolores, estratega, footer, fraseAncla, header, hero, paraQuien, proceso, puntosInflexion. Verifícalo con:

grep -rhoP "import \{[^}]+\} from '@/lib/content'" components app \
  | sed "s/import {//; s/} from.*//" | tr ',' '\n' | tr -d ' ' | sort -u

Cada nombre de esa lista tiene que existir en lib/content.ts al terminar. npx tsc --noEmit es la red final, pero esta comprobación te dice cuál falta en vez de sepultarte en errores en cascada.

Cuidado: puntosInflexion.items[].respuesta alimenta ParDialogo y EscenaDialogo, que exigen la propiedad. El alias la rellena con la propia situacion — es texto muerto que solo vive hasta la Tarea 13, y evita arrastrar seis párrafos que el diseño nuevo descarta.

  • Step 4: Run tests to verify they pass

Run: pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde, incluidos los tests de las secciones viejas.

  • Step 5: Commit
git add lib/content.ts lib/__tests__/content.test.ts
git commit -m "feat(content): estructura de seis secciones, con alias hacia las diez viejas"

Task 6: Sección 01 · Apertura

Files:

  • Create: components/sections/Apertura.tsx
  • Create: components/sections/__tests__/Apertura.test.tsx

Interfaces:

  • Consumes: Seccion, Antetitulo, CTA, Wordmark, Revelar, apertura.

  • Produces: <Apertura />. La monta app/page.tsx (Tarea 12).

  • Step 1: Write the failing test

Crear components/sections/__tests__/Apertura.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Apertura } from '../Apertura';

describe('<Apertura />', () => {
  it('pinta el titular entero', () => {
    const { container } = render(<Apertura />);
    expect(container.textContent).toMatch(/Tu negocio no necesita/);
    expect(container.textContent).toMatch(/más esfuerzo/);
    expect(container.textContent).toMatch(/más claridad/);
  });

  it('es el único h1 de la página', () => {
    const { container } = render(<Apertura />);
    expect(container.querySelectorAll('h1')).toHaveLength(1);
  });

  it('ofrece la ruta de conversión y la de lectura', () => {
    render(<Apertura />);
    expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toHaveAttribute('href', '#contacto');
    expect(screen.getByRole('link', { name: /ver el trabajo completo/i })).toHaveAttribute('href', '#trabajo');
  });

  it('NO promete escasez', () => {
    // Decisión explícita de la dueña. Viene del Hero viejo y viaja con la
    // sección.
    const { container } = render(<Apertura />);
    expect(container.textContent).not.toMatch(/cupos|cohorte|generación fundadora/i);
  });

  it('entra pintada: nada nace oculto ni difuminado', () => {
    // Es el LCP, y la página se abre desde un enlace de WhatsApp con datos
    // móviles.
    const { container } = render(<Apertura />);
    const elementos = Array.from(container.querySelectorAll<HTMLElement>('*'));
    expect(elementos.filter((el) => el.style.opacity === '0')).toHaveLength(0);
    expect(elementos.filter((el) => el.style.filter.includes('blur'))).toHaveLength(0);
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/sections/__tests__/Apertura.test.tsx Expected: FAIL — Failed to resolve import "../Apertura".

  • Step 3: Write minimal implementation

Crear components/sections/Apertura.tsx:

import { Seccion } from '@/components/primitives/Seccion';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { CTA } from '@/components/primitives/CTA';
import { Wordmark } from '@/components/primitives/Wordmark';
import { apertura } from '@/lib/content';

// The opening page of the document. No illustration, no dot field, no minimum
// viewport height — it breathes through padding, which behaves the same on a
// phone whose address bar keeps collapsing.
//
// Nothing here animates and nothing here is a client component: this is the LCP
// and it must exist in the HTML.

export function Apertura() {
  return (
    <Seccion numero={apertura.numero} paso={0} ritmo="momento">
      <Wordmark size="xl" showTagline />
      <Antetitulo className="mt-10">{apertura.eyebrow}</Antetitulo>
      {/* The only `display` in the whole page. */}
      <h1 className="mt-6 font-sans font-extrabold text-display text-balance">
        {apertura.headline.line1}{' '}
        <span className="text-dorado-texto">{apertura.headline.acento}</span>{' '}
        {apertura.headline.line3}{' '}
        <span className="border-b-[3px] border-dorado pb-1">{apertura.headline.cierre}</span>
      </h1>
      <p className="mt-10 max-w-medida text-sumario text-azul-profundo/70 text-pretty">
        {apertura.quien}
      </p>
      <div className="mt-12 flex flex-col items-stretch gap-4 sm:flex-row sm:items-center">
        <CTA href="#contacto" label={apertura.cta} variant="light" className="text-center" />
        <CTA href="#trabajo" label={apertura.ctaSecundario} variant="ghost" className="text-center" />
      </div>
    </Seccion>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test components/sections/__tests__/Apertura.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/sections/Apertura.tsx components/sections/__tests__/Apertura.test.tsx
git commit -m "feat(01): la apertura del documento, sin animación y sin cliente"

Task 7: Sección 02 · Dónde estás

Files:

  • Create: components/sections/DondeEstas.tsx
  • Create: components/sections/__tests__/DondeEstas.test.tsx

Interfaces:

  • Consumes: Seccion, Antetitulo, Revelar, dondeEstas.

  • Produces: <DondeEstas />.

  • Step 1: Write the failing test

Crear components/sections/__tests__/DondeEstas.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { DondeEstas } from '../DondeEstas';

describe('<DondeEstas />', () => {
  it('pone los seis puntos de inflexión en la tabla', () => {
    render(<DondeEstas />);
    ['Crecer', 'Consolidarse', 'Recuperar rentabilidad', 'Simplificar', 'Expandirse', 'Replantear el portafolio']
      .forEach((punto) => expect(screen.getByText(punto)).toBeInTheDocument());
  });

  it('recoge los cuatro dolores en primera persona', () => {
    const { container } = render(<DondeEstas />);
    expect(container.textContent).toMatch(/Trabajo más y gano casi lo mismo que hace dos años/);
    expect(container.textContent).toMatch(/Lo que me trajo hasta aquí ya no me sirve/);
  });

  it('cierra con el remate de la dueña', () => {
    const { container } = render(<DondeEstas />);
    expect(container.textContent).toMatch(/Esto no se resuelve con más esfuerzo/);
    expect(container.textContent).toMatch(/Se resuelve con una estrategia clara/);
  });

  it('los seis puntos son una lista de verdad, no seis divs', () => {
    // Un lector de pantalla tiene que oír «lista de 6 elementos» y poder
    // saltarla; el recorrido viejo los tenía dentro de un escenario fijado.
    const { container } = render(<DondeEstas />);
    expect(container.querySelectorAll('li')).toHaveLength(6 + 4);
  });

  it('no oculta contenido tras estado de scroll', () => {
    const { container } = render(<DondeEstas />);
    const ocultos = Array.from(container.querySelectorAll<HTMLElement>('*'))
      .filter((el) => el.style.opacity === '0');
    expect(ocultos).toHaveLength(0);
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/sections/__tests__/DondeEstas.test.tsx Expected: FAIL — Failed to resolve import "../DondeEstas".

  • Step 3: Write minimal implementation

Crear components/sections/DondeEstas.tsx:

import { Seccion } from '@/components/primitives/Seccion';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { Revelar } from '@/components/primitives/Revelar';
import { dondeEstas } from '@/lib/content';

// Six rows of a table, then the four things the reader says to themselves.
//
// This section replaces 600vh of pinned scroll that hid five sixths of its own
// copy behind a scroll position and cost 1,24 MB of photographs. Same six
// points, read in fifteen seconds.

export function DondeEstas() {
  return (
    <Seccion numero={dondeEstas.numero} paso={1} ritmo="normal">
      <Antetitulo>{dondeEstas.eyebrow}</Antetitulo>
      <h2 className="mt-5 max-w-medida font-sans font-bold text-titulo text-balance">
        {dondeEstas.title}
      </h2>
      <p className="mt-5 text-sumario text-azul-profundo/70">{dondeEstas.sumario}</p>

      <ul className="mt-14 list-none border-t border-gris-claro p-0">
        {dondeEstas.puntos.map((punto, i) => (
          <li key={punto.name} className="border-b border-gris-claro">
            <Revelar indice={i} className="grid gap-1 py-6 sm:grid-cols-[14rem_minmax(0,1fr)] sm:gap-8">
              <h3 className="font-sans font-bold text-cuerpo">{punto.name}</h3>
              <p className="max-w-medida text-cuerpo text-azul-profundo/70 text-pretty">
                {punto.situacion}
              </p>
            </Revelar>
          </li>
        ))}
      </ul>

      {/* The four quotes are the client's own words, from slide 3 of the deck.
          Set into the document as an aside rather than given a screen of their
          own: they are evidence for the table above, not a second argument. */}
      <Revelar className="mt-16 border-l-2 border-dorado pl-6 md:pl-8">
        <Antetitulo>{dondeEstas.etiquetaDolores}</Antetitulo>
        <ul className="mt-6 list-none space-y-5 p-0">
          {dondeEstas.dolores.map((dolor) => (
            <li key={dolor} className="max-w-medida font-sans italic text-sumario text-pretty">
              «{dolor}»
            </li>
          ))}
        </ul>
      </Revelar>

      <Revelar className="mt-16">
        <p className="max-w-medida font-sans font-bold text-titulo text-balance">
          {dondeEstas.remate.before}{' '}
          <span className="text-dorado-texto">{dondeEstas.remate.accent}</span>
        </p>
      </Revelar>
    </Seccion>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test components/sections/__tests__/DondeEstas.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/sections/DondeEstas.tsx components/sections/__tests__/DondeEstas.test.tsx
git commit -m "feat(02): los seis puntos como tabla y los cuatro dolores como aparte"

Task 8: Sección 03 · Qué cambia

Files:

  • Create: components/sections/QueCambia.tsx
  • Create: components/sections/__tests__/QueCambia.test.tsx

Interfaces:

  • Consumes: Seccion, Antetitulo, Revelar, queCambia.

  • Produces: <QueCambia />. Primera de las dos secciones navy.

  • Step 1: Write the failing test

Crear components/sections/__tests__/QueCambia.test.tsx:

import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { QueCambia } from '../QueCambia';

describe('<QueCambia />', () => {
  it('pinta la frase ancla entera', () => {
    const { container } = render(<QueCambia />);
    expect(container.textContent).toMatch(/no dejan de crecer por falta de talento/);
    expect(container.textContent).toMatch(/dejan de crecer por falta de claridad/);
  });

  it('dice qué se vende y qué no', () => {
    const { container } = render(<QueCambia />);
    expect(container.textContent).toMatch(/No vendemos marketing/);
    expect(container.textContent).toMatch(/Vendemos claridad estratégica/);
  });

  it('va sobre azul profundo', () => {
    // Es uno de los dos únicos momentos oscuros de la página; el peso de la
    // sección depende de que sean dos y no seis.
    const { container } = render(<QueCambia />);
    expect(container.querySelector('section')?.className).toContain('bg-azul-profundo');
  });

  it('usa el dorado del manual, que sobre navy sí se lee', () => {
    const { container } = render(<QueCambia />);
    const acento = container.querySelector('.text-dorado');
    expect(acento).not.toBeNull();
    expect(container.querySelector('.text-dorado-texto')).toBeNull();
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/sections/__tests__/QueCambia.test.tsx Expected: FAIL — Failed to resolve import "../QueCambia".

  • Step 3: Write minimal implementation

Crear components/sections/QueCambia.tsx:

import { Seccion } from '@/components/primitives/Seccion';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { Revelar } from '@/components/primitives/Revelar';
import { queCambia } from '@/lib/content';

// The thesis, and the first of exactly two navy sections. Two dark moments in a
// white page carry enormous weight; six carry none, which is what the previous
// page had.
//
// The anchor phrase is set at `titulo`, not `display`: the page spends its one
// display size on the opening headline. What makes this the loudest moment is
// the ground turning over, not the type getting bigger.

export function QueCambia() {
  return (
    <Seccion numero={queCambia.numero} paso={2} ritmo="momento" fondo="oscuro">
      <Antetitulo sobre="oscuro">{queCambia.eyebrow}</Antetitulo>
      <Revelar>
        <p className="mt-8 max-w-medida font-sans font-bold text-titulo text-balance">
          {queCambia.ancla.before}{' '}
          <span className="text-dorado">{queCambia.ancla.accent}</span>
        </p>
      </Revelar>
      <Revelar indice={1} className="mt-16 border-t border-white/15 pt-10">
        <p className="max-w-medida text-sumario text-balance">{queCambia.insight}</p>
        <p className="mt-5 max-w-medida text-cuerpo text-white/60 text-pretty">{queCambia.medios}</p>
      </Revelar>
    </Seccion>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test components/sections/__tests__/QueCambia.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/sections/QueCambia.tsx components/sections/__tests__/QueCambia.test.tsx
git commit -m "feat(03): la tesis, en el primero de los dos navy"

Task 9: Sección 04 · El trabajo

Files:

  • Create: components/sections/ElTrabajo.tsx
  • Create: components/sections/__tests__/ElTrabajo.test.tsx

Interfaces:

  • Consumes: Seccion, Antetitulo, Revelar, elTrabajo.

  • Produces: <ElTrabajo />, con id="trabajo" — es el destino del CTA secundario de la Apertura.

  • Step 1: Write the failing test

Crear components/sections/__tests__/ElTrabajo.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ElTrabajo } from '../ElTrabajo';
import { elTrabajo } from '@/lib/content';

describe('<ElTrabajo />', () => {
  it('es el destino del CTA secundario de la apertura', () => {
    const { container } = render(<ElTrabajo />);
    expect(container.querySelector('section')).toHaveAttribute('id', 'trabajo');
  });

  it('lista las ocho etapas en orden', () => {
    render(<ElTrabajo />);
    elTrabajo.etapas.forEach((etapa) => {
      expect(screen.getByText(etapa.name)).toBeInTheDocument();
    });
  });

  it('la secuencia la lleva el marcado, no solo el número dibujado', () => {
    // Ojo al orden: la sección monta DOS <ol>, y la tira de las tres fases va
    // antes que el índice de las ocho etapas. El que lleva la secuencia para la
    // tecnología asistiva —lo que permite que el número dibujado sea
    // decorativo— es el segundo. Se fijan los dos para que reordenarlos no
    // pase inadvertido.
    const { container } = render(<ElTrabajo />);
    const listas = Array.from(container.querySelectorAll('ol'));
    expect(listas).toHaveLength(2);
    expect(listas[0].querySelectorAll(':scope > li')).toHaveLength(3);
    expect(listas[1].querySelectorAll(':scope > li')).toHaveLength(8);
  });

  it('muestra todos los entregables de todas las etapas', () => {
    const { container } = render(<ElTrabajo />);
    elTrabajo.etapas.flatMap((e) => e.entregables).forEach((entregable) => {
      expect(container.textContent).toContain(entregable);
    });
  });

  it('nombra las tres fases aprobadas', () => {
    const { container } = render(<ElTrabajo />);
    expect(container.textContent).toMatch(/Inmersión/);
    expect(container.textContent).toMatch(/Desafiar supuestos/);
    expect(container.textContent).toMatch(/Estrategia ejecutable/);
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/sections/__tests__/ElTrabajo.test.tsx Expected: FAIL — Failed to resolve import "../ElTrabajo".

  • Step 3: Write minimal implementation

Crear components/sections/ElTrabajo.tsx:

import { Seccion } from '@/components/primitives/Seccion';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { Revelar } from '@/components/primitives/Revelar';
import { elTrabajo } from '@/lib/content';

// The eight stages as the document's outline, not as eight photo cards.
//
// The photographs went: eight pictures by eight different photographers,
// unified by force with a duotone, were exactly what made this section read as
// an agency catalogue — which the owner's own positioning rules out. So did the
// 27 bespoke deliverable icons, illegible at 18px anyway.
//
// What carries the section now is the deliverables. They are the argument: the
// claridad this sells is an object you can hold.

export function ElTrabajo() {
  return (
    <Seccion numero={elTrabajo.numero} paso={3} id="trabajo">
      <Antetitulo>{elTrabajo.eyebrow}</Antetitulo>
      <h2 className="mt-5 max-w-medida font-sans font-bold text-titulo text-balance">
        {elTrabajo.title}
      </h2>
      <p className="mt-5 text-sumario text-azul-profundo/70">{elTrabajo.sumario}</p>

      <ol className="mt-8 flex list-none flex-wrap items-center gap-x-3 gap-y-2 p-0">
        {elTrabajo.fases.map((fase, i) => (
          <li key={fase} className="flex items-center gap-3">
            {i > 0 && <span aria-hidden="true" className="text-azul-profundo/40">→</span>}
            <span className="text-xs font-semibold uppercase tracking-[0.18em] text-azul-profundo/70">
              {fase}
            </span>
          </li>
        ))}
      </ol>

      <ol className="mt-14 list-none border-t border-gris-claro p-0">
        {elTrabajo.etapas.map((etapa, i) => (
          <li key={etapa.n} className="border-b border-gris-claro">
            <Revelar indice={i} className="grid gap-4 py-8 md:grid-cols-[3rem_minmax(0,1fr)] md:gap-8">
              <span
                aria-hidden="true"
                className="font-sans font-semibold text-cuerpo tabular-nums text-dorado-texto"
              >
                {etapa.n}
              </span>
              <div className="min-w-0">
                <h3
                  className={`font-sans font-bold text-balance ${
                    'remate' in etapa && etapa.remate ? 'text-titulo' : 'text-sumario'
                  }`}
                >
                  {etapa.name}
                </h3>
                <p className="sr-only">{elTrabajo.etiquetaEntregables}</p>
                {/* azul-claro finally earns its keep: navy on #C8EEF5 is 13,5:1. */}
                <ul className="mt-4 flex list-none flex-wrap gap-2 p-0">
                  {etapa.entregables.map((entregable) => (
                    <li
                      key={entregable}
                      className="rounded-sm bg-azul-claro px-3 py-1.5 text-sm text-azul-profundo"
                    >
                      {entregable}
                    </li>
                  ))}
                </ul>
              </div>
            </Revelar>
          </li>
        ))}
      </ol>
    </Seccion>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test components/sections/__tests__/ElTrabajo.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/sections/ElTrabajo.tsx components/sections/__tests__/ElTrabajo.test.tsx
git commit -m "feat(04): las ocho etapas como índice, sin fotos ni 27 iconos"

Task 10: Sección 05 · Para quién · Quién

Files:

  • Create: components/sections/ParaQuienQuien.tsx
  • Create: components/sections/__tests__/ParaQuienQuien.test.tsx

Interfaces:

  • Consumes: Seccion, Antetitulo, Revelar, paraQuienQuien.

  • Produces: <ParaQuienQuien />.

  • Step 1: Write the failing test

Crear components/sections/__tests__/ParaQuienQuien.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ParaQuienQuien } from '../ParaQuienQuien';
import { paraQuienQuien } from '@/lib/content';

describe('<ParaQuienQuien />', () => {
  it('presenta la bifurcación completa: cuatro criterios de cada lado', () => {
    const { container } = render(<ParaQuienQuien />);
    paraQuienQuien.si.criterios.forEach((c) => expect(container.textContent).toContain(c));
    paraQuienQuien.no.criterios.forEach((c) => expect(container.textContent).toContain(c));
  });

  it('descalifica un perfil sin acusar al lector', () => {
    render(<ParaQuienQuien />);
    expect(screen.getByText('Esto no es para ti si…')).toBeInTheDocument();
  });

  it('presenta a quién está detrás', () => {
    const { container } = render(<ParaQuienQuien />);
    expect(container.textContent).toMatch(/Quiénes somos/);
    expect(container.textContent).toMatch(/El tiempo no se compra, se invierte/);
  });

  it('la cifra es estática: ya no cuenta hacia arriba', () => {
    render(<ParaQuienQuien />);
    expect(screen.getByText('25+')).toBeInTheDocument();
  });

  it('deja el hueco del retrato anunciado como pendiente', () => {
    // TODO dueña: el retrato real entra aquí sin tocar el layout.
    render(<ParaQuienQuien />);
    expect(screen.getByRole('img', { name: /pendiente/i })).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/sections/__tests__/ParaQuienQuien.test.tsx Expected: FAIL — Failed to resolve import "../ParaQuienQuien".

  • Step 3: Write minimal implementation

Crear components/sections/ParaQuienQuien.tsx:

import { Seccion } from '@/components/primitives/Seccion';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { Revelar } from '@/components/primitives/Revelar';
import { Wordmark } from '@/components/primitives/Wordmark';
import { paraQuienQuien } from '@/lib/content';

// The fork and the person, in one section.
//
// The fork used to be a full-bleed split with a navy half. It is two columns of
// a document now, separated by a rule: navy is spent on 03 and 06, and a third
// dark slab here would cost those two their weight.

const { si, no, quien } = paraQuienQuien;

export function ParaQuienQuien() {
  return (
    <Seccion numero={paraQuienQuien.numero} paso={4}>
      <Antetitulo>{paraQuienQuien.eyebrow}</Antetitulo>
      <h2 className="mt-5 max-w-medida font-sans font-bold text-titulo text-balance">
        {paraQuienQuien.title}
      </h2>

      <div className="mt-12 grid gap-10 md:grid-cols-2 md:gap-14">
        {[si, no].map((lado, i) => (
          <Revelar
            key={lado.lead}
            indice={i}
            className={i === 1 ? 'md:border-l md:border-gris-claro md:pl-14' : ''}
          >
            <h3 className="font-sans font-bold text-sumario">{lado.lead}</h3>
            <ul className="mt-6 list-none space-y-3 p-0">
              {lado.criterios.map((criterio) => (
                <li
                  key={criterio}
                  className={`text-cuerpo text-pretty ${
                    i === 0 ? 'text-azul-profundo' : 'text-azul-profundo/55'
                  }`}
                >
                  {criterio}
                </li>
              ))}
            </ul>
          </Revelar>
        ))}
      </div>

      <Revelar className="mt-20 border-t border-gris-claro pt-14">
        <Antetitulo>{quien.eyebrow}</Antetitulo>
        <div className="mt-8 grid gap-10 md:grid-cols-[16rem_minmax(0,1fr)] md:gap-12">
          {/* TODO dueña: retrato real. El marco lo recibe tal cual — basta
              sustituir este bloque por un <Image fill /> con el mismo aspecto. */}
          <div
            role="img"
            aria-label={quien.retratoAlt}
            className="grid aspect-[4/5] w-full place-items-center bg-azul-profundo bg-hondo-navy"
          >
            <Wordmark size="md" onDark />
          </div>
          <div className="min-w-0">
            <h3 className="font-sans font-bold text-titulo text-balance">{quien.title}</h3>
            <div className="mt-6 flex items-baseline gap-4">
              <span className="font-sans font-extrabold text-titulo tabular-nums text-dorado-texto">
                {quien.cifra}
              </span>
              <span className="max-w-[12rem] text-xs font-semibold uppercase leading-snug tracking-[0.14em] text-azul-profundo/70">
                {quien.cifraPie}
              </span>
            </div>
            <p className="mt-6 max-w-medida text-cuerpo text-azul-profundo/70 text-pretty">
              {quien.bio}
            </p>
            <blockquote className="mt-8 border-l-2 border-dorado pl-5 font-sans italic text-sumario">
              {quien.quote}
            </blockquote>
          </div>
        </div>
      </Revelar>
    </Seccion>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test components/sections/__tests__/ParaQuienQuien.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/sections/ParaQuienQuien.tsx components/sections/__tests__/ParaQuienQuien.test.tsx
git commit -m "feat(05): la bifurcación y quién está detrás, en una sola sección"

Task 11: Sección 06 · Empezar

Absorbe el footer. Es la última sección de la página.

Files:

  • Create: components/sections/Empezar.tsx
  • Create: components/sections/__tests__/Empezar.test.tsx

Interfaces:

  • Consumes: Seccion, Antetitulo, CTA, Wordmark, Revelar, empezar.

  • Produces: <Empezar />, con id="contacto" — destino del CTA principal de la Apertura y del header.

  • Step 1: Write the failing test

Crear components/sections/__tests__/Empezar.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Empezar } from '../Empezar';

describe('<Empezar />', () => {
  it('es el destino de todos los CTA de la página', () => {
    const { container } = render(<Empezar />);
    expect(container.querySelector('section')).toHaveAttribute('id', 'contacto');
  });

  it('explica los tres pasos', () => {
    render(<Empezar />);
    expect(screen.getByText('Escríbenos')).toBeInTheDocument();
    expect(screen.getByText('Sesión de diagnóstico')).toBeInTheDocument();
    expect(screen.getByText('Plan en mano')).toBeInTheDocument();
  });

  it('el CTA lleva el mensaje ya escrito a WhatsApp', () => {
    render(<Empezar />);
    const cta = screen.getByRole('link', { name: /agenda tu sesión de diagnóstico/i });
    expect(cta.getAttribute('href')).toContain('wa.me');
    expect(decodeURIComponent(cta.getAttribute('href') ?? '')).toContain('quiero agendar una sesión');
  });

  it('ofrece las tres vías de contacto', () => {
    render(<Empezar />);
    const nav = screen.getByRole('navigation', { name: /contacto/i });
    expect(nav.querySelectorAll('a')).toHaveLength(3);
  });

  it('cierra con la promesa de la marca', () => {
    const { container } = render(<Empezar />);
    expect(container.textContent).toMatch(/Devuélvele claridad al rumbo de tu empresa/);
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test components/sections/__tests__/Empezar.test.tsx Expected: FAIL — Failed to resolve import "../Empezar".

  • Step 3: Write minimal implementation

Crear components/sections/Empezar.tsx:

import { Seccion } from '@/components/primitives/Seccion';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { CTA } from '@/components/primitives/CTA';
import { Wordmark } from '@/components/primitives/Wordmark';
import { Revelar } from '@/components/primitives/Revelar';
import { empezar } from '@/lib/content';

// The last page of the document: the three steps, the way in, and the sign-off.
// It absorbs the footer — a separate strip under a closing section was one more
// seam for no gain, and the second of the page's two navy grounds should run
// all the way to the bottom of the window.

const { contacto } = empezar;
const enlaces = [contacto.whatsapp, contacto.email, contacto.linkedin];
const hrefWhatsapp = `${contacto.whatsapp.href}?text=${encodeURIComponent(empezar.whatsappText)}`;

export function Empezar() {
  return (
    <Seccion numero={empezar.numero} paso={5} ritmo="momento" fondo="oscuro" id="contacto">
      <Antetitulo sobre="oscuro">{empezar.eyebrow}</Antetitulo>
      <h2 className="mt-5 max-w-medida font-sans font-bold text-titulo text-balance">
        {empezar.title}
      </h2>

      <ol className="mt-14 list-none border-t border-white/15 p-0">
        {empezar.pasos.map((paso, i) => (
          <li key={paso.n} className="border-b border-white/15">
            <Revelar indice={i} className="grid gap-2 py-6 sm:grid-cols-[3rem_12rem_minmax(0,1fr)] sm:gap-8">
              <span aria-hidden="true" className="font-sans font-semibold text-cuerpo tabular-nums text-dorado">
                {paso.n}
              </span>
              <h3 className="font-sans font-bold text-cuerpo">{paso.name}</h3>
              <p className="max-w-medida text-cuerpo text-white/60 text-pretty">{paso.desc}</p>
            </Revelar>
          </li>
        ))}
      </ol>

      <Revelar className="mt-14">
        <CTA href={hrefWhatsapp} label={empezar.cta} variant="dark" />
      </Revelar>

      <Revelar className="mt-20 border-t border-white/15 pt-12">
        <p className="max-w-medida font-sans font-bold text-titulo text-balance">{empezar.quote}</p>
        <nav aria-label="Contacto" className="mt-10">
          <ul className="flex list-none flex-wrap gap-3 p-0 text-sm">
            {enlaces.map((enlace) => (
              <li key={enlace.href}>
                <a
                  href={enlace.href}
                  className="inline-block rounded-full border border-white/25 px-5 py-2.5 transition-colors hover:border-dorado hover:text-dorado"
                >
                  {enlace.label}
                </a>
              </li>
            ))}
          </ul>
        </nav>
        <div className="mt-16 flex flex-col gap-4 border-t border-white/10 pt-8 sm:flex-row sm:items-center sm:justify-between">
          <Wordmark size="sm" onDark />
          <p className="text-xs text-white/50">{empezar.legal}</p>
        </div>
      </Revelar>
    </Seccion>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test components/sections/__tests__/Empezar.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

  • Step 5: Commit
git add components/sections/Empezar.tsx components/sections/__tests__/Empezar.test.tsx
git commit -m "feat(06): la última página del documento, con el footer dentro"

Cambia app/page.tsx a las seis secciones nuevas. El header pierde su estado de scroll y vuelve a ser server component.

Files:

  • Modify: app/page.tsx
  • Modify: components/layout/Header.tsx
  • Modify: components/layout/__tests__/Header.test.tsx
  • Create: app/__tests__/page.test.tsx

Interfaces:

  • Consumes: las seis secciones de las Tareas 6–11.

  • Produces: la página completa. Ninguna tarea posterior depende de su API.

  • Step 1: Write the failing test

Crear app/__tests__/page.test.tsx:

import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import Home from '../page';

describe('la página', () => {
  it('monta las seis secciones numeradas', () => {
    const { container } = render(<Home />);
    const numeros = Array.from(container.querySelectorAll('section [aria-hidden="true"] span'))
      .map((s) => s.textContent)
      .filter((t) => t && /^0\d$/.test(t));
    expect(numeros).toEqual(['01', '02', '03', '04', '05', '06']);
  });

  it('gasta el tamaño display una sola vez', () => {
    // El fallo que este rediseño arregla: cinco elementos empatados como «lo
    // más grande de la página», y por eso ninguno lo era.
    const { container } = render(<Home />);
    expect(container.querySelectorAll('.text-display')).toHaveLength(1);
  });

  it('usa el navy exactamente dos veces', () => {
    const { container } = render(<Home />);
    const oscuras = Array.from(container.querySelectorAll('section'))
      .filter((s) => s.className.includes('bg-azul-profundo'));
    expect(oscuras).toHaveLength(2);
  });

  it('tiene un solo h1', () => {
    const { container } = render(<Home />);
    expect(container.querySelectorAll('h1')).toHaveLength(1);
  });

  it('ofrece saltar al contenido', () => {
    const { container } = render(<Home />);
    const salto = container.querySelector('a[href="#contenido"]');
    expect(salto).not.toBeNull();
    expect(container.querySelector('#contenido')).not.toBeNull();
  });

  it('no manda nada oculto en el marcado', () => {
    const { container } = render(<Home />);
    const ocultos = Array.from(container.querySelectorAll<HTMLElement>('*'))
      .filter((el) => el.style.opacity === '0' || el.style.filter.includes('blur'));
    expect(ocultos).toHaveLength(0);
  });
});

Reemplazar el contenido de components/layout/__tests__/Header.test.tsx por:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Header } from '../Header';

describe('<Header />', () => {
  it('lleva al inicio desde el wordmark', () => {
    render(<Header />);
    expect(screen.getByRole('link', { name: /creeer — inicio/i })).toHaveAttribute('href', '#top');
  });

  it('lleva al contacto', () => {
    render(<Header />);
    expect(screen.getByRole('link', { name: /hablemos/i })).toHaveAttribute('href', '#contacto');
  });

  it('no compite con el CTA de la apertura', () => {
    // Dos botones navy idénticos en la primera pantalla leían como un error de
    // maquetación, no como jerarquía. El del header va en fantasma.
    render(<Header />);
    const cta = screen.getByRole('link', { name: /hablemos/i });
    expect(cta.className).toContain('border-azul-profundo');
    expect(cta.className.split(/\s+/)).not.toContain('bg-azul-profundo');
  });
});
  • Step 2: Run tests to verify they fail

Run: pnpm test app/__tests__/page.test.tsx components/layout/__tests__/Header.test.tsx Expected: FAIL — la página monta diez secciones y el CTA del header sigue en variant="light".

  • Step 3: Write minimal implementation

Reemplazar app/page.tsx:

import { Header } from '@/components/layout/Header';
import { Apertura } from '@/components/sections/Apertura';
import { DondeEstas } from '@/components/sections/DondeEstas';
import { QueCambia } from '@/components/sections/QueCambia';
import { ElTrabajo } from '@/components/sections/ElTrabajo';
import { ParaQuienQuien } from '@/components/sections/ParaQuienQuien';
import { Empezar } from '@/components/sections/Empezar';

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'ProfessionalService',
  name: 'creeer',
  description: 'Estratega de crecimiento. Claridad estratégica para empresarios con tracción.',
  url: 'https://creeer.com',
};

// Seis secciones en el orden del documento: dónde estás, qué cambia, cuál es el
// trabajo, para quién es y cómo se empieza.
export default function Home() {
  return (
    <>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
      <a
        href="#contenido"
        className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[60] focus:bg-azul-profundo focus:px-5 focus:py-3 focus:text-white"
      >
        Saltar al contenido
      </a>
      <Header />
      <main id="contenido">
        <Apertura />
        <DondeEstas />
        <QueCambia />
        <ElTrabajo />
        <ParaQuienQuien />
        <Empezar />
      </main>
    </>
  );
}

Reemplazar components/layout/Header.tsx:

import { Wordmark } from '@/components/primitives/Wordmark';
import { CTA } from '@/components/primitives/CTA';
import { Container } from '@/components/primitives/Container';
import { header } from '@/lib/content';

// A server component again. The header used to hold scroll state so it could
// compact itself past 120px; that was a client boundary and a reading-progress
// bar paid for a flourish nobody asked for.
//
// The CTA is a ghost so the opening section's filled button is unmistakably the
// primary one — two identical navy buttons in the first screen read as a
// mistake rather than as hierarchy.

export function Header() {
  return (
    <header className="sticky top-0 z-50 border-b border-azul-profundo/10 bg-white/85 py-3 backdrop-blur">
      <Container className="flex items-center justify-between gap-4">
        <a href="#top" aria-label="creeer — inicio" className="block">
          <Wordmark size="sm" />
        </a>
        <nav aria-label="Principal">
          <CTA href="#contacto" label={header.cta} variant="ghost" />
        </nav>
      </Container>
    </header>
  );
}
  • Step 4: Run tests to verify they pass

Run: pnpm test app/__tests__/page.test.tsx components/layout/__tests__/Header.test.tsx && pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde. Los tests de las secciones viejas siguen pasando: los componentes existen todavía, solo han dejado de montarse.

  • Step 5: Commit
git add app/page.tsx app/__tests__/page.test.tsx components/layout/Header.tsx components/layout/__tests__/Header.test.tsx
git commit -m "feat(pagina): las seis secciones, header en fantasma y skip link"

Task 13: Retirar la página vieja

Ya nada la monta. Sale entera, con sus dependencias.

Files:

  • Delete: 10 secciones, sus tests, 17 primitivas, sus tests, components/layout/Footer.tsx + test, lib/motion.ts + test, public/images/ (recorrido, proceso, comoempezar)
  • Modify: lib/content.ts (fuera el bloque de alias), tailwind.config.ts (fuera los tokens muertos), package.json
  • Create: lib/__tests__/limpieza.test.ts

Interfaces:

  • Consumes: nada. Produces: nada. Es una retirada.

  • Step 1: Write the failing test

Crear lib/__tests__/limpieza.test.ts:

import { readdirSync, readFileSync } from 'node:fs';
import { describe, it, expect } from 'vitest';

// Guardas contra la vuelta de lo retirado. No son gusto: cada una nombra un
// problema medido.

const fuentes = () =>
  ['components/primitives', 'components/sections', 'components/layout', 'app', 'lib']
    .flatMap((dir) =>
      readdirSync(dir, { recursive: true, encoding: 'utf8' })
        .filter((f) => /\.tsx?$/.test(f) && !f.includes('__tests__'))
        .map((f) => `${dir}/${f}`),
    );

describe('la página vieja se fue', () => {
  it('nadie importa framer-motion', () => {
    // 53,6 kB del bundle compartido para un gesto que hace una animación CSS.
    fuentes().forEach((archivo) => {
      expect(readFileSync(archivo, 'utf8'), archivo).not.toMatch(/framer-motion/);
    });
  });

  it('no quedan pesos que la tipografía no tiene', () => {
    // Open Sans llega a 800 y layout.tsx carga hasta 800: font-black mentía.
    fuentes().forEach((archivo) => {
      expect(readFileSync(archivo, 'utf8'), archivo).not.toMatch(/font-black/);
    });
  });

  it('no quedan los tokens tipográficos viejos', () => {
    fuentes().forEach((archivo) => {
      expect(readFileSync(archivo, 'utf8'), archivo).not.toMatch(/text-ancla|text-seccion\b/);
    });
  });

  it('no quedan los dos blancos que eran el mismo blanco', () => {
    fuentes().forEach((archivo) => {
      expect(readFileSync(archivo, 'utf8'), archivo).not.toMatch(/bg-hondo-blanco|bg-hondo-claro/);
    });
  });

  it('no quedan alturas atadas al viewport', () => {
    // 100vh salta cada vez que se colapsa la barra del navegador móvil. El
    // documento respira por padding; si vuelve un vh, que sea svh o dvh.
    fuentes().forEach((archivo) => {
      expect(readFileSync(archivo, 'utf8'), archivo).not.toMatch(/\[\d+vh\]|h-screen|min-h-screen/);
    });
  });

  it('el package.json no arrastra dependencias muertas', () => {
    const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
    const deps = Object.keys(pkg.dependencies ?? {});
    expect(deps).not.toContain('framer-motion');
    expect(deps).not.toContain('embla-carousel-react');
    expect(deps).not.toContain('embla-carousel-autoplay');
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm test lib/__tests__/limpieza.test.ts Expected: FAIL en las cinco — las secciones viejas siguen en el árbol.

  • Step 3: Write minimal implementation
# Las diez secciones viejas y sus tests
git rm -r components/sections/Hero.tsx components/sections/PuntosInflexion.tsx \
  components/sections/FraseAncla.tsx components/sections/Dolores.tsx \
  components/sections/Disciplinas.tsx components/sections/Proceso.tsx \
  components/sections/ParaQuien.tsx components/sections/Estratega.tsx \
  components/sections/ComoEmpezar.tsx components/sections/Cierre.tsx \
  components/sections/__tests__/Hero.test.tsx components/sections/__tests__/PuntosInflexion.test.tsx \
  components/sections/__tests__/FraseAncla.test.tsx components/sections/__tests__/Dolores.test.tsx \
  components/sections/__tests__/Disciplinas.test.tsx components/sections/__tests__/Proceso.test.tsx \
  components/sections/__tests__/ParaQuien.test.tsx components/sections/__tests__/Estratega.test.tsx \
  components/sections/__tests__/ComoEmpezar.test.tsx components/sections/__tests__/Cierre.test.tsx

# Las diecisiete primitivas del aparato gráfico viejo, y sus tests
git rm components/primitives/FondoPuntos.tsx components/primitives/PatronPuntos.tsx \
  components/primitives/Resplandor.tsx components/primitives/IlustracionNudo.tsx \
  components/primitives/IlustracionBifurcacion.tsx components/primitives/IlustracionInflexion.tsx \
  components/primitives/IlustracionConvergencia.tsx components/primitives/SecuenciaClaridad.tsx \
  components/primitives/SimboloMarca.tsx components/primitives/TarjetaSpotlight.tsx \
  components/primitives/MarcoRetrato.tsx components/primitives/EscenaDialogo.tsx \
  components/primitives/ParDialogo.tsx components/primitives/FondoFoto.tsx \
  components/primitives/TextoDesenfoque.tsx components/primitives/ContadorNumero.tsx \
  components/primitives/ProgresoLectura.tsx components/primitives/Icono.tsx
git rm components/primitives/__tests__/FondoPuntos.test.tsx components/primitives/__tests__/PatronPuntos.test.tsx \
  components/primitives/__tests__/IlustracionNudo.test.tsx components/primitives/__tests__/IlustracionBifurcacion.test.tsx \
  components/primitives/__tests__/IlustracionInflexion.test.tsx components/primitives/__tests__/IlustracionConvergencia.test.tsx \
  components/primitives/__tests__/SecuenciaClaridad.test.tsx components/primitives/__tests__/SimboloMarca.test.tsx \
  components/primitives/__tests__/TarjetaSpotlight.test.tsx components/primitives/__tests__/MarcoRetrato.test.tsx \
  components/primitives/__tests__/ParDialogo.test.tsx components/primitives/__tests__/FondoFoto.test.tsx \
  components/primitives/__tests__/TextoDesenfoque.test.tsx components/primitives/__tests__/ContadorNumero.test.tsx \
  components/primitives/__tests__/ProgresoLectura.test.tsx components/primitives/__tests__/Icono.test.tsx

# El footer, absorbido por la sección 06; y el sistema de movimiento viejo
git rm components/layout/Footer.tsx components/layout/__tests__/Footer.test.tsx \
  lib/motion.ts lib/__tests__/motion.test.tsx

# Las catorce fotografías
git rm -r public/images/recorrido public/images/proceso public/images/comoempezar-bg.jpg

pnpm remove framer-motion embla-carousel-react embla-carousel-autoplay

Las rutas de arriba están verificadas contra el árbol del 7/ago/2026: los diez tests de sección y los dieciséis de primitiva existen todos. EscenaDialogo no tiene test propio, por eso no aparece. Se quedan simbolo.test.ts, Container.test.tsx, CTA.test.tsx, Wordmark.test.tsx y Antetitulo.test.tsx.

En lib/content.ts, borrar entero el bloque ── Alias de compatibilidad ── (de export const hero hasta el final de export const cierre) y también creditosFotos, que solo acreditaba las fotografías retiradas. Conservar wordmark, brandPillars, header y los seis objetos nuevos. Quitar de lib/__tests__/content.test.ts el test 'los alias viejos siguen leyendo de la misma fuente, no de una copia' y cualquier test que importe creditosFotos.

En tailwind.config.ts, borrar de fontSize las entradas ancla y seccion; de backgroundImage, hondo-blanco, hondo-claro, dorado-metal, velo-dorado y velo-frio (queda solo hondo-navy); y el bloque boxShadow entero.

En vitest.setup.ts, borrar el stub de ResizeObserver (era para Embla) y el de matchMedia (era para usePrefersReducedMotion). El de IntersectionObserver se queda: Revelar lo usa.

  • Step 4: Run tests to verify they pass

Run: pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde. Si tsc señala imports rotos, son restos de la retirada: borrarlos.

  • Step 5: Commit
git add -A
git commit -m "refactor: retirar la página vieja, sus 27 componentes y framer-motion"

Task 14: Verificar los criterios de aceptación

Ninguna implementación nueva: se comprueba contra el export real lo que el spec exige.

Files:

  • Modify: ninguno, salvo lo que falle.

  • Step 1: Construir el export

Run: pnpm build Expected: build correcto, sin warnings de módulos no encontrados.

  • Step 2: Comprobar que no se manda nada oculto

Run:

echo "opacity:0  -> $(grep -o 'opacity:0' out/index.html | wc -l)"
echo "filter:blur-> $(grep -o 'filter:blur' out/index.html | wc -l)"

Expected: 0 y 0. El export anterior daba 126 y 68.

  • Step 3: Comprobar que la página se lee sin JavaScript

Run:

python3 - <<'PY'
import re
html = open('out/index.html', encoding='utf8').read()
texto = re.sub(r'<script.*?</script>', '', html, flags=re.S)
texto = re.sub(r'<[^>]+>', ' ', texto)
for frase in ['Tu negocio no necesita', 'Crecer', 'One Year Plan',
              'Devuélvele claridad', 'Agenda tu sesión']:
    assert frase in texto, f'FALTA en el HTML: {frase}'
print('las cinco frases clave están en el HTML servido')
PY

Expected: imprime la confirmación.

  • Step 4: Comprobar que framer-motion salió del bundle

Run: grep -rl "framer-motion" out/_next/static/chunks/ | head; grep -c framer-motion package.json Expected: sin resultados en chunks/, y 0 en package.json.

  • Step 5: Comprobar la escala y el ritmo

Run:

echo "text-display -> $(grep -o 'text-display' out/index.html | wc -l)   (debe ser 1)"
echo "secciones navy -> $(grep -o '<section[^>]*bg-azul-profundo' out/index.html | wc -l)   (debe ser 2)"

Expected: 1 y 2.

  • Step 6: Cerrar la verificación

Run: pnpm test && npx tsc --noEmit && pnpm lint Expected: todo verde.

Si algo de los pasos 1–5 falla, arreglarlo antes de dar el rediseño por terminado. Reportar los números reales, no la intención.

  • Step 7: Commit
git commit --allow-empty -m "chore: verificados los criterios de aceptación del rediseño"

Lo que este plan NO hace

  • No pide la aprobación de la dueña. El spec (§1, §15) deja escrito que esta dirección va contra su encargo del 30/jul de "más gráfico y movimiento", y que su doc dice de los dolores y los puntos "usarlos, no reescribirlos" — este plan los conserva verbatim, pero reorganiza todo lo demás. Esa conversación es del usuario con ella.
  • No trae el retrato ni los contactos reales. Siguen como TODO dueña.
  • No decide si la escasez ("Generación Fundadora") puede vivir fuera del hero.