Files
Creeer-Web/docs/superpowers/plans/2026-08-21-animacion-tres-lineas.md
T
erikghandClaude Opus 5.5 89e54a6e84 feat: rediseño «El documento» en curso, contacto real y enlaces externos
Primitivas y contenido del rediseño (escala tipográfica, Revelar,
MarcaSeccion, Seccion, Antetitulo, SimboloMarca, Resplandor, ClaridadHero,
sección Proceso de ocho etapas, lib/content.ts reestructurado en seis
secciones con alias para las viejas). Mecanismo y FormaTrabajar se retiran.

Encima, lo de esta sesión:
- recorrido: creeer vuelve a responder cada situación en vez de repetirla
  (el alias apuntaba respuesta a situacion); respuestas en plural. Créditos
  de las seis fotos de México.
- contacto: WhatsApp, email (con mailto:) y LinkedIn reales; iconos de las
  redes en el cierre y el footer (IconoRed, trazos de Simple Icons, CC0).
- enlaces: lo externo abre en otra pestaña con noopener y aviso para
  lectores de pantalla (lib/enlaces.ts); mailto y anclas se quedan.
- Proceso: las ocho tarjetas no aparecían nunca en móvil (amount 0.3 sobre
  una lista de ~3000px); ahora cada una se revela sola. El ≡ de la 08 ya no
  tapa un entregable, título equilibrado, etiqueta a 12px, sin elevar al
  hover lo que no es clicable.
- Disciplinas: la frase «No son la promesa — son los medios» gana peso y
  se agrupa con título e ilustración; sin cortes huérfanos en móvil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:27:20 -06:00

13 KiB
Raw Blame History

«Las Tres Líneas» — 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: Reemplazar la escena Three.js del hero ("Astrolabio") por «Las Tres Líneas»: tres barras doradas que pasan de caos → ≡ → escalera → crecimiento, con reposo vivo.

Architecture: Solo se reescribe el interior de components/primitives/EscenaClaridad.tsx (misma ruta, misma API className, mismo export). El wrapper (IntersectionObserver, prefers-reduced-motion, SiHayWebGL, props del Canvas) queda idéntico. ClaridadHero.tsx, Hero.tsx y las 4 pruebas existentes no se tocan.

Tech Stack: Next.js 14 (App Router, output: export), three@0.185, @react-three/fiber@8, TypeScript, Vitest.

Spec: docs/superpowers/specs/2026-08-21-animacion-tres-lineas-design.md

Global Constraints

  • Gestor de paquetes: pnpm (nunca npm). Comandos: pnpm test, pnpm lint, npx tsc --noEmit, pnpm build.
  • Comentarios de código en inglés; copy/docs/commits en español.
  • Paleta: dorado #C4942E (la escena no usa azul — ya no hay anillos ni sombra).
  • Staging del repo tiene cambios ajenos staged (deletes de FormaTrabajar/Mecanismo, rename CLAUDE.md). Al commitear usa rutas explícitas (git add <archivo>), nunca git add -A / git add ..
  • Commits de esta implementación aprobados por el usuario en la rama animacion-tres-lineas.
  • prefers-reduced-motion debe montar la pose final directamente (patrón estatico existente).

Fuente de verdad de la coreografía

Los parámetros aprobados son los del preview del visual companion (elección de Erik, concepto A). Nota: el geometry es centrado (BoxGeometry(2.3, 0.3, 0.3)) y crece simétricamente en X — así se ve en el preview aprobado; ignora la mención a "origen en extremo" heredada del código viejo en una frase del spec.


Task 1: Reescribir EscenaClaridad.tsx con «Las Tres Líneas»

Files:

  • Modify: components/primitives/EscenaClaridad.tsx (reescritura completa del interior)
  • Test (sin edits): components/primitives/__tests__/EscenaClaridad.test.tsx

Interfaces:

  • Consumes: usePrefersReducedMotion de @/lib/motion (ya usada), Canvas/useFrame/useThree de @react-three/fiber.

  • Produces: export function EscenaClaridad({ className = '' }: { className?: string }) — firma idéntica a la actual. ClaridadHero y los tests la consumen sin cambios.

  • Step 1: Baseline — las 4 pruebas pasan hoy

Run: pnpm test components/primitives/__tests__/EscenaClaridad.test.tsx Expected: 4 passed (aria-hidden, tamaño del caller, frameloop always, frameloop demand).

  • Step 2: Reemplazar el archivo completo por la nueva escena

Escribir en components/primitives/EscenaClaridad.tsx:

'use client';

import { Component, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { usePrefersReducedMotion } from '@/lib/motion';

// VISUAL CONCEPT: "Las Tres Líneas" — the brand symbol tells the story.
// 1. Caos: three golden bars drift tangled, each on its own frequencies.
// 2. Claridad: they settle into the "≡" of cre≡er — level, exact, still.
// 3. Dirección: together they tilt ~9° and stagger into a staircase.
// 4. Crecimiento: each line extends forward (the top one the most) with a
//    rising golden glow. The scene then rests, barely alive: a slow camera
//    drift and an almost imperceptible emissive pulse. No loops, no resets.

const DORADO = new THREE.Color('#C4942E');

const suave = (t: number) => 1 - Math.pow(1 - t, 3);
const tramo = (t: number, desde: number, dura: number) =>
  Math.min(1, Math.max(0, (t - desde) / dura));

// Phase timings (seconds), verbatim from the approved preview.
const CAOS_DURA = 3.0;
const CLARIDAD = { desde: 3.0, dura: 2.2 };
const DIRECCION = { desde: 5.2, dura: 2.0 };
const CRECIMIENTO = { desde: 7.2, dura: 2.4 };

// ── Las Tres Líneas ─────────────────────────────────────────────────────────

function TresLineas({ estatico }: { estatico: boolean }) {
  const time = useRef(estatico ? 10 : 0);
  const m1 = useRef<THREE.Mesh>(null);
  const m2 = useRef<THREE.Mesh>(null);
  const m3 = useRef<THREE.Mesh>(null);
  const meshes = [m1, m2, m3];

  // Decoupled drift frequencies per bar, so the chaos never syncs.
  const deriva = useMemo(
    () => [
      { fx: 1.1, fy: 0.9, fz: 0.7, rx: 0.8, ry: 1.1 },
      { fx: 1.47, fy: 1.4, fz: 1.4, rx: 1.1, ry: 0.9 },
      { fx: 1.84, fy: 1.9, fz: 0.7, rx: 1.4, ry: 0.7 },
    ],
    [],
  );

  const geometria = useMemo(() => new THREE.BoxGeometry(2.3, 0.3, 0.3), []);
  const materiales = useMemo(
    () =>
      [0, 1, 2].map(
        () =>
          new THREE.MeshStandardMaterial({
            color: DORADO,
            metalness: 0.8,
            roughness: 0.25,
            emissive: DORADO,
            emissiveIntensity: 0,
          }),
      ),
    [],
  );

  // Final poses: the "≡" (identity rotation) and the tilted staircase.
  const qSimbolo = useMemo(() => new THREE.Quaternion().identity(), []);
  const qEscalera = useMemo(
    () => new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, -0.16)),
    [],
  );

  useFrame((_, delta) => {
    if (!estatico) time.current += delta;
    const t = time.current;

    const claridad = suave(tramo(t, CLARIDAD.desde, CLARIDAD.dura));
    const direccion = suave(tramo(t, DIRECCION.desde, DIRECCION.dura));
    const crecimiento = suave(tramo(t, CRECIMIENTO.desde, CRECIMIENTO.dura));
    // "Reposo vivo": a slow emissive breathing once the story is told.
    const pulso = Math.sin(t * 0.8) * 0.03 * crecimiento;

    meshes.forEach((ref, i) => {
      const mesh = ref.current;
      if (!mesh) return;
      const d = deriva[i];

      // Caos: free drift around the frame.
      const posCaos = new THREE.Vector3(
        Math.sin(t * d.fx + i * 2.1) * 1.6,
        Math.cos(t * d.fy + i) * 1.1 + 0.7,
        Math.sin(t * d.fz + i * 3) * 0.9,
      );
      const rotCaos = new THREE.Quaternion().setFromEuler(
        new THREE.Euler(t * d.rx, t * (d.ry - i * 0.2), t * 0.5),
      );

      // Claridad: the "≡" of the wordmark.
      const posSimbolo = new THREE.Vector3(0, 0.7 - i * 0.7, 0);
      // Dirección: the ascending staircase.
      const posEscalera = new THREE.Vector3(i * 1.15 - 1.15, 0.35 + i * 0.8, 0);

      mesh.position.copy(
        posCaos.lerp(posSimbolo, claridad).lerp(posEscalera, direccion),
      );
      mesh.quaternion.slerpQuaternions(rotCaos, qSimbolo, claridad);
      mesh.quaternion.slerp(qEscalera, direccion);

      // Crecimiento: each line extends, the top one the most.
      mesh.scale.set(
        1 + direccion * 0.1 + crecimiento * (0.35 + i * 0.35),
        1,
        1,
      );

      materiales[i].emissiveIntensity = crecimiento * (0.1 + i * 0.06) + pulso;
    });
  });

  return (
    <group>
      {meshes.map((ref, i) => (
        <mesh key={i} ref={ref} geometry={geometria} material={materiales[i]} />
      ))}
    </group>
  );
}

// ── Cámara — frontal, with the preview's gentle drift ───────────────────────

const CAMARA: [number, number, number] = [0, 0.9, 8];
const MIRA: [number, number, number] = [0, 0.7, 0];

function Camara({ estatico }: { estatico: boolean }) {
  const { camera, invalidate } = useThree();
  const time = useRef(estatico ? 10 : 0);

  useEffect(() => {
    camera.position.set(...CAMARA);
    camera.lookAt(...MIRA);
    invalidate();
  }, [camera, invalidate]);

  useFrame((_, delta) => {
    if (estatico) return;
    time.current += delta;
    camera.position.set(
      CAMARA[0] + Math.sin(time.current * 0.25) * 0.5,
      CAMARA[1],
      CAMARA[2],
    );
    camera.lookAt(...MIRA);
  });

  return null;
}

// ── Iluminación — the three lights of the approved preview ──────────────────

function Luces() {
  return (
    <>
      <ambientLight intensity={1.4} color="#ffffff" />
      <directionalLight position={[4, 8, 5]} intensity={2.2} color="#ffffff" />
      <pointLight position={[0, 2, 4]} intensity={10} distance={25} color={DORADO} />
    </>
  );
}

// ── WebGL Guard ─────────────────────────────────────────────────────────────

class SiHayWebGL extends Component<{ children: ReactNode }, { falla: boolean }> {
  state = { falla: false };
  static getDerivedStateFromError() { return { falla: true }; }
  render() { return this.state.falla ? null : this.props.children; }
}

// ── El Componente ───────────────────────────────────────────────────────────

export function EscenaClaridad({ className = '' }: { className?: string }) {
  const reducido = usePrefersReducedMotion();
  const contenedor = useRef<HTMLDivElement>(null);
  const [enVista, setEnVista] = useState(true);

  useEffect(() => {
    const el = contenedor.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const obs = new IntersectionObserver(([entrada]) => setEnVista(entrada.isIntersecting));
    obs.observe(el);
    return () => obs.disconnect();
  }, []);

  const animando = enVista && !reducido;

  return (
    <div ref={contenedor} className={className} aria-hidden="true">
      <SiHayWebGL>
        <Canvas
          dpr={[1, 2]}
          frameloop={animando ? 'always' : 'demand'}
          camera={{ position: CAMARA, fov: 38, near: 0.1, far: 100 }}
          gl={{ antialias: true, alpha: true, powerPreference: 'low-power' }}
          style={{ background: 'transparent' }}
        >
          <Camara estatico={reducido} />
          <Luces />
          <TresLineas estatico={reducido} />
        </Canvas>
      </SiHayWebGL>
    </div>
  );
}

Diferencias clave vs. el archivo viejo: fuera Anillos, Simbolo, Suelo/texturaSombra y la pointLight azul; el fov pasa de 40 a 38 y far de 60 a 100 (los del preview); Camara recibe estatico para congelar la deriva en reduced-motion.

  • Step 3: Correr las pruebas del componente

Run: pnpm test components/primitives/__tests__/EscenaClaridad.test.tsx Expected: 4 passed — el wrapper no cambió de contrato.

  • Step 4: Commit
git add components/primitives/EscenaClaridad.tsx
git commit -m "feat(hero): el astrolabio deja paso a «Las Tres Líneas» — caos, ≡, escalera, crecimiento"

Task 2: Verificación completa y ajuste de luces

Files:

  • Modify (solo si hace falta): components/primitives/EscenaClaridad.tsx

Interfaces:

  • Consumes: Task 1 completo.

  • Produces: nada nuevo — verificación.

  • Step 1: Typecheck y lint

Run: npx tsc --noEmit && pnpm lint Expected: sin errores. Si three@0.185 tipa distinto algún prop de luces, ajusta el valor, no el tipo.

  • Step 2: Suite completa

Run: pnpm test Expected: todos pasan (incl. content/contraste/escala, intactos).

  • Step 3: Build de export estático

Run: pnpm build Expected: export exitoso a out/ sin errores de prerender.

  • Step 4: Humo visual

Run: pnpm dev → abrir http://localhost:3000, dejar correr el relato completo (~10 s) y verificar: (a) las tres barras forman el ≡, (b) se vuelven escalera ascendente, (c) crecen con brillo, (d) reposo con deriva sutil sin reiniciar, (e) con DevTools → rendering → emulate prefers-reduced-motion, la escena monta directo en la escalera, quieta.

Nota de luces: el preview corrió en three 0.160; en 0.185 la caída física de la pointLight es igual pero si el dorado se ve apagado o quemado, ajusta intensity (rango sano 6–16) en Luces — es calibración, no cambio de concepto.

  • Step 5: Commit (solo si hubo ajuste)
git add components/primitives/EscenaClaridad.tsx
git commit -m "fix(hero): calibración de luces de «Las Tres Líneas» para three 0.185"

Si no hubo ajuste, este paso se omite.


Self-Review

  • Cobertura del spec: concepto/tabla de fases → Task 1 Step 2 (CAOS_DURA, CLARIDAD, DIRECCION, CRECIMIENTO, pulso, deriva de cámara); reposo vivo → pulso + drift de Camara; reduced-motion → estatico en Camara/TresLineas + frameloop: demand; fuera de vista → IntersectionObserver intacto; eliminaciones (Anillos/Suelo/pointLight azul) → código nuevo no los contiene; pruebas intactas → Steps 1/3; commit en rama → aprobado. ✔
  • Placeholders: ninguno — todo paso tiene código o comando exacto. ✔
  • Consistencia de tipos: EscenaClaridad({ className }: { className?: string }) idéntico al consumido por ClaridadHero.tsx:54 y por los tests. Camara/TresLineas comparten la prop estatico: boolean. ✔