Files
Creeer-Web/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md
T
erikghandClaude Opus 5 9d544a3ebb docs(plan): implementación del hilo conductor en 12 tareas
Cada tarea trae test primero, código completo y commit propio.

En el self-review salieron tres cosas que se corrigieron en el plan: el
spread de las líneas dispersas mandaba `barra` al DOM como atributo SVG
inválido; el paso de parallax decía "mismo tratamiento para Mecanismo" en
vez de mostrar el código (FondoFoto no acepta style, necesita envoltorio);
y el compactado del header que pide el spec no tenía tarea asignada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 10:51:57 -06:00

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. 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 2: SecuenciaClaridad — la pieza central

Complejidad → Enfoque → Claridad. Dieciocho líneas cruzadas convergen; en variant="completa" resuelven en el ≡.

Files:

  • Create: components/primitives/SecuenciaClaridad.tsx
  • Test: components/primitives/__tests__/SecuenciaClaridad.test.tsx

Interfaces:

  • Consumes: LINEAS_SIMBOLO de lib/simbolo.ts (Task 1); usePrefersReducedMotion de lib/motion.ts.

  • Produces: SecuenciaClaridad({ variant?: 'completa' | 'hero'; className?: string }). Por defecto variant='completa'. Usada por Hero (Task 4) y FraseAncla (Task 5).

  • Step 1: Escribir el test que falla

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