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>
62 KiB
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. Nuncanpm. - 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()delib/motion.ts. Los componentes de sección combinan ademásmotionProps(reduced). - Regla del
motion.path/motion.line: aplican su propio andamiaje dedasharrayaunque no recibaninitial/animate, lo que deja los trazos invisibles para usuarios de reduced motion. La rama reducida SIEMPRE renderiza elementos SVG planos (<path>,<line>,<rect>), nuncamotion.*neutralizados. Este bug ya mordió encomponents/primitives/IlustracionInflexion.tsx:37— ahí está documentado. - Ilustraciones decorativas:
aria-hidden="true"yfocusable="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≡erva 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 testen verde antes de cada commit. Al final del plan, además:pnpm lintynpx 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
// 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
// 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
SimboloTresLineasconsuma el módulo
En components/primitives/SimboloTresLineas.tsx, sustituir la constante local FINAL (líneas 11-15) por el import:
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
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_SIMBOLOdelib/simbolo.ts(Task 1);usePrefersReducedMotiondelib/motion.ts. -
Produces:
SecuenciaClaridad({ variant?: 'completa' | 'hero'; className?: string }). Por defectovariant='completa'. Usada porHero(Task 4) yFraseAncla(Task 5). -
Step 1: Escribir el test que falla
// 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
// 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
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,motiondeframer-motion. -
Produces:
HiloConductor({ className?: string }). Se monta una sola vez enapp/page.tsx. -
Step 1: Escribir el test que falla
// 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
// 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:
import { HiloConductor } from '@/components/primitives/HiloConductor';
<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.
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:
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:
- Borrar los imports de
Carrusel,TarjetaSpotlighty dedolores; el import deherose queda:
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
import { hero } from '@/lib/content';
- Dejar una sola capa de puntos interactiva (línea 27 se queda, línea 28 pierde
interactivo):
<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} />
- Sustituir el bloque completo del carrusel (líneas 53-69) por:
{/* 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
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:
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:
<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
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
// 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
// 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.
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
Cambiar la apertura de la sección y el Container:
<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.
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
// 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
// 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:
import { IlustracionBifurcacion } from '@/components/primitives/IlustracionBifurcacion';
<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.
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
// 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
// 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:
import { IlustracionConvergencia } from '@/components/primitives/IlustracionConvergencia';
<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.
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
// 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
// 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:
{/* 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.
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
// 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
// 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:
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:
'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.
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-anclaytext-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:
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-anclaa 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-secciona 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
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,useTransformdeframer-motion. -
Step 1: Escribir el test de legibilidad que falla
Añadir a components/sections/__tests__/ComoEmpezar.test.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:
- Quitar
useAutoStepdel import delib/motiony borrar la líneaconst { active, setActive, hoverProps } = useAutoStep(...). - Quitar
{...hoverProps}del<motion.ol>. - Sustituir cada
<motion.li>con<button>por contenido plano:
<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
Mecanismoconservando 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:
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 = ...:
// 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:
<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:
const { scrollYProgress } = useScroll();
const desplazamientoFoto = useTransform(scrollYProgress, [0, 1], [-24, 24]);
Y envolver el <FondoFoto> (línea 18):
{/* 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.
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 verdepnpm lint— sin warningsnpx tsc --noEmit— sin errorespnpm build— el export estático compilapnpm devy revisar a mano: (a) el hilo se dibuja al bajar; (b) la secuencia del hero no resuelve y la deFraseAnclasí; (c) ningún par de secciones consecutivas comparte fondo; (d) los tres pasos deMecanismoy deComoEmpezarse leen sin interactuar- Con
prefers-reduced-motion: reduceactivado 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-anclaque 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.