# 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; 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; 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: `{children}`. 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[]) => void; beforeEach(() => { vi.stubGlobal( 'IntersectionObserver', class { constructor(cb: IntersectionObserverCallback) { disparar = (entradas) => cb(entradas as IntersectionObserverEntry[], this as never); } observe() {} unobserve() {} disconnect() {} }, ); }); describe('', () => { 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(Tu negocio no necesita más esfuerzo.); 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(x); expect(container.firstElementChild).toHaveAttribute('data-revelar'); }); it('escalona el retardo con el índice', () => { const { container } = render(x); expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--i')).toBe('3'); }); it('se marca visible al entrar en viewport', () => { const { container } = render(x); 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 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(null); useEffect(() => { const el = ref.current; if (!el || typeof IntersectionObserver === 'undefined') return; const obs = observadorCompartido(); obs.observe(el); return () => obs.unobserve(el); }, []); return (
{children}
); } ``` 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 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 `` y antes de ``: ```tsx {/* 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. */} ', '', 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 ']*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.