Files
Creeer-Web/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md
T
erikghandClaude Opus 5 9d544a3ebb docs(plan): implementación del hilo conductor en 12 tareas
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 <noreply@anthropic.com>
2026-07-31 10:51:57 -06:00

1620 lines
62 KiB
Markdown

# 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 (`<path>`, `<line>`, `<rect>`), 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 `<svg>`.
- **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 `<Wordmark />`.
- **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 `<line>`).
- [ ] **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 <rect>) and SecuenciaClaridad (which resolves into it
// with <line>) 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('<SecuenciaClaridad />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<SecuenciaClaridad />);
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(<SecuenciaClaridad />);
expect(container.querySelectorAll('line').length).toBeGreaterThanOrEqual(18);
});
it('resolves into the mark itself, not a lookalike', () => {
const { container } = render(<SecuenciaClaridad variant="completa" />);
const resolucion = container.querySelectorAll('[data-resolucion]');
expect(resolucion).toHaveLength(3);
});
it('leaves the tension unresolved in the hero variant', () => {
const { container } = render(<SecuenciaClaridad variant="hero" />);
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(0);
});
it('shares the viewBox with the symbol so both scale alike', () => {
const { container } = render(<SecuenciaClaridad />);
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<SVGSVGElement>(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 <line> elements: motion.line would apply its
// own dash scaffolding and leave the drawing invisible (see the same trap in
// IlustracionInflexion).
if (reduced) {
return (
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
{DISPERSAS.map((_, i) => {
const d = destino(i);
const visible = completa ? SOBREVIVE.includes(i) : true;
return (
<line
key={i}
{...(completa && SOBREVIVE.includes(i) ? { 'data-resolucion': true } : {})}
x1={d.x1}
y1={d.y1}
x2={d.x2}
y2={d.y2}
stroke="currentColor"
strokeWidth={grosor(i)}
strokeLinecap="round"
opacity={visible ? d.opacity : 0}
/>
);
})}
</svg>
);
}
return (
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
{DISPERSAS.map(({ barra: _barra, ...inicio }, i) => {
const d = destino(i);
// `barra` is grouping metadata, not an SVG attribute — destructured out
// so it never reaches the DOM.
const reposo = { ...inicio, opacity: 0.55, strokeWidth: 1.5 };
return (
<motion.line
key={i}
{...(completa && SOBREVIVE.includes(i) ? { 'data-resolucion': true } : {})}
stroke="currentColor"
strokeLinecap="round"
initial={reposo}
animate={inView ? { ...d, strokeWidth: grosor(i) } : reposo}
transition={{ duration: 1.2, delay: 0.25 + (i % 6) * 0.05, ease: EASE }}
/>
);
})}
</svg>
);
}
```
- [ ] **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('<HiloConductor />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<HiloConductor />);
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(<HiloConductor />);
expect(container.querySelectorAll('path')).toHaveLength(1);
});
it('travels with the document instead of sticking to the viewport', () => {
const { container } = render(<HiloConductor />);
// `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(<HiloConductor />);
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 <path> for reduced motion: motion.path applies dash scaffolding even
// without initial/animate and would leave the thread invisible.
if (reduced) {
return (
<svg
className={base}
viewBox="0 0 100 1200"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
<path d={TRAZO} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
</svg>
);
}
return (
<svg
className={base}
viewBox="0 0 100 1200"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
<motion.path
d={TRAZO}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
style={{ pathLength: avance }}
/>
</svg>
);
}
```
- [ ] **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 `<main>` para dar contexto de posicionamiento y montar el hilo. El `<main>` pasa a `relative`; el hilo va **detrás** del contenido:
```tsx
import { HiloConductor } from '@/components/primitives/HiloConductor';
```
```tsx
<Header />
<main className="relative">
<HiloConductor />
<Hero variant="editorial" /> {/* 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(<Hero variant="editorial" />);
// 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(<Hero variant="editorial" />);
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
<FondoPuntos className="top-24 -right-16 w-72 md:w-[28rem] text-azul-profundo/10" interactivo />
<FondoPuntos className="bottom-16 -left-20 w-64 md:w-96 text-dorado/20" distance={28} rows={2} />
```
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 ≡. */}
<motion.div variants={fadeInUp} initial="hidden" animate="visible" {...motionProps(reduced)}
className="hidden lg:block min-w-0">
<SecuenciaClaridad variant="hero" className="w-full max-w-md mx-auto text-azul-profundo" />
</motion.div>
```
- [ ] **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(<FraseAncla />);
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
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex justify-center">
{/* 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. */}
<SecuenciaClaridad className="pointer-events-none select-none w-28 md:w-36 text-dorado" />
</motion.div>
```
- [ ] **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('<IlustracionNudo />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<IlustracionNudo />);
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(<IlustracionNudo />);
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<SVGSVGElement>(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 <path> under reduced motion — motion.path would hide the drawing.
if (reduced) {
return (
<svg ref={ref} viewBox="0 0 190 140" className={className} aria-hidden="true" focusable="false">
{HEBRAS.map((h) => (
<path key={h.d} d={h.d} fill="none" stroke="currentColor" strokeWidth={h.ancho} strokeLinecap="round" />
))}
</svg>
);
}
return (
<svg ref={ref} viewBox="0 0 190 140" className={className} aria-hidden="true" focusable="false">
{HEBRAS.map((h, i) => (
<motion.path
key={h.d}
d={h.d}
fill="none"
stroke="currentColor"
strokeWidth={h.ancho}
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={inView ? { pathLength: 1 } : { pathLength: 0 }}
transition={{ duration: 1.3, delay: i * 0.18, ease: [0.22, 1, 0.36, 1] }}
/>
))}
</svg>
);
}
```
- [ ] **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
<section className="bg-white relative overflow-hidden">
<IlustracionNudo className="pointer-events-none select-none absolute -top-6 right-4 w-64 md:w-96 text-azul-profundo/[0.08]" />
<Container className="relative py-24 md:py-32">
```
- [ ] **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('<IlustracionBifurcacion />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<IlustracionBifurcacion />);
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(<IlustracionBifurcacion />);
expect(container.querySelectorAll('path')).toHaveLength(3);
});
it('tells the two routes apart: one solid, one dashed', () => {
const { container } = render(<IlustracionBifurcacion />);
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(<IlustracionBifurcacion />);
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<SVGSVGElement>(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 = (
<>
<circle cx={110} cy={70} r={5} fill="currentColor" className="text-dorado" />
<circle cx={110} cy={70} r={9} fill="none" stroke="currentColor" className="text-dorado" strokeWidth={1} strokeOpacity={0.4} />
</>
);
// Plain <path> under reduced motion — motion.path would hide the drawing.
if (reduced) {
return (
<svg ref={ref} viewBox="0 0 220 120" className={className} aria-hidden="true" focusable="false">
<path d={TRONCO} fill="none" stroke="currentColor" className="text-azul-profundo" strokeWidth={2.5} strokeLinecap="round" />
<path d={RUTA_NO} fill="none" stroke="currentColor" className="text-gris-medio" strokeWidth={2} strokeLinecap="round" strokeDasharray="1 8" />
<path d={RUTA_SI} fill="none" stroke="currentColor" className="text-dorado" strokeWidth={3} strokeLinecap="round" />
{punto}
</svg>
);
}
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 (
<svg ref={ref} viewBox="0 0 220 120" className={className} aria-hidden="true" focusable="false">
<motion.path d={TRONCO} fill="none" stroke="currentColor" className="text-azul-profundo" strokeWidth={2.5} strokeLinecap="round" {...dibuja(0)} />
<motion.path d={RUTA_NO} fill="none" stroke="currentColor" className="text-gris-medio" strokeWidth={2} strokeLinecap="round" strokeDasharray="1 8" {...dibuja(0.55)} />
<motion.path d={RUTA_SI} fill="none" stroke="currentColor" className="text-dorado" strokeWidth={3} strokeLinecap="round" {...dibuja(0.55)} />
{punto}
</svg>
);
}
```
- [ ] **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
<Container className="relative pt-24 md:pt-32 pb-12 md:pb-16 md:flex md:items-center md:justify-between md:gap-12">
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="font-sans font-black text-seccion text-azul-profundo max-w-3xl">{paraQuien.title}</motion.h2>
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-8 md:mt-0 shrink-0">
<IlustracionBifurcacion className="w-56 md:w-72 h-auto mx-auto" />
</motion.div>
</Container>
```
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('<IlustracionConvergencia />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<IlustracionConvergencia />);
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(<IlustracionConvergencia />);
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<SVGSVGElement>(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 <path> under reduced motion — motion.path would hide the drawing.
if (reduced) {
return (
<svg ref={ref} viewBox="0 0 220 120" className={className} aria-hidden="true" focusable="false">
{HEBRAS.map((d) => (
<path key={d} d={d} fill="none" stroke="currentColor" className="text-gris-medio" strokeWidth={2} strokeLinecap="round" />
))}
<path d={ESTRATEGIA} fill="none" stroke="currentColor" className="text-dorado" strokeWidth={3.5} strokeLinecap="round" />
</svg>
);
}
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 (
<svg ref={ref} viewBox="0 0 220 120" className={className} aria-hidden="true" focusable="false">
{HEBRAS.map((d, i) => (
<motion.path key={d} d={d} fill="none" stroke="currentColor" className="text-gris-medio" strokeWidth={2} strokeLinecap="round" {...dibuja(i * 0.12)} />
))}
<motion.path d={ESTRATEGIA} fill="none" stroke="currentColor" className="text-dorado" strokeWidth={3.5} strokeLinecap="round" {...dibuja(0.7)} />
</svg>
);
}
```
- [ ] **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
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 md:flex md:items-center md:justify-between md:gap-10">
<p className="text-gris-medio text-lg">{disciplinas.lead}</p>
<IlustracionConvergencia className="mt-6 md:mt-0 w-56 md:w-64 h-auto shrink-0" />
</motion.div>
```
- [ ] **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('<MarcoRetrato />', () => {
it('renders whatever portrait it is given', () => {
render(<MarcoRetrato><span>retrato</span></MarcoRetrato>);
expect(screen.getByText('retrato')).toBeInTheDocument();
});
it('keeps the frame decorative: the portrait carries the meaning', () => {
const { container } = render(<MarcoRetrato><span>retrato</span></MarcoRetrato>);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it('draws the arc that will survive the real photograph landing', () => {
const { container } = render(<MarcoRetrato><span>retrato</span></MarcoRetrato>);
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 (
<div className={`relative w-44 h-44 md:w-64 md:h-64 shrink-0 ${className}`}>
<svg viewBox="0 0 100 100" className="absolute -inset-3 w-[calc(100%+1.5rem)] h-[calc(100%+1.5rem)] text-dorado" aria-hidden="true" focusable="false">
{reduced ? (
<circle
cx={50}
cy={50}
r={47}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeDasharray="220 295"
transform="rotate(-120 50 50)"
/>
) : (
<motion.circle
cx={50}
cy={50}
r={47}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeDasharray="220 295"
transform="rotate(-120 50 50)"
initial={{ pathLength: 0, opacity: 0 }}
whileInView={{ pathLength: 1, opacity: 1 }}
viewport={{ once: true, amount: 0.4 }}
transition={{ duration: 1.2, ease: [0.22, 1, 0.36, 1] }}
/>
)}
</svg>
<div className="relative w-full h-full rounded-full overflow-hidden">{children}</div>
</div>
);
}
```
- [ ] **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 <Image fill /> in place of the placeholder below. */}
<motion.div variants={fadeInUp}>
<MarcoRetrato>
<div className="w-full h-full bg-azul-profundo grid place-items-center" role="img" aria-label="Retrato de la estratega — pendiente">
<PatronPuntos rows={4} cols={7} className="absolute inset-0 m-auto w-[150%] h-auto text-white/15" />
<span className="relative"><Wordmark size="sm" onDark /></span>
</div>
</MarcoRetrato>
</motion.div>
```
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 `<header>` (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('<ProgresoLectura />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<ProgresoLectura />);
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
});
it('renders as a gold rule anchored to the bottom edge', () => {
const { container } = render(<ProgresoLectura />);
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 (
<motion.div
aria-hidden="true"
className={`absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-dorado ${className}`}
style={{ scaleX: avance }}
/>
);
}
```
- [ ] **Step 4: Correr — debe pasar**
Run: `pnpm test components/primitives/__tests__/ProgresoLectura.test.tsx`
Expected: PASS (2 tests). En jsdom `matches` es `false`, así que se renderiza la barra.
- [ ] **Step 5: Escribir el test del header que falla**
Añadir a `components/layout/__tests__/Header.test.tsx`:
```tsx
it('shows reading progress', () => {
const { container } = render(<Header />);
expect(container.querySelector('.bg-dorado')).toBeInTheDocument();
});
it('starts roomy, before any scrolling has happened', () => {
const { container } = render(<Header />);
// The header compacts on scroll; at rest it must be in its tall state, or
// the wordmark would land already shrunken on first paint.
expect(container.querySelector('header')?.className).toContain('py-3');
});
```
Run: `pnpm test components/layout/__tests__/Header.test.tsx`
Expected: FAIL — no hay barra dorada.
- [ ] **Step 6: Reescribir el header**
`components/layout/Header.tsx` completo:
```tsx
'use client';
import { useState } from 'react';
import { useScroll, useMotionValueEvent } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark';
import { CTA } from '@/components/primitives/CTA';
import { Container } from '@/components/primitives/Container';
import { ProgresoLectura } from '@/components/primitives/ProgresoLectura';
import { header } from '@/lib/content';
// The header tightens once the reader leaves the hero: less chrome, more page.
// Client component because the compaction needs scroll state on the <header>
// itself; there is no server-rendered data here to lose.
export function Header() {
const [compacto, setCompacto] = useState(false);
const { scrollY } = useScroll();
useMotionValueEvent(scrollY, 'change', (y) => {
// Hysteresis: compact past 120px, expand back below 60px, so a header
// sitting exactly on the threshold doesn't flicker.
setCompacto((actual) => (actual ? y > 60 : y > 120));
});
return (
<header
className={`sticky top-0 z-50 relative border-b border-azul-profundo/10 bg-white/80 backdrop-blur transition-[padding] duration-300 motion-reduce:transition-none ${
compacto ? 'py-0' : 'py-3'
}`}
>
<Container className="flex items-center justify-between">
<a href="#top" aria-label="creeer — inicio" className="block transition-transform duration-300 origin-left motion-reduce:transition-none">
<Wordmark size={compacto ? 'sm' : 'md'} />
</a>
<nav aria-label="Principal">
<CTA href="#contacto" label={header.cta} variant="light" />
</nav>
</Container>
<ProgresoLectura />
</header>
);
}
```
Nota: el `py-3` se mueve del `Container` al `<header>` para que sea el elemento que anima el padding. Verifica que `Wordmark` acepte `size="sm"` y `size="md"` (lo hace: `components/primitives/Wordmark.tsx`).
- [ ] **Step 7: Correr la suite y commitear**
Run: `pnpm test`
Expected: PASS. En jsdom `scrollY` no cambia, así que el header se queda en su estado alto — que es justo lo que afirma el test.
```bash
git add components/primitives/ProgresoLectura.tsx components/primitives/__tests__/ProgresoLectura.test.tsx components/layout/Header.tsx components/layout/__tests__/Header.test.tsx
git commit -m "feat(header): progreso de lectura y compactado al bajar"
```
---
### Task 11: Ritmo de fondos y escala tipográfica
Dos cambios de fondo (para que no queden secciones iguales consecutivas) y dos niveles de titular, para que una declaración de marca no se vea igual que un encabezado de sección.
**Files:**
- Modify: `tailwind.config.ts`
- Modify: `components/sections/Estratega.tsx`, `ComoEmpezar.tsx`, `FraseAncla.tsx`, `Dolores.tsx`, `Cierre.tsx`, `PuntosInflexion.tsx`, `Disciplinas.tsx`, `Mecanismo.tsx`, `ParaQuien.tsx`
- Test: `components/sections/__tests__/` (existentes; deben seguir pasando sin cambios de copy)
**Interfaces:**
- Produces: clases `text-ancla` y `text-seccion`, consumidas por todas las secciones.
- [ ] **Step 1: Añadir la escala a Tailwind**
En `tailwind.config.ts`, dentro de `theme.extend`, junto a `maxWidth`:
```ts
fontSize: {
// Two title roles, so a brand statement never looks like a section
// heading. Fluid so they hold from 360px to desktop without a stack of
// breakpoint overrides.
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' }],
},
```
- [ ] **Step 2: Aplicar `text-ancla` a los tres momentos-ancla**
Sustituir en cada archivo la cadena de tamaño por `text-ancla`, conservando `font-sans font-black` y los colores:
- `components/sections/FraseAncla.tsx` — el `<motion.p>`: `text-4xl md:text-6xl leading-[0.95] tracking-tight` → `text-ancla`
- `components/sections/Dolores.tsx` — el `<motion.p>` del remate: `text-3xl md:text-5xl leading-[1.05] tracking-tight` → `text-ancla`
- `components/sections/Cierre.tsx` — el `<motion.p>` de la cita: `text-3xl md:text-5xl leading-[1.05] tracking-tight` → `text-ancla`
- [ ] **Step 3: Aplicar `text-seccion` a los encabezados de sección**
En cada `<h2>` de `PuntosInflexion`, `Dolores`, `Mecanismo`, `Disciplinas`, `ParaQuien`, `Estratega` y `ComoEmpezar`, sustituir `text-4xl md:text-6xl` (y el `leading-[0.95] tracking-tight` que lo acompañe) por `text-seccion`.
- [ ] **Step 4: Cambiar los dos fondos**
- `components/sections/Estratega.tsx`: `<section className="bg-white">` → `<section className="bg-gris-claro">`
- `components/sections/ComoEmpezar.tsx`: `<section className="bg-gris-claro">` → `<section className="bg-white">`
- [ ] **Step 5: Correr la suite completa**
Run: `pnpm test`
Expected: PASS — los tests verifican copy y contrato, no clases, así que ninguno debe romperse.
- [ ] **Step 6: Commit**
```bash
git add tailwind.config.ts components/sections/
git commit -m "feat(ritmo): dos niveles de titular y alternancia de fondos"
```
---
### Task 12: Rebalanceo final del movimiento
Retira el autoplay duplicado y la opacidad que deja ilegibles dos de cada tres pasos, y suma parallax suave a las dos fotos.
**Files:**
- Modify: `components/sections/ComoEmpezar.tsx`
- Modify: `components/sections/Mecanismo.tsx`
- Modify: `components/sections/__tests__/ComoEmpezar.test.tsx`
**Interfaces:**
- Consumes: `useScroll`, `useTransform` de `framer-motion`.
- [ ] **Step 1: Escribir el test de legibilidad que falla**
Añadir a `components/sections/__tests__/ComoEmpezar.test.tsx`:
```tsx
it('keeps all three steps readable without interaction', () => {
const { container } = render(<ComoEmpezar />);
// The steps used to sit at opacity-40 until the reader hovered or the
// autoplay reached them, which made two of every three illegible.
expect(container.innerHTML).not.toContain('opacity-40');
});
it('presents the steps as content, not as controls', () => {
render(<ComoEmpezar />);
// Without autoplay there is nothing to steer, so the steps stop being
// buttons and go back to being a plain ordered list.
expect(screen.queryAllByRole('button')).toHaveLength(0);
});
```
Asegúrate de que el archivo importe `screen` de `@testing-library/react`.
- [ ] **Step 2: Correr — debe fallar**
Run: `pnpm test components/sections/__tests__/ComoEmpezar.test.tsx`
Expected: FAIL — el HTML todavía contiene `opacity-40` y hay 3 botones.
- [ ] **Step 3: Quitar el autoplay de `ComoEmpezar`**
En `components/sections/ComoEmpezar.tsx`:
1. Quitar `useAutoStep` del import de `lib/motion` y borrar la línea `const { active, setActive, hoverProps } = useAutoStep(...)`.
2. Quitar `{...hoverProps}` del `<motion.ol>`.
3. Sustituir cada `<motion.li>` con `<button>` por contenido plano:
```tsx
<motion.li key={step.n} variants={fadeInUp}
className="border-t border-dorado/40 pt-6 transition-transform duration-300 ease-out hover:-translate-y-1 motion-reduce:transform-none motion-reduce:transition-none
md:flex md:items-baseline md:gap-4 md:border-t-0 md:border-l-2 md:pt-0 md:pl-5">
<div className="font-sans font-black text-5xl md:text-3xl text-dorado leading-none shrink-0">{step.n}</div>
<div>
<div className="mt-4 md:mt-0 flex items-center gap-3">
<Icono nombre={ICONOS[i]} className="w-6 h-6 text-dorado shrink-0" />
<h3 className="font-sans font-bold text-xl text-azul-profundo">{step.name}</h3>
</div>
<p className="mt-3 md:mt-1 text-gris-medio leading-relaxed">{step.desc}</p>
</div>
</motion.li>
```
- [ ] **Step 4: Correr — debe pasar**
Run: `pnpm test components/sections/__tests__/ComoEmpezar.test.tsx`
Expected: PASS.
- [ ] **Step 5: Quitar la opacidad de `Mecanismo` conservando su autoplay**
En `components/sections/Mecanismo.tsx`, en el `<button>` de cada paso (líneas 66-68), sustituir el realce por uno que no apague el resto:
```tsx
className={`w-full text-left border-t pt-6 transition-all duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none ${
active === i ? 'border-dorado md:-translate-y-1.5' : 'border-white/20'
}`}
```
`Mecanismo` conserva `useAutoStep`: es la única sección de la página que lo usa, y ahí el paso activo se distingue por borde y elevación, sin volver ilegibles los otros dos.
- [ ] **Step 6: Añadir parallax a las dos fotos**
**En `components/sections/ComoEmpezar.tsx`:**
Ampliar el import de framer-motion a `import { motion, useScroll, useTransform } from 'framer-motion';` y añadir dentro del componente, junto a `const reduced = ...`:
```tsx
// Page-level scroll (no target ref) keeps this to a single listener and
// avoids jsdom's "container has a static position" warning.
const { scrollYProgress } = useScroll();
const desplazamiento = useTransform(scrollYProgress, [0, 1], [20, -20]);
```
Y en el `motion.div` de la foto, añadir el `style`:
```tsx
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
style={reduced ? undefined : { y: desplazamiento }}
className="hidden md:block relative w-72 lg:w-96 aspect-[4/5] rounded-lg overflow-hidden shadow-2xl shadow-azul-profundo/20 rotate-2 motion-reduce:rotate-0">
```
**En `components/sections/Mecanismo.tsx`:**
`FondoFoto` no acepta `style`, así que el parallax se aplica a un envoltorio en vez de al componente. Ampliar el import a `import { motion, useScroll, useTransform } from 'framer-motion';`, añadir dentro del componente:
```tsx
const { scrollYProgress } = useScroll();
const desplazamientoFoto = useTransform(scrollYProgress, [0, 1], [-24, 24]);
```
Y envolver el `<FondoFoto>` (línea 18):
```tsx
{/* Foto: Golden Gate Bridge, Dietmar Rabich, CC BY-SA 4.0, modificada (duotono) — créditos en el footer */}
<motion.div
aria-hidden="true"
className="absolute inset-0 pointer-events-none"
style={reduced ? undefined : { y: desplazamientoFoto }}
>
<FondoFoto src="/images/mecanismo-bg.jpg" className="inset-0 opacity-25" wash="bg-azul-profundo/80" />
</motion.div>
```
El envoltorio se desplaza ±24px mientras la sección mide bastante más de alto, así que el borde de la foto nunca entra al encuadre.
- [ ] **Step 7: Verificación completa y commit**
Run: `pnpm test && pnpm lint && npx tsc --noEmit`
Expected: los tres en verde.
```bash
git add components/sections/ComoEmpezar.tsx components/sections/Mecanismo.tsx components/sections/__tests__/ComoEmpezar.test.tsx
git commit -m "feat(movimiento): un solo autoplay, tres pasos legibles y parallax en las fotos"
```
---
## Verificación final
- [ ] `pnpm test` — todos los tests en verde
- [ ] `pnpm lint` — sin warnings
- [ ] `npx tsc --noEmit` — sin errores
- [ ] `pnpm build` — el export estático compila
- [ ] `pnpm dev` y revisar a mano: (a) el hilo se dibuja al bajar; (b) la secuencia del hero **no** resuelve y la de `FraseAncla` **sí**; (c) ningún par de secciones consecutivas comparte fondo; (d) los tres pasos de `Mecanismo` y de `ComoEmpezar` se leen sin interactuar
- [ ] Con `prefers-reduced-motion: reduce` activado en el navegador: ninguna ilustración queda invisible, la barra de progreso desaparece y la página se lee completa
## Notas para quien ejecute
- **El orden importa poco salvo en dos puntos:** la Task 1 va antes que la 2, y la Task 11 define `text-seccion`/`text-ancla` que las Tasks 7 y 8 mencionan. Si ejecutas 7 u 8 antes que 11, conserva las clases de tamaño originales y sustitúyelas al llegar a la 11.
- **No inventes contenido.** El retrato de la dueña y los 2 casos de claridad estratégica siguen pendientes; no maquetes una sección de casos ni rellenes métricas.
- **Si algo del spec choca con el Manual de Identidad Visual, para y pregunta.** Hay una contradicción ya abierta sobre si el `≡` lleva espina vertical (`docs/dueña-directrices.md`), y no se resuelve en este plan.