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>
2274 lines
89 KiB
Markdown
2274 lines
89 KiB
Markdown
# 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`:
|
||
|
||
```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:
|
||
|
||
```ts
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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:
|
||
|
||
```css
|
||
/* 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`:
|
||
|
||
```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>`:
|
||
|
||
```tsx
|
||
<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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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:
|
||
```ts
|
||
<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`:
|
||
|
||
```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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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:
|
||
|
||
```ts
|
||
// ── 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:
|
||
|
||
```ts
|
||
// ── 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:
|
||
>
|
||
> ```bash
|
||
> 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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Montar la página, el header y el skip link
|
||
|
||
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`:
|
||
|
||
```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:
|
||
|
||
```tsx
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
# 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**
|
||
|
||
```bash
|
||
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:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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.
|