From 9d544a3ebb53072920c9beedc4931b26bf57d095 Mon Sep 17 00:00:00 2001 From: erikgh Date: Fri, 31 Jul 2026 10:51:57 -0600 Subject: [PATCH] =?UTF-8?q?docs(plan):=20implementaci=C3=B3n=20del=20hilo?= =?UTF-8?q?=20conductor=20en=2012=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cada tarea trae test primero, código completo y commit propio. En el self-review salieron tres cosas que se corrigieron en el plan: el spread de las líneas dispersas mandaba `barra` al DOM como atributo SVG inválido; el paso de parallax decía "mismo tratamiento para Mecanismo" en vez de mostrar el código (FondoFoto no acepta style, necesita envoltorio); y el compactado del header que pide el spec no tenía tarea asignada. Co-Authored-By: Claude Opus 5 --- .../plans/2026-07-31-hilo-conductor-visual.md | 1619 +++++++++++++++++ 1 file changed, 1619 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md diff --git a/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md b/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md new file mode 100644 index 0000000..a8707e3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md @@ -0,0 +1,1619 @@ +# 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 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); `usePrefersReducedMotion` de `lib/motion.ts`. +- 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 { useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { usePrefersReducedMotion } 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 reduced = usePrefersReducedMotion(); + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.unobserve(el); + } + }, + { threshold: 0.35 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [reduced]); + + 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 { useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { usePrefersReducedMotion } 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 reduced = usePrefersReducedMotion(); + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.unobserve(el); + } + }, + { threshold: 0.4 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [reduced]); + + // 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 { useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { usePrefersReducedMotion } 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 reduced = usePrefersReducedMotion(); + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.unobserve(el); + } + }, + { threshold: 0.4 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [reduced]); + + const punto = ( + <> + + + + ); + + // Plain under reduced motion — motion.path would hide the drawing. + if (reduced) { + return ( + + ); + } + + const dibuja = (delay: number) => ({ + initial: { pathLength: 0 }, + animate: inView ? { 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 { useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { usePrefersReducedMotion } 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 reduced = usePrefersReducedMotion(); + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.unobserve(el); + } + }, + { threshold: 0.4 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [reduced]); + + // Plain under reduced motion — motion.path would hide the drawing. + if (reduced) { + return ( + + ); + } + + const dibuja = (delay: number) => ({ + initial: { pathLength: 0 }, + animate: inView ? { 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 ( +