From e76c492c3f661dd4f39907a5956eee14c9f98db1 Mon Sep 17 00:00:00 2001 From: erikgh Date: Fri, 31 Jul 2026 10:54:51 -0600 Subject: [PATCH] docs(plan): extraer useEnVista antes de escribir las ilustraciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plans/2026-07-31-hilo-conductor-visual.md | 214 ++++++++++-------- 1 file changed, 125 insertions(+), 89 deletions(-) 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 }, });