El scan previo a la ejecución encontró que el plan mandaba repetir el bloque del IntersectionObserver en las cuatro ilustraciones nuevas, sobre las dos copias que ya existen. Se añade la Task 1b, que lo extrae a un hook y migra las dos existentes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1656 lines
64 KiB
Markdown
1656 lines
64 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 1b: Hook `useEnVista`
|
|
|
|
El bloque "observa el elemento, marca `inView` una vez, desconecta" está hoy duplicado literal en `IlustracionInflexion.tsx:17-31` y `SimboloTresLineas.tsx:30-44`. Las cuatro ilustraciones de este plan lo necesitan también: sin extraerlo quedarían seis copias idénticas. Se extrae **antes** de escribirlas.
|
|
|
|
**Files:**
|
|
- Modify: `lib/motion.ts`
|
|
- Modify: `components/primitives/IlustracionInflexion.tsx:17-31`
|
|
- Modify: `components/primitives/SimboloTresLineas.tsx:30-44`
|
|
- Test: `lib/__tests__/motion.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `usePrefersReducedMotion` (ya en `lib/motion.ts`).
|
|
- Produces: `useEnVista(umbral?: number): { ref: RefObject<SVGSVGElement>; enVista: boolean; reduced: boolean }`. Lo consumen las Tasks 2, 6, 7 y 8.
|
|
|
|
- [ ] **Step 1: Escribir el test que falla**
|
|
|
|
```tsx
|
|
// lib/__tests__/motion.test.tsx
|
|
import { render } from '@testing-library/react';
|
|
import { describe, it, expect } from 'vitest';
|
|
import { useEnVista } from '@/lib/motion';
|
|
|
|
function Sonda() {
|
|
const { ref, enVista, reduced } = useEnVista();
|
|
return <svg ref={ref} data-en-vista={String(enVista)} data-reducido={String(reduced)} />;
|
|
}
|
|
|
|
describe('useEnVista', () => {
|
|
it('hands back a ref to attach, plus the two flags a drawing needs', () => {
|
|
const { container } = render(<Sonda />);
|
|
const svg = container.querySelector('svg');
|
|
expect(svg).toHaveAttribute('data-en-vista');
|
|
expect(svg).toHaveAttribute('data-reducido');
|
|
});
|
|
|
|
it('starts out not-yet-in-view so drawings do not skip their entrance', () => {
|
|
const { container } = render(<Sonda />);
|
|
// jsdom's IntersectionObserver stub never fires, so this also pins the
|
|
// safe default: nothing claims to be visible until the observer says so.
|
|
expect(container.querySelector('svg')).toHaveAttribute('data-en-vista', 'false');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Correr — debe fallar**
|
|
|
|
Run: `pnpm test lib/__tests__/motion.test.tsx`
|
|
Expected: FAIL — `useEnVista` no está exportado.
|
|
|
|
- [ ] **Step 3: Implementar el hook**
|
|
|
|
Añadir a `lib/motion.ts` (y ampliar el import de React a `import { useEffect, useRef, useState } from 'react';`):
|
|
|
|
```ts
|
|
// Shared "draw once when scrolled into view" wiring for the SVG illustrations.
|
|
// Returns the ref to attach, whether the element has been seen, and the
|
|
// reduced-motion flag the caller needs anyway to pick its render branch.
|
|
// Observes nothing under reduced motion — those branches render statically.
|
|
export function useEnVista(umbral = 0.4) {
|
|
const reduced = usePrefersReducedMotion();
|
|
const ref = useRef<SVGSVGElement>(null);
|
|
const [enVista, setEnVista] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el || reduced) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
setEnVista(true);
|
|
observer.unobserve(el);
|
|
}
|
|
},
|
|
{ threshold: umbral },
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [reduced, umbral]);
|
|
|
|
return { ref, enVista, reduced };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Correr — debe pasar**
|
|
|
|
Run: `pnpm test lib/__tests__/motion.test.tsx`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 5: Migrar las dos ilustraciones existentes**
|
|
|
|
En `components/primitives/IlustracionInflexion.tsx` y `components/primitives/SimboloTresLineas.tsx`, borrar el `useRef`/`useState`/`useEffect` del observer y sustituirlos por:
|
|
|
|
```tsx
|
|
const { ref, enVista, reduced } = useEnVista();
|
|
```
|
|
|
|
Ajustar los usos: donde decía `inView` ahora dice `enVista`; el `ref={ref}` del `<svg>` no cambia. Los imports de `useEffect`, `useRef` y `useState` de React se van; `usePrefersReducedMotion` también, porque el hook ya devuelve `reduced`.
|
|
|
|
- [ ] **Step 6: Correr la suite completa**
|
|
|
|
Run: `pnpm test`
|
|
Expected: PASS — los tests de ambas ilustraciones siguen verdes sin cambios (verifican trazos, no cableado).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add lib/motion.ts lib/__tests__/motion.test.tsx components/primitives/IlustracionInflexion.tsx components/primitives/SimboloTresLineas.tsx
|
|
git commit -m "refactor(motion): extraer useEnVista, compartido por las ilustraciones"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: `SecuenciaClaridad` — la pieza central
|
|
|
|
Complejidad → Enfoque → Claridad. Dieciocho líneas cruzadas convergen; en `variant="completa"` resuelven en el `≡`.
|
|
|
|
**Files:**
|
|
- Create: `components/primitives/SecuenciaClaridad.tsx`
|
|
- Test: `components/primitives/__tests__/SecuenciaClaridad.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `LINEAS_SIMBOLO` de `lib/simbolo.ts` (Task 1); `useEnVista` de `lib/motion.ts` (Task 1b).
|
|
- Produces: `SecuenciaClaridad({ variant?: 'completa' | 'hero'; className?: string })`. Por defecto `variant='completa'`. Usada por `Hero` (Task 4) y `FraseAncla` (Task 5).
|
|
|
|
- [ ] **Step 1: Escribir el test que falla**
|
|
|
|
```tsx
|
|
// components/primitives/__tests__/SecuenciaClaridad.test.tsx
|
|
import { render } from '@testing-library/react';
|
|
import { describe, it, expect } from 'vitest';
|
|
import { SecuenciaClaridad } from '../SecuenciaClaridad';
|
|
|
|
describe('<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 { motion } from 'framer-motion';
|
|
import { useEnVista } from '@/lib/motion';
|
|
import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo';
|
|
|
|
// The manual's core metaphor, finally drawn: Complejidad → Enfoque → Claridad.
|
|
// Eighteen crossing lines converge in three groups; in the "completa" variant
|
|
// each group collapses onto one of the three bars of the ≡, so the mark is
|
|
// literally produced by the resolution of the tangle rather than placed next
|
|
// to it. The "hero" variant stops at convergence and leaves the tension open —
|
|
// FraseAncla pays it off further down the page.
|
|
|
|
type Variant = 'completa' | 'hero';
|
|
|
|
// Scattered start positions. Hand-placed rather than random so the tangle is
|
|
// identical on every render and between server and client.
|
|
const DISPERSAS = [
|
|
{ x1: 2, y1: 8, x2: 40, y2: 3, barra: 0 },
|
|
{ x1: 20, y1: 2, x2: 61, y2: 24, barra: 0 },
|
|
{ x1: 6, y1: 22, x2: 52, y2: 6, barra: 0 },
|
|
{ x1: 34, y1: 5, x2: 12, y2: 27, barra: 0 },
|
|
{ x1: 48, y1: 2, x2: 58, y2: 30, barra: 0 },
|
|
{ x1: 3, y1: 15, x2: 46, y2: 19, barra: 0 },
|
|
{ x1: 5, y1: 30, x2: 60, y2: 26, barra: 1 },
|
|
{ x1: 14, y1: 40, x2: 55, y2: 20, barra: 1 },
|
|
{ x1: 2, y1: 26, x2: 38, y2: 44, barra: 1 },
|
|
{ x1: 44, y1: 22, x2: 18, y2: 41, barra: 1 },
|
|
{ x1: 30, y1: 18, x2: 62, y2: 40, barra: 1 },
|
|
{ x1: 8, y1: 36, x2: 50, y2: 33, barra: 1 },
|
|
{ x1: 4, y1: 44, x2: 47, y2: 58, barra: 2 },
|
|
{ x1: 24, y1: 62, x2: 60, y2: 42, barra: 2 },
|
|
{ x1: 10, y1: 56, x2: 56, y2: 50, barra: 2 },
|
|
{ x1: 40, y1: 61, x2: 14, y2: 46, barra: 2 },
|
|
{ x1: 2, y1: 50, x2: 44, y2: 62, barra: 2 },
|
|
{ x1: 33, y1: 44, x2: 62, y2: 57, barra: 2 },
|
|
];
|
|
|
|
// One line per group survives to become a bar of the ≡; the rest fade as they
|
|
// converge — many/disordered becomes few/aligned, per the manual.
|
|
const SOBREVIVE = [0, 6, 12];
|
|
|
|
const EASE = [0.22, 1, 0.36, 1] as const;
|
|
|
|
export function SecuenciaClaridad({
|
|
variant = 'completa',
|
|
className = '',
|
|
}: {
|
|
variant?: Variant;
|
|
className?: string;
|
|
}) {
|
|
const { ref, enVista, reduced } = useEnVista(0.35);
|
|
|
|
const completa = variant === 'completa';
|
|
|
|
// Where each line ends up. In "completa" the survivors land exactly on the
|
|
// symbol's own coordinates; in "hero" they merely tighten toward their
|
|
// group's axis without ever aligning.
|
|
const destino = (i: number) => {
|
|
const { barra } = DISPERSAS[i];
|
|
const linea = LINEAS_SIMBOLO[barra];
|
|
const sobrevive = SOBREVIVE.includes(i);
|
|
if (!completa) {
|
|
const eje = linea.y1;
|
|
const d = DISPERSAS[i];
|
|
return {
|
|
x1: d.x1 + (26 - d.x1) * 0.35,
|
|
y1: d.y1 + (eje - d.y1) * 0.55,
|
|
x2: d.x2 + (38 - d.x2) * 0.35,
|
|
y2: d.y2 + (eje - d.y2) * 0.55,
|
|
opacity: sobrevive ? 0.95 : 0.3,
|
|
};
|
|
}
|
|
return {
|
|
x1: linea.x1,
|
|
y1: linea.y1,
|
|
x2: linea.x2,
|
|
y2: linea.y2,
|
|
opacity: sobrevive ? 1 : 0,
|
|
};
|
|
};
|
|
|
|
const grosor = (i: number) => (completa && SOBREVIVE.includes(i) ? LINEAS_SIMBOLO[0].grosor : 1.5);
|
|
|
|
// Reduced motion renders plain <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={enVista ? { ...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 { motion } from 'framer-motion';
|
|
import { useEnVista } from '@/lib/motion';
|
|
|
|
// Four strands looping over each other into a knot that never opens — the
|
|
// section's own subject drawn: effort that folds back on itself instead of
|
|
// going anywhere. The strands draw themselves in sequence, which reads as the
|
|
// knot tightening rather than as decoration appearing.
|
|
const HEBRAS = [
|
|
{ d: 'M30 90 C 60 20, 130 20, 140 70 C 148 112, 78 118, 70 78 C 63 44, 120 38, 150 66', ancho: 2.5 },
|
|
{ d: 'M20 60 C 70 110, 120 110, 160 60', ancho: 2 },
|
|
{ d: 'M40 110 C 80 40, 110 130, 155 95', ancho: 2 },
|
|
{ d: 'M25 40 C 90 70, 100 100, 165 40', ancho: 1.5 },
|
|
];
|
|
|
|
export function IlustracionNudo({ className = '' }: { className?: string }) {
|
|
const { ref, enVista, reduced } = useEnVista();
|
|
|
|
// Plain <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={enVista ? { 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 { motion } from 'framer-motion';
|
|
import { useEnVista } from '@/lib/motion';
|
|
|
|
// A shared trunk splitting at a marked decision point: one route climbs, gold
|
|
// and continuous; the other flattens, grey and dashed. It draws the section's
|
|
// own bifurcation — "sí es para ti / no es para ti" — without illustrating a
|
|
// judgement about the reader.
|
|
const TRONCO = 'M10 100 C 50 96, 80 84, 110 70';
|
|
const RUTA_SI = 'M110 70 C 145 58, 175 30, 210 18';
|
|
const RUTA_NO = 'M110 70 C 145 82, 175 92, 210 90';
|
|
|
|
export function IlustracionBifurcacion({ className = '' }: { className?: string }) {
|
|
const { ref, enVista, reduced } = useEnVista();
|
|
|
|
const punto = (
|
|
<>
|
|
<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: enVista ? { 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 { motion } from 'framer-motion';
|
|
import { useEnVista } from '@/lib/motion';
|
|
|
|
// Three strands — comunicación, digital, experiencias — meeting and leaving as
|
|
// a single gold line. The section's own lead, drawn: "No son la promesa, son
|
|
// los medios." The three are grey; only what they produce is gold.
|
|
const HEBRAS = [
|
|
'M6 18 C 60 20, 90 52, 120 60',
|
|
'M6 60 C 60 60, 90 60, 120 60',
|
|
'M6 102 C 60 100, 90 68, 120 60',
|
|
];
|
|
const ESTRATEGIA = 'M120 60 L 214 60';
|
|
|
|
export function IlustracionConvergencia({ className = '' }: { className?: string }) {
|
|
const { ref, enVista, reduced } = useEnVista();
|
|
|
|
// Plain <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: enVista ? { 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.
|