diff --git a/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md b/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md index a8707e3..27aef8e 100644 --- a/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md +++ b/docs/superpowers/plans/2026-07-31-hilo-conductor-visual.md @@ -144,6 +144,118 @@ 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; enVista: boolean; reduced: boolean }`. Lo consumen las Tasks 2, 6, 7 y 8. + +- [ ] **Step 1: Escribir el test que falla** + +```tsx +// lib/__tests__/motion.test.tsx +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useEnVista } from '@/lib/motion'; + +function Sonda() { + const { ref, enVista, reduced } = useEnVista(); + return ; +} + +describe('useEnVista', () => { + it('hands back a ref to attach, plus the two flags a drawing needs', () => { + const { container } = render(); + 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(); + // jsdom's IntersectionObserver stub never fires, so this also pins the + // safe default: nothing claims to be visible until the observer says so. + expect(container.querySelector('svg')).toHaveAttribute('data-en-vista', 'false'); + }); +}); +``` + +- [ ] **Step 2: Correr — debe fallar** + +Run: `pnpm test lib/__tests__/motion.test.tsx` +Expected: FAIL — `useEnVista` no está exportado. + +- [ ] **Step 3: Implementar el hook** + +Añadir a `lib/motion.ts` (y ampliar el import de React a `import { useEffect, useRef, useState } from 'react';`): + +```ts +// Shared "draw once when scrolled into view" wiring for the SVG illustrations. +// Returns the ref to attach, whether the element has been seen, and the +// reduced-motion flag the caller needs anyway to pick its render branch. +// Observes nothing under reduced motion — those branches render statically. +export function useEnVista(umbral = 0.4) { + const reduced = usePrefersReducedMotion(); + const ref = useRef(null); + const [enVista, setEnVista] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setEnVista(true); + observer.unobserve(el); + } + }, + { threshold: umbral }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [reduced, umbral]); + + return { ref, enVista, reduced }; +} +``` + +- [ ] **Step 4: Correr — debe pasar** + +Run: `pnpm test lib/__tests__/motion.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Migrar las dos ilustraciones existentes** + +En `components/primitives/IlustracionInflexion.tsx` y `components/primitives/SimboloTresLineas.tsx`, borrar el `useRef`/`useState`/`useEffect` del observer y sustituirlos por: + +```tsx + const { ref, enVista, reduced } = useEnVista(); +``` + +Ajustar los usos: donde decía `inView` ahora dice `enVista`; el `ref={ref}` del `` no cambia. Los imports de `useEffect`, `useRef` y `useState` de React se van; `usePrefersReducedMotion` también, porque el hook ya devuelve `reduced`. + +- [ ] **Step 6: Correr la suite completa** + +Run: `pnpm test` +Expected: PASS — los tests de ambas ilustraciones siguen verdes sin cambios (verifican trazos, no cableado). + +- [ ] **Step 7: Commit** + +```bash +git add lib/motion.ts lib/__tests__/motion.test.tsx components/primitives/IlustracionInflexion.tsx components/primitives/SimboloTresLineas.tsx +git commit -m "refactor(motion): extraer useEnVista, compartido por las ilustraciones" +``` + +--- + ### Task 2: `SecuenciaClaridad` — la pieza central Complejidad → Enfoque → Claridad. Dieciocho líneas cruzadas convergen; en `variant="completa"` resuelven en el `≡`. @@ -153,7 +265,7 @@ Complejidad → Enfoque → Claridad. Dieciocho líneas cruzadas convergen; en ` - Test: `components/primitives/__tests__/SecuenciaClaridad.test.tsx` **Interfaces:** -- Consumes: `LINEAS_SIMBOLO` de `lib/simbolo.ts` (Task 1); `usePrefersReducedMotion` de `lib/motion.ts`. +- 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** @@ -206,9 +318,8 @@ Expected: FAIL — `Failed to resolve import "../SecuenciaClaridad"`. // components/primitives/SecuenciaClaridad.tsx 'use client'; -import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; -import { usePrefersReducedMotion } from '@/lib/motion'; +import { useEnVista } from '@/lib/motion'; import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo'; // The manual's core metaphor, finally drawn: Complejidad → Enfoque → Claridad. @@ -256,25 +367,7 @@ export function SecuenciaClaridad({ variant?: Variant; className?: string; }) { - const reduced = usePrefersReducedMotion(); - const ref = useRef(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 { ref, enVista, reduced } = useEnVista(0.35); const completa = variant === 'completa'; @@ -349,7 +442,7 @@ export function SecuenciaClaridad({ stroke="currentColor" strokeLinecap="round" initial={reposo} - animate={inView ? { ...d, strokeWidth: grosor(i) } : reposo} + animate={enVista ? { ...d, strokeWidth: grosor(i) } : reposo} transition={{ duration: 1.2, delay: 0.25 + (i % 6) * 0.05, ease: EASE }} /> ); @@ -706,9 +799,8 @@ Expected: FAIL — import no resuelto. // components/primitives/IlustracionNudo.tsx 'use client'; -import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; -import { usePrefersReducedMotion } from '@/lib/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 @@ -722,25 +814,7 @@ const HEBRAS = [ ]; export function IlustracionNudo({ className = '' }: { className?: string }) { - const reduced = usePrefersReducedMotion(); - const ref = useRef(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 { ref, enVista, reduced } = useEnVista(); // Plain under reduced motion — motion.path would hide the drawing. if (reduced) { @@ -764,7 +838,7 @@ export function IlustracionNudo({ className = '' }: { className?: string }) { strokeWidth={h.ancho} strokeLinecap="round" initial={{ pathLength: 0 }} - animate={inView ? { pathLength: 1 } : { pathLength: 0 }} + animate={enVista ? { pathLength: 1 } : { pathLength: 0 }} transition={{ duration: 1.3, delay: i * 0.18, ease: [0.22, 1, 0.36, 1] }} /> ))} @@ -863,9 +937,8 @@ Expected: FAIL — import no resuelto. // components/primitives/IlustracionBifurcacion.tsx 'use client'; -import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; -import { usePrefersReducedMotion } from '@/lib/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 @@ -876,25 +949,7 @@ 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(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 { ref, enVista, reduced } = useEnVista(); const punto = ( <> @@ -917,7 +972,7 @@ export function IlustracionBifurcacion({ className = '' }: { className?: string const dibuja = (delay: number) => ({ initial: { pathLength: 0 }, - animate: inView ? { pathLength: 1 } : { pathLength: 0 }, + animate: enVista ? { pathLength: 1 } : { pathLength: 0 }, transition: { duration: 1.1, delay, ease: [0.22, 1, 0.36, 1] as const }, }); @@ -1016,9 +1071,8 @@ Expected: FAIL — import no resuelto. // components/primitives/IlustracionConvergencia.tsx 'use client'; -import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; -import { usePrefersReducedMotion } from '@/lib/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 @@ -1031,25 +1085,7 @@ const HEBRAS = [ const ESTRATEGIA = 'M120 60 L 214 60'; export function IlustracionConvergencia({ className = '' }: { className?: string }) { - const reduced = usePrefersReducedMotion(); - const ref = useRef(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 { ref, enVista, reduced } = useEnVista(); // Plain under reduced motion — motion.path would hide the drawing. if (reduced) { @@ -1065,7 +1101,7 @@ export function IlustracionConvergencia({ className = '' }: { className?: string const dibuja = (delay: number) => ({ initial: { pathLength: 0 }, - animate: inView ? { pathLength: 1 } : { pathLength: 0 }, + animate: enVista ? { pathLength: 1 } : { pathLength: 0 }, transition: { duration: 1, delay, ease: [0.22, 1, 0.36, 1] as const }, });