# El hilo conductor — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Dar a la landing de creeer un hilo visual continuo, cinco ilustraciones line-art nuevas y un movimiento rebalanceado, según `docs/superpowers/specs/2026-07-31-hilo-conductor-sistema-visual-design.md`. **Architecture:** Todas las piezas gráficas son SVG inline en `components/primitives/`, cada una con test co-locado. El movimiento ligado al scroll usa un único `useScroll()` global de Framer Motion (progreso de página, sin `target` ref — así no hay advertencias de posicionamiento en jsdom ni medición por sección). Las secciones existentes se modifican para consumir las primitivas nuevas y para retirar el movimiento que compite consigo mismo. **Tech Stack:** Next.js 14 App Router (`output: 'export'`), TypeScript, Tailwind, Framer Motion, Vitest + jsdom + Testing Library. ## Global Constraints Estas reglas aplican a **todas** las tareas: - **Gestor de paquetes: `pnpm`.** Nunca `npm`. - **Idioma:** copy, docs y mensajes de commit en **español**; comentarios de código en **inglés**. - **No se toca el copy** de `lib/content.ts`. Ninguna tarea edita ese archivo. - **Toda animación** pasa por `usePrefersReducedMotion()` de `lib/motion.ts`. Los componentes de sección combinan además `motionProps(reduced)`. - **Regla del `motion.path`/`motion.line`:** aplican su propio andamiaje de `dasharray` aunque no reciban `initial`/`animate`, lo que deja los trazos **invisibles** para usuarios de reduced motion. La rama reducida SIEMPRE renderiza elementos SVG planos (``, ``, ``), nunca `motion.*` neutralizados. Este bug ya mordió en `components/primitives/IlustracionInflexion.tsx:37` — ahí está documentado. - **Ilustraciones decorativas:** `aria-hidden="true"` y `focusable="false"` en el ``. - **Colores por token**, nunca hex suelto: `text-dorado`, `text-azul-profundo`, `text-gris-medio`, `bg-gris-claro`, `bg-azul-profundo`. - **El wordmark `cre≡er` va siempre en minúsculas** y sólo se renderiza vía ``. - **No se agrega escasez al hero.** El test negativo `Hero.test.tsx` (`cupos|cohorte|generación fundadora`) debe seguir pasando. - **No se reordenan las secciones** de `app/page.tsx`. - **Verificación por tarea:** `pnpm test` en verde antes de cada commit. Al final del plan, además: `pnpm lint` y `npx tsc --noEmit`. - **Rama de trabajo:** `feat/hilo-conductor-visual` (ya creada). --- ## Estructura de archivos **Crear:** | Archivo | Responsabilidad | |---|---| | `lib/simbolo.ts` | Geometría compartida del `≡` — única fuente de las coordenadas del símbolo | | `components/primitives/SecuenciaClaridad.tsx` | Complejidad → Enfoque → Claridad, en dos variantes | | `components/primitives/HiloConductor.tsx` | La línea dorada que atraviesa la página | | `components/primitives/IlustracionNudo.tsx` | El nudo de `Dolores` | | `components/primitives/IlustracionBifurcacion.tsx` | Los dos caminos de `ParaQuien` | | `components/primitives/IlustracionConvergencia.tsx` | Los tres trazos de `Disciplinas` | | `components/primitives/MarcoRetrato.tsx` | Marco line-art del retrato | | `components/primitives/ProgresoLectura.tsx` | Barra de progreso del header | | + `__tests__/` de cada uno | Contrato y marca, no estilos | **Modificar:** `tailwind.config.ts` (escala tipográfica), `components/primitives/SimboloTresLineas.tsx` (consumir `lib/simbolo.ts`), `app/page.tsx`, `components/layout/Header.tsx`, y las secciones `Hero`, `FraseAncla`, `Dolores`, `Mecanismo`, `Disciplinas`, `ParaQuien`, `Estratega`, `ComoEmpezar`, `Cierre`. --- ### Task 1: Geometría compartida del símbolo Hoy las coordenadas del `≡` viven sólo dentro de `SimboloTresLineas`. `SecuenciaClaridad` debe resolver **en el mismo símbolo**, no en uno parecido, así que la geometría se extrae a un módulo. **Files:** - Create: `lib/simbolo.ts` - Modify: `components/primitives/SimboloTresLineas.tsx:11-15` - Test: `components/primitives/__tests__/SimboloTresLineas.test.tsx` (ya existe; debe seguir pasando sin cambios) **Interfaces:** - Consumes: nada. - Produces: `BARRAS_SIMBOLO: readonly { x: number; y: number; width: number; height: number }[]` (3 barras), `VIEWBOX_SIMBOLO: string`, `LINEAS_SIMBOLO: readonly { x1: number; y1: number; x2: number; y2: number; grosor: number }[]` (las mismas 3 barras expresadas como líneas con grosor, para quien anime con ``). - [ ] **Step 1: Crear el módulo de geometría** ```ts // lib/simbolo.ts // Single source of truth for the ≡ glyph geometry. Both SimboloTresLineas // (which draws it with ) and SecuenciaClaridad (which resolves into it // with ) read from here, so the symbol they produce is provably the // same one and not a lookalike. export const VIEWBOX_SIMBOLO = '0 0 64 64'; export const BARRAS_SIMBOLO = [ { x: 13, y: 13, width: 38, height: 8 }, { x: 13, y: 28, width: 38, height: 8 }, { x: 13, y: 43, width: 38, height: 8 }, ] as const; // Same three bars as strokes: a horizontal line through each bar's centre, // with the bar's height as stroke width. export const LINEAS_SIMBOLO = BARRAS_SIMBOLO.map((b) => ({ x1: b.x, y1: b.y + b.height / 2, x2: b.x + b.width, y2: b.y + b.height / 2, grosor: b.height, })); ``` - [ ] **Step 2: Escribir el test de la geometría compartida** ```tsx // components/primitives/__tests__/simbolo.test.ts import { describe, it, expect } from 'vitest'; import { BARRAS_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo'; describe('geometría del ≡', () => { it('define exactly the three bars of the mark', () => { expect(BARRAS_SIMBOLO).toHaveLength(3); }); it('keeps the three bars aligned and equal in width', () => { const anchos = new Set(BARRAS_SIMBOLO.map((b) => b.width)); const izquierdas = new Set(BARRAS_SIMBOLO.map((b) => b.x)); expect(anchos.size).toBe(1); expect(izquierdas.size).toBe(1); }); it('derives stroke lines through each bar centre', () => { expect(LINEAS_SIMBOLO.map((l) => l.y1)).toEqual([17, 32, 47]); }); }); ``` - [ ] **Step 3: Correr el test — debe fallar** Run: `pnpm test components/primitives/__tests__/simbolo.test.ts` Expected: FAIL — `Cannot find module '@/lib/simbolo'` (si aún no creaste el archivo del Step 1, créalo ahora y vuelve a correr; debe PASAR). - [ ] **Step 4: Hacer que `SimboloTresLineas` consuma el módulo** En `components/primitives/SimboloTresLineas.tsx`, sustituir la constante local `FINAL` (líneas 11-15) por el import: ```tsx import { BARRAS_SIMBOLO } from '@/lib/simbolo'; const FINAL = BARRAS_SIMBOLO; ``` El resto del archivo (el array `BARS`, el `viewBox`, el render) **no cambia**. - [ ] **Step 5: Correr la suite completa** Run: `pnpm test` Expected: PASS — 92 tests previos + 3 nuevos = 95. - [ ] **Step 6: Commit** ```bash git add lib/simbolo.ts components/primitives/SimboloTresLineas.tsx components/primitives/__tests__/simbolo.test.ts git commit -m "refactor(simbolo): extraer la geometría del ≡ a lib/simbolo.ts" ``` --- ### Task 1b: Hook `useEnVista` El bloque "observa el elemento, marca `inView` una vez, desconecta" está hoy duplicado literal en `IlustracionInflexion.tsx:17-31` y `SimboloTresLineas.tsx:30-44`. Las cuatro ilustraciones de este plan lo necesitan también: sin extraerlo quedarían seis copias idénticas. Se extrae **antes** de escribirlas. **Files:** - Modify: `lib/motion.ts` - Modify: `components/primitives/IlustracionInflexion.tsx:17-31` - Modify: `components/primitives/SimboloTresLineas.tsx:30-44` - Test: `lib/__tests__/motion.test.tsx` **Interfaces:** - Consumes: `usePrefersReducedMotion` (ya en `lib/motion.ts`). - Produces: `useEnVista(umbral?: number): { ref: RefObject; enVista: boolean; reduced: boolean }`. Lo consumen las Tasks 2, 6, 7 y 8. - [ ] **Step 1: Escribir el test que falla** ```tsx // lib/__tests__/motion.test.tsx import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { useEnVista } from '@/lib/motion'; function Sonda() { const { ref, enVista, reduced } = useEnVista(); return ; } describe('useEnVista', () => { it('hands back a ref to attach, plus the two flags a drawing needs', () => { const { container } = render(); const svg = container.querySelector('svg'); expect(svg).toHaveAttribute('data-en-vista'); expect(svg).toHaveAttribute('data-reducido'); }); it('starts out not-yet-in-view so drawings do not skip their entrance', () => { const { container } = render(); // jsdom's IntersectionObserver stub never fires, so this also pins the // safe default: nothing claims to be visible until the observer says so. expect(container.querySelector('svg')).toHaveAttribute('data-en-vista', 'false'); }); }); ``` - [ ] **Step 2: Correr — debe fallar** Run: `pnpm test lib/__tests__/motion.test.tsx` Expected: FAIL — `useEnVista` no está exportado. - [ ] **Step 3: Implementar el hook** Añadir a `lib/motion.ts` (y ampliar el import de React a `import { useEffect, useRef, useState } from 'react';`): ```ts // Shared "draw once when scrolled into view" wiring for the SVG illustrations. // Returns the ref to attach, whether the element has been seen, and the // reduced-motion flag the caller needs anyway to pick its render branch. // Observes nothing under reduced motion — those branches render statically. export function useEnVista(umbral = 0.4) { const reduced = usePrefersReducedMotion(); const ref = useRef(null); const [enVista, setEnVista] = useState(false); useEffect(() => { const el = ref.current; if (!el || reduced) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setEnVista(true); observer.unobserve(el); } }, { threshold: umbral }, ); observer.observe(el); return () => observer.disconnect(); }, [reduced, umbral]); return { ref, enVista, reduced }; } ``` - [ ] **Step 4: Correr — debe pasar** Run: `pnpm test lib/__tests__/motion.test.tsx` Expected: PASS (2 tests). - [ ] **Step 5: Migrar las dos ilustraciones existentes** En `components/primitives/IlustracionInflexion.tsx` y `components/primitives/SimboloTresLineas.tsx`, borrar el `useRef`/`useState`/`useEffect` del observer y sustituirlos por: ```tsx const { ref, enVista, reduced } = useEnVista(); ``` Ajustar los usos: donde decía `inView` ahora dice `enVista`; el `ref={ref}` del `` no cambia. Los imports de `useEffect`, `useRef` y `useState` de React se van; `usePrefersReducedMotion` también, porque el hook ya devuelve `reduced`. - [ ] **Step 6: Correr la suite completa** Run: `pnpm test` Expected: PASS — los tests de ambas ilustraciones siguen verdes sin cambios (verifican trazos, no cableado). - [ ] **Step 7: Commit** ```bash git add lib/motion.ts lib/__tests__/motion.test.tsx components/primitives/IlustracionInflexion.tsx components/primitives/SimboloTresLineas.tsx git commit -m "refactor(motion): extraer useEnVista, compartido por las ilustraciones" ``` --- ### Task 2: `SecuenciaClaridad` — la pieza central Complejidad → Enfoque → Claridad. Dieciocho líneas cruzadas convergen; en `variant="completa"` resuelven en el `≡`. **Files:** - Create: `components/primitives/SecuenciaClaridad.tsx` - Test: `components/primitives/__tests__/SecuenciaClaridad.test.tsx` **Interfaces:** - Consumes: `LINEAS_SIMBOLO` de `lib/simbolo.ts` (Task 1); `useEnVista` de `lib/motion.ts` (Task 1b). - Produces: `SecuenciaClaridad({ variant?: 'completa' | 'hero'; className?: string })`. Por defecto `variant='completa'`. Usada por `Hero` (Task 4) y `FraseAncla` (Task 5). - [ ] **Step 1: Escribir el test que falla** ```tsx // components/primitives/__tests__/SecuenciaClaridad.test.tsx import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { SecuenciaClaridad } from '../SecuenciaClaridad'; describe('', () => { it('is decorative: hidden from assistive tech', () => { const { container } = render(); const svg = container.querySelector('svg'); expect(svg).toHaveAttribute('aria-hidden', 'true'); expect(svg).toHaveAttribute('focusable', 'false'); }); it('draws the tangle of lines that stands for complexity', () => { const { container } = render(); expect(container.querySelectorAll('line').length).toBeGreaterThanOrEqual(18); }); it('resolves into the mark itself, not a lookalike', () => { const { container } = render(); const resolucion = container.querySelectorAll('[data-resolucion]'); expect(resolucion).toHaveLength(3); }); it('leaves the tension unresolved in the hero variant', () => { const { container } = render(); expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(0); }); it('shares the viewBox with the symbol so both scale alike', () => { const { container } = render(); expect(container.querySelector('svg')).toHaveAttribute('viewBox', '0 0 64 64'); }); }); ``` - [ ] **Step 2: Correr el test — debe fallar** Run: `pnpm test components/primitives/__tests__/SecuenciaClaridad.test.tsx` Expected: FAIL — `Failed to resolve import "../SecuenciaClaridad"`. - [ ] **Step 3: Implementar el componente** ```tsx // components/primitives/SecuenciaClaridad.tsx 'use client'; import { motion } from 'framer-motion'; import { useEnVista } from '@/lib/motion'; import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo'; // The manual's core metaphor, finally drawn: Complejidad → Enfoque → Claridad. // Eighteen crossing lines converge in three groups; in the "completa" variant // each group collapses onto one of the three bars of the ≡, so the mark is // literally produced by the resolution of the tangle rather than placed next // to it. The "hero" variant stops at convergence and leaves the tension open — // FraseAncla pays it off further down the page. type Variant = 'completa' | 'hero'; // Scattered start positions. Hand-placed rather than random so the tangle is // identical on every render and between server and client. const DISPERSAS = [ { x1: 2, y1: 8, x2: 40, y2: 3, barra: 0 }, { x1: 20, y1: 2, x2: 61, y2: 24, barra: 0 }, { x1: 6, y1: 22, x2: 52, y2: 6, barra: 0 }, { x1: 34, y1: 5, x2: 12, y2: 27, barra: 0 }, { x1: 48, y1: 2, x2: 58, y2: 30, barra: 0 }, { x1: 3, y1: 15, x2: 46, y2: 19, barra: 0 }, { x1: 5, y1: 30, x2: 60, y2: 26, barra: 1 }, { x1: 14, y1: 40, x2: 55, y2: 20, barra: 1 }, { x1: 2, y1: 26, x2: 38, y2: 44, barra: 1 }, { x1: 44, y1: 22, x2: 18, y2: 41, barra: 1 }, { x1: 30, y1: 18, x2: 62, y2: 40, barra: 1 }, { x1: 8, y1: 36, x2: 50, y2: 33, barra: 1 }, { x1: 4, y1: 44, x2: 47, y2: 58, barra: 2 }, { x1: 24, y1: 62, x2: 60, y2: 42, barra: 2 }, { x1: 10, y1: 56, x2: 56, y2: 50, barra: 2 }, { x1: 40, y1: 61, x2: 14, y2: 46, barra: 2 }, { x1: 2, y1: 50, x2: 44, y2: 62, barra: 2 }, { x1: 33, y1: 44, x2: 62, y2: 57, barra: 2 }, ]; // One line per group survives to become a bar of the ≡; the rest fade as they // converge — many/disordered becomes few/aligned, per the manual. const SOBREVIVE = [0, 6, 12]; const EASE = [0.22, 1, 0.36, 1] as const; export function SecuenciaClaridad({ variant = 'completa', className = '', }: { variant?: Variant; className?: string; }) { const { ref, enVista, reduced } = useEnVista(0.35); const completa = variant === 'completa'; // Where each line ends up. In "completa" the survivors land exactly on the // symbol's own coordinates; in "hero" they merely tighten toward their // group's axis without ever aligning. const destino = (i: number) => { const { barra } = DISPERSAS[i]; const linea = LINEAS_SIMBOLO[barra]; const sobrevive = SOBREVIVE.includes(i); if (!completa) { const eje = linea.y1; const d = DISPERSAS[i]; return { x1: d.x1 + (26 - d.x1) * 0.35, y1: d.y1 + (eje - d.y1) * 0.55, x2: d.x2 + (38 - d.x2) * 0.35, y2: d.y2 + (eje - d.y2) * 0.55, opacity: sobrevive ? 0.95 : 0.3, }; } return { x1: linea.x1, y1: linea.y1, x2: linea.x2, y2: linea.y2, opacity: sobrevive ? 1 : 0, }; }; const grosor = (i: number) => (completa && SOBREVIVE.includes(i) ? LINEAS_SIMBOLO[0].grosor : 1.5); // Reduced motion renders plain elements: motion.line would apply its // own dash scaffolding and leave the drawing invisible (see the same trap in // IlustracionInflexion). if (reduced) { return ( ); } return ( ); } ``` - [ ] **Step 4: Correr el test — debe pasar** Run: `pnpm test components/primitives/__tests__/SecuenciaClaridad.test.tsx` Expected: PASS (5 tests). Nota: en jsdom `matchMedia.matches` es `false`, así que se ejercita la rama animada. El atributo `data-resolucion` existe en ambas ramas, por eso el test es válido en las dos. - [ ] **Step 5: Commit** ```bash git add components/primitives/SecuenciaClaridad.tsx components/primitives/__tests__/SecuenciaClaridad.test.tsx git commit -m "feat(grafico): SecuenciaClaridad — la metáfora del manual, dibujada" ``` --- ### Task 3: `HiloConductor` — la línea que cose la página **Files:** - Create: `components/primitives/HiloConductor.tsx` - Test: `components/primitives/__tests__/HiloConductor.test.tsx` - Modify: `app/page.tsx` **Interfaces:** - Consumes: `usePrefersReducedMotion`; `useScroll`, `useSpring`, `motion` de `framer-motion`. - Produces: `HiloConductor({ className?: string })`. Se monta una sola vez en `app/page.tsx`. - [ ] **Step 1: Escribir el test que falla** ```tsx // components/primitives/__tests__/HiloConductor.test.tsx import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { HiloConductor } from '../HiloConductor'; describe('', () => { it('is decorative: hidden from assistive tech', () => { const { container } = render(); const svg = container.querySelector('svg'); expect(svg).toHaveAttribute('aria-hidden', 'true'); expect(svg).toHaveAttribute('focusable', 'false'); }); it('draws a single continuous thread', () => { const { container } = render(); expect(container.querySelectorAll('path')).toHaveLength(1); }); it('travels with the document instead of sticking to the viewport', () => { const { container } = render(); // `fixed` would read as an ornament pinned to the window; the thread // belongs to the page and must scroll with it. expect(container.firstElementChild?.className).toContain('absolute'); expect(container.firstElementChild?.className).not.toContain('fixed'); }); it('stays out of the way of pointer input', () => { const { container } = render(); expect(container.firstElementChild?.className).toContain('pointer-events-none'); }); }); ``` - [ ] **Step 2: Correr el test — debe fallar** Run: `pnpm test components/primitives/__tests__/HiloConductor.test.tsx` Expected: FAIL — no se resuelve el import. - [ ] **Step 3: Implementar** ```tsx // components/primitives/HiloConductor.tsx 'use client'; import { motion, useScroll, useSpring } from 'framer-motion'; import { usePrefersReducedMotion } from '@/lib/motion'; // One continuous gold line running the height of the page, drawing itself as // the reader scrolls. It is the spine of the layout: it stitches the sections // together so the landing reads as a route rather than a stack of blocks. // // Positioned `absolute`, never `fixed` — the thread belongs to the document // and scrolls with it. A fixed layer would read as an ornament stuck to the // window. // // Uses the page-level useScroll() with no target ref on purpose: no element // measuring, no "container has a static position" warnings, and one scroll // listener for the whole page. const TRAZO = 'M50 0 C 20 140, 80 260, 50 400 C 20 540, 80 660, 50 800 C 20 940, 80 1060, 50 1200'; export function HiloConductor({ className = '' }: { className?: string }) { const reduced = usePrefersReducedMotion(); const { scrollYProgress } = useScroll(); const avance = useSpring(scrollYProgress, { stiffness: 90, damping: 30, restDelta: 0.001 }); const base = `pointer-events-none absolute inset-y-0 left-1/2 -translate-x-1/2 w-full max-w-content h-full text-dorado/25 ${className}`; // Plain for reduced motion: motion.path applies dash scaffolding even // without initial/animate and would leave the thread invisible. if (reduced) { return ( ); } return ( ); } ``` - [ ] **Step 4: Correr el test — debe pasar** Run: `pnpm test components/primitives/__tests__/HiloConductor.test.tsx` Expected: PASS (4 tests). - [ ] **Step 5: Montarlo en la página** En `app/page.tsx`, envolver `
` para dar contexto de posicionamiento y montar el hilo. El `
` pasa a `relative`; el hilo va **detrás** del contenido: ```tsx import { HiloConductor } from '@/components/primitives/HiloConductor'; ``` ```tsx
{/* dueña puede cambiar a "hormozi" */} ``` Las secciones ya traen fondo propio (`bg-white`, `bg-azul-profundo`, …), así que el hilo queda oculto tras ellas salvo donde se le deje pasar. Eso se resuelve en la Task 11. - [ ] **Step 6: Correr la suite completa y commitear** Run: `pnpm test` Expected: PASS. ```bash git add components/primitives/HiloConductor.tsx components/primitives/__tests__/HiloConductor.test.tsx app/page.tsx git commit -m "feat(grafico): HiloConductor — la línea dorada que cose la página" ``` --- ### Task 4: El hero cambia el carrusel por la secuencia Retira el `Carrusel` de dolores (duplica la sección `Dolores` tres bloques abajo y le compite atención al headline) y pone en su lugar `SecuenciaClaridad variant="hero"`. Retira además una de las dos capas de puntos que siguen el cursor. **Files:** - Modify: `components/sections/Hero.tsx` - Modify: `components/sections/__tests__/Hero.test.tsx:28-32` **Interfaces:** - Consumes: `SecuenciaClaridad` (Task 2). - Produces: nada nuevo. - [ ] **Step 1: Actualizar el test — el carrusel ya no debe existir** Sustituir el test `mirrors the client's own words back in a rotating carousel` (líneas 28-32) por: ```tsx it('does not pre-empt the Dolores section with its own copy', () => { const { container } = render(); // The hero used to rotate the same four quotes that Dolores renders in // full further down. Duplicating them spent the emotional beat early and // competed with the headline. expect(container.textContent).not.toMatch(/Trabajo más y gano casi lo mismo/i); }); it('carries the brand metaphor as its visual anchor', () => { const { container } = render(); expect(container.querySelectorAll('svg').length).toBeGreaterThan(0); }); ``` - [ ] **Step 2: Correr el test — debe fallar** Run: `pnpm test components/sections/__tests__/Hero.test.tsx` Expected: FAIL — el hero todavía renderiza la frase "Trabajo más y gano casi lo mismo". - [ ] **Step 3: Modificar el hero** En `components/sections/Hero.tsx`: 1. Borrar los imports de `Carrusel`, `TarjetaSpotlight` y de `dolores`; el import de `hero` se queda: ```tsx import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad'; import { hero } from '@/lib/content'; ``` 2. Dejar **una sola** capa de puntos interactiva (línea 27 se queda, línea 28 pierde `interactivo`): ```tsx ``` 3. Sustituir el bloque completo del carrusel (líneas 53-69) por: ```tsx {/* Complejidad → Enfoque, sin resolver: el hero plantea la tensión y FraseAncla la paga resolviendo en el ≡. */} ``` - [ ] **Step 4: Correr los tests — deben pasar** Run: `pnpm test components/sections/__tests__/Hero.test.tsx` Expected: PASS (7 tests). El test negativo de escasez sigue verde. - [ ] **Step 5: Commit** ```bash git add components/sections/Hero.tsx components/sections/__tests__/Hero.test.tsx git commit -m "feat(hero): la metáfora de marca sustituye al carrusel de dolores" ``` --- ### Task 5: `FraseAncla` resuelve la secuencia **Files:** - Modify: `components/sections/FraseAncla.tsx` - Modify: `components/sections/__tests__/FraseAncla.test.tsx` **Interfaces:** - Consumes: `SecuenciaClaridad` (Task 2). - [ ] **Step 1: Escribir el test que falla** Añadir a `components/sections/__tests__/FraseAncla.test.tsx`: ```tsx it('resolves the brand metaphor into the mark', () => { const { container } = render(); expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(3); }); ``` - [ ] **Step 2: Correr — debe fallar** Run: `pnpm test components/sections/__tests__/FraseAncla.test.tsx` Expected: FAIL — recibe 0 elementos con `data-resolucion`. - [ ] **Step 3: Sustituir el símbolo estático por la secuencia** En `components/sections/FraseAncla.tsx`, cambiar el import de `SimboloTresLineas` por `SecuenciaClaridad` y el bloque de las líneas 15-18 por: ```tsx {/* El pago de la tensión abierta en el hero: aquí las líneas sí resuelven en el ≡, justo cuando el copy nombra la claridad. */} ``` - [ ] **Step 4: Correr — debe pasar** Run: `pnpm test components/sections/__tests__/FraseAncla.test.tsx` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add components/sections/FraseAncla.tsx components/sections/__tests__/FraseAncla.test.tsx git commit -m "feat(frase-ancla): la secuencia resuelve en el ≡ sobre la frase" ``` --- ### Task 6: `IlustracionNudo` para `Dolores` `Dolores` es hoy la única sección sin ninguna pieza gráfica, y es la de mayor carga emocional. **Files:** - Create: `components/primitives/IlustracionNudo.tsx` - Test: `components/primitives/__tests__/IlustracionNudo.test.tsx` - Modify: `components/sections/Dolores.tsx` **Interfaces:** - Produces: `IlustracionNudo({ className?: string })`. - [ ] **Step 1: Escribir el test que falla** ```tsx // components/primitives/__tests__/IlustracionNudo.test.tsx import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { IlustracionNudo } from '../IlustracionNudo'; describe('', () => { it('is decorative: hidden from assistive tech', () => { const { container } = render(); const svg = container.querySelector('svg'); expect(svg).toHaveAttribute('aria-hidden', 'true'); expect(svg).toHaveAttribute('focusable', 'false'); }); it('tangles several strands rather than drawing one line', () => { const { container } = render(); expect(container.querySelectorAll('path').length).toBeGreaterThanOrEqual(3); }); }); ``` - [ ] **Step 2: Correr — debe fallar** Run: `pnpm test components/primitives/__tests__/IlustracionNudo.test.tsx` Expected: FAIL — import no resuelto. - [ ] **Step 3: Implementar** ```tsx // components/primitives/IlustracionNudo.tsx 'use client'; import { motion } from 'framer-motion'; import { useEnVista } from '@/lib/motion'; // Four strands looping over each other into a knot that never opens — the // section's own subject drawn: effort that folds back on itself instead of // going anywhere. The strands draw themselves in sequence, which reads as the // knot tightening rather than as decoration appearing. const HEBRAS = [ { d: 'M30 90 C 60 20, 130 20, 140 70 C 148 112, 78 118, 70 78 C 63 44, 120 38, 150 66', ancho: 2.5 }, { d: 'M20 60 C 70 110, 120 110, 160 60', ancho: 2 }, { d: 'M40 110 C 80 40, 110 130, 155 95', ancho: 2 }, { d: 'M25 40 C 90 70, 100 100, 165 40', ancho: 1.5 }, ]; export function IlustracionNudo({ className = '' }: { className?: string }) { const { ref, enVista, reduced } = useEnVista(); // Plain under reduced motion — motion.path would hide the drawing. if (reduced) { return ( ); } return ( ); } ``` - [ ] **Step 4: Correr — debe pasar** Run: `pnpm test components/primitives/__tests__/IlustracionNudo.test.tsx` Expected: PASS (2 tests). - [ ] **Step 5: Montarlo en `Dolores`** En `components/sections/Dolores.tsx`: añadir el import, hacer la sección `relative overflow-hidden` y colocar el nudo detrás del encabezado. ```tsx import { IlustracionNudo } from '@/components/primitives/IlustracionNudo'; ``` Cambiar la apertura de la sección y el `Container`: ```tsx
``` - [ ] **Step 6: Correr la suite y commitear** Run: `pnpm test` Expected: PASS. ```bash git add components/primitives/IlustracionNudo.tsx components/primitives/__tests__/IlustracionNudo.test.tsx components/sections/Dolores.tsx git commit -m "feat(dolores): el nudo que no se deshace" ``` --- ### Task 7: `IlustracionBifurcacion` para `ParaQuien` **Files:** - Create: `components/primitives/IlustracionBifurcacion.tsx` - Test: `components/primitives/__tests__/IlustracionBifurcacion.test.tsx` - Modify: `components/sections/ParaQuien.tsx` **Interfaces:** - Produces: `IlustracionBifurcacion({ className?: string })`. - [ ] **Step 1: Escribir el test que falla** ```tsx // components/primitives/__tests__/IlustracionBifurcacion.test.tsx import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { IlustracionBifurcacion } from '../IlustracionBifurcacion'; describe('', () => { it('is decorative: hidden from assistive tech', () => { const { container } = render(); const svg = container.querySelector('svg'); expect(svg).toHaveAttribute('aria-hidden', 'true'); expect(svg).toHaveAttribute('focusable', 'false'); }); it('draws one shared trunk and two diverging routes', () => { const { container } = render(); expect(container.querySelectorAll('path')).toHaveLength(3); }); it('tells the two routes apart: one solid, one dashed', () => { const { container } = render(); const punteadas = Array.from(container.querySelectorAll('path')).filter((p) => p.getAttribute('stroke-dasharray'), ); expect(punteadas).toHaveLength(1); }); it('marks the decision point', () => { const { container } = render(); expect(container.querySelectorAll('circle').length).toBeGreaterThanOrEqual(1); }); }); ``` - [ ] **Step 2: Correr — debe fallar** Run: `pnpm test components/primitives/__tests__/IlustracionBifurcacion.test.tsx` Expected: FAIL — import no resuelto. - [ ] **Step 3: Implementar** ```tsx // components/primitives/IlustracionBifurcacion.tsx 'use client'; import { motion } from 'framer-motion'; import { useEnVista } from '@/lib/motion'; // A shared trunk splitting at a marked decision point: one route climbs, gold // and continuous; the other flattens, grey and dashed. It draws the section's // own bifurcation — "sí es para ti / no es para ti" — without illustrating a // judgement about the reader. const TRONCO = 'M10 100 C 50 96, 80 84, 110 70'; const RUTA_SI = 'M110 70 C 145 58, 175 30, 210 18'; const RUTA_NO = 'M110 70 C 145 82, 175 92, 210 90'; export function IlustracionBifurcacion({ className = '' }: { className?: string }) { const { ref, enVista, reduced } = useEnVista(); const punto = ( <> ); // Plain under reduced motion — motion.path would hide the drawing. if (reduced) { return ( ); } const dibuja = (delay: number) => ({ initial: { pathLength: 0 }, animate: enVista ? { pathLength: 1 } : { pathLength: 0 }, transition: { duration: 1.1, delay, ease: [0.22, 1, 0.36, 1] as const }, }); return ( ); } ``` - [ ] **Step 4: Correr — debe pasar** Run: `pnpm test components/primitives/__tests__/IlustracionBifurcacion.test.tsx` Expected: PASS (4 tests). - [ ] **Step 5: Montarla en `ParaQuien`** En `components/sections/ParaQuien.tsx`, junto al título (líneas 12-15), pasar a dos columnas para que la ilustración acompañe al encabezado: ```tsx import { IlustracionBifurcacion } from '@/components/primitives/IlustracionBifurcacion'; ``` ```tsx {paraQuien.title} ``` Nota: `text-seccion` se define en la Task 11. Si ejecutas esta tarea antes, deja la clase original `text-4xl md:text-6xl leading-[0.95] tracking-tight` y sustitúyela en la Task 11. - [ ] **Step 6: Correr la suite y commitear** Run: `pnpm test` Expected: PASS. ```bash git add components/primitives/IlustracionBifurcacion.tsx components/primitives/__tests__/IlustracionBifurcacion.test.tsx components/sections/ParaQuien.tsx git commit -m "feat(para-quien): la bifurcación dibujada junto al split sí/no" ``` --- ### Task 8: `IlustracionConvergencia` para `Disciplinas` Tres trazos que confluyen en uno — refuerza el lead ya escrito: *"No son la promesa — son los medios."* **Files:** - Create: `components/primitives/IlustracionConvergencia.tsx` - Test: `components/primitives/__tests__/IlustracionConvergencia.test.tsx` - Modify: `components/sections/Disciplinas.tsx` **Interfaces:** - Produces: `IlustracionConvergencia({ className?: string })`. - [ ] **Step 1: Escribir el test que falla** ```tsx // components/primitives/__tests__/IlustracionConvergencia.test.tsx import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { IlustracionConvergencia } from '../IlustracionConvergencia'; describe('', () => { it('is decorative: hidden from assistive tech', () => { const { container } = render(); const svg = container.querySelector('svg'); expect(svg).toHaveAttribute('aria-hidden', 'true'); expect(svg).toHaveAttribute('focusable', 'false'); }); it('draws one strand per discipline plus the strategy they serve', () => { const { container } = render(); expect(container.querySelectorAll('path')).toHaveLength(4); }); }); ``` - [ ] **Step 2: Correr — debe fallar** Run: `pnpm test components/primitives/__tests__/IlustracionConvergencia.test.tsx` Expected: FAIL — import no resuelto. - [ ] **Step 3: Implementar** ```tsx // components/primitives/IlustracionConvergencia.tsx 'use client'; import { motion } from 'framer-motion'; import { useEnVista } from '@/lib/motion'; // Three strands — comunicación, digital, experiencias — meeting and leaving as // a single gold line. The section's own lead, drawn: "No son la promesa, son // los medios." The three are grey; only what they produce is gold. const HEBRAS = [ 'M6 18 C 60 20, 90 52, 120 60', 'M6 60 C 60 60, 90 60, 120 60', 'M6 102 C 60 100, 90 68, 120 60', ]; const ESTRATEGIA = 'M120 60 L 214 60'; export function IlustracionConvergencia({ className = '' }: { className?: string }) { const { ref, enVista, reduced } = useEnVista(); // Plain under reduced motion — motion.path would hide the drawing. if (reduced) { return ( ); } const dibuja = (delay: number) => ({ initial: { pathLength: 0 }, animate: enVista ? { pathLength: 1 } : { pathLength: 0 }, transition: { duration: 1, delay, ease: [0.22, 1, 0.36, 1] as const }, }); return ( ); } ``` - [ ] **Step 4: Correr — debe pasar** Run: `pnpm test components/primitives/__tests__/IlustracionConvergencia.test.tsx` Expected: PASS (2 tests). - [ ] **Step 5: Montarla en `Disciplinas`** En `components/sections/Disciplinas.tsx`, sustituir el párrafo `lead` (líneas 20-21) por una fila que combine el lead con la ilustración: ```tsx import { IlustracionConvergencia } from '@/components/primitives/IlustracionConvergencia'; ``` ```tsx

{disciplinas.lead}

``` - [ ] **Step 6: Correr la suite y commitear** Run: `pnpm test` Expected: PASS. ```bash git add components/primitives/IlustracionConvergencia.tsx components/primitives/__tests__/IlustracionConvergencia.test.tsx components/sections/Disciplinas.tsx git commit -m "feat(disciplinas): tres hebras que confluyen en una estrategia" ``` --- ### Task 9: `MarcoRetrato` para `Estratega` El retrato real de la dueña sigue pendiente. El marco debe verse intencional **hoy**, con el placeholder dentro, y aceptar la foto real **sin rehacer nada**. **Files:** - Create: `components/primitives/MarcoRetrato.tsx` - Test: `components/primitives/__tests__/MarcoRetrato.test.tsx` - Modify: `components/sections/Estratega.tsx:19-26` **Interfaces:** - Consumes: nada. - Produces: `MarcoRetrato({ children: ReactNode; className?: string })` — envuelve el contenido del retrato y dibuja el marco alrededor. - [ ] **Step 1: Escribir el test que falla** ```tsx // components/primitives/__tests__/MarcoRetrato.test.tsx import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MarcoRetrato } from '../MarcoRetrato'; describe('', () => { it('renders whatever portrait it is given', () => { render(retrato); expect(screen.getByText('retrato')).toBeInTheDocument(); }); it('keeps the frame decorative: the portrait carries the meaning', () => { const { container } = render(retrato); expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); }); it('draws the arc that will survive the real photograph landing', () => { const { container } = render(retrato); expect(container.querySelectorAll('circle').length).toBeGreaterThanOrEqual(1); }); }); ``` - [ ] **Step 2: Correr — debe fallar** Run: `pnpm test components/primitives/__tests__/MarcoRetrato.test.tsx` Expected: FAIL — import no resuelto. - [ ] **Step 3: Implementar** ```tsx // components/primitives/MarcoRetrato.tsx 'use client'; import type { ReactNode } from 'react'; import { motion } from 'framer-motion'; import { usePrefersReducedMotion } from '@/lib/motion'; // Line-art frame for the strategist's portrait. Built so it reads as // deliberate with today's placeholder inside and accepts the real photograph // when it arrives without any rework: the frame never assumes what it wraps. // // The gold arc traces roughly three quarters of the circle and draws itself in // — an open frame, not a closed badge. export function MarcoRetrato({ children, className = '' }: { children: ReactNode; className?: string }) { const reduced = usePrefersReducedMotion(); return (
{children}
); } ``` - [ ] **Step 4: Correr — debe pasar** Run: `pnpm test components/primitives/__tests__/MarcoRetrato.test.tsx` Expected: PASS (3 tests). - [ ] **Step 5: Usarlo en `Estratega`** Sustituir el bloque del retrato (líneas 19-26) por: ```tsx {/* TODO dueña: real portrait. The frame is built to take it as-is — drop an in place of the placeholder below. */}
``` Añadir el import `import { MarcoRetrato } from '@/components/primitives/MarcoRetrato';` y quitar del `motion.div` las clases del marco viejo (`ring-4 ring-dorado ring-offset-4`, tamaños y `rounded-full`), que ahora las aporta `MarcoRetrato`. - [ ] **Step 6: Correr la suite y commitear** Run: `pnpm test` Expected: PASS. ```bash git add components/primitives/MarcoRetrato.tsx components/primitives/__tests__/MarcoRetrato.test.tsx components/sections/Estratega.tsx git commit -m "feat(estratega): marco line-art listo para el retrato real" ``` --- ### Task 10: `ProgresoLectura` y header que se compacta **Files:** - Create: `components/primitives/ProgresoLectura.tsx` - Test: `components/primitives/__tests__/ProgresoLectura.test.tsx` - Modify: `components/layout/Header.tsx` **Interfaces:** - Consumes: `useScroll`, `useSpring`, `motion`; `usePrefersReducedMotion`. - Produces: `ProgresoLectura({ className?: string })`. Nota sobre `'use client'`: `Header` es hoy un Server Component. El compactado necesita estado de scroll en el propio `
` (padding y tamaño del wordmark), así que **esta tarea lo convierte en client component**. El costo es nulo — el header sólo renderiza `Wordmark` y `CTA`, sin datos de servidor — y evita partirlo en dos componentes para conservar una frontera que aquí no aporta nada. - [ ] **Step 1: Escribir el test que falla** ```tsx // components/primitives/__tests__/ProgresoLectura.test.tsx import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { ProgresoLectura } from '../ProgresoLectura'; describe('', () => { it('is decorative: hidden from assistive tech', () => { const { container } = render(); expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true'); }); it('renders as a gold rule anchored to the bottom edge', () => { const { container } = render(); const cls = container.firstElementChild?.className ?? ''; expect(cls).toContain('bg-dorado'); expect(cls).toContain('absolute'); }); }); ``` - [ ] **Step 2: Correr — debe fallar** Run: `pnpm test components/primitives/__tests__/ProgresoLectura.test.tsx` Expected: FAIL — import no resuelto. - [ ] **Step 3: Implementar** ```tsx // components/primitives/ProgresoLectura.tsx 'use client'; import { motion, useScroll, useSpring } from 'framer-motion'; import { usePrefersReducedMotion } from '@/lib/motion'; // Reading progress along the bottom edge of the header. Pure motion feedback: // under reduced motion it renders nothing at all, because a static progress // bar communicates nothing — unlike an illustration, there is no meaningful // still frame of it. export function ProgresoLectura({ className = '' }: { className?: string }) { const reduced = usePrefersReducedMotion(); const { scrollYProgress } = useScroll(); const avance = useSpring(scrollYProgress, { stiffness: 120, damping: 30, restDelta: 0.001 }); if (reduced) return null; return (