Files
Creeer-Web/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md
T
erikghandClaude Opus 5 e76c492c3f docs(plan): extraer useEnVista antes de escribir las ilustraciones
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>
2026-07-31 10:54:51 -06:00

64 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. 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

// 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 SimboloTresLineas consuma 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 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

// 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';):

// 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:

  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
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

// 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 { 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
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

// 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:

  1. Borrar los imports de Carrusel, TarjetaSpotlight y de dolores; el import de hero se queda:
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
import { hero } from '@/lib/content';
  1. 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} />
  1. 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 { 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.

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 { 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:

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 { 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:

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-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:

      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
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:

  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:
              <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:

                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 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.