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>
1620 lines
62 KiB
Markdown
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.
|