Files
Creeer-Web/components/primitives/HiloConductor.tsx
T
erikghandClaude Opus 5 3c2185e200 fix(hilo): la banda recupera su grosor y remata en el ≡
El grosor nunca se decidió: la primera versión ocupaba toda la columna,
así que el viewBox se estiraba por 11.5 y un strokeWidth de 1.5 se
pintaba a ~17px reales. Al mudar el hilo a una banda de 56px el mismo
valor quedó en 0.84px, y con eso dejó de leerse.

- Grosor fijado en 14px reales con non-scaling-stroke, ya no heredado
  del escalado del viewBox.
- Banda de vuelta a toda la columna: ±184px de recorrido lateral.
- Cuatro ondulaciones largas en vez de una sola (que se aplanaba) o
  diez cortas (que se leían inquietas).
- El trazo termina al 85% del scroll: colgado del scroll de página sólo
  llegaba al 100% pasado el footer, así que el final no se veía nunca.
- Remata en el ≡ del wordmark, con la geometría de lib/simbolo.ts. Es
  lo que pedía el spec y era lo que faltaba.

Test nuevo que exige stroke-width >= 10: el grosor ya se había perdido
dos veces por accidente.

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

128 lines
5.0 KiB
TypeScript

'use client';
import { useState } from 'react';
import { motion, useMotionValueEvent, useScroll, useSpring, useTransform } from 'framer-motion';
import { usePrefersReducedMotion } from '@/lib/motion';
import { BARRAS_SIMBOLO, VIEWBOX_SIMBOLO } from '@/lib/simbolo';
// The gold spine of the page: one continuous line down the vertical centre,
// drawing itself as the reader scrolls and resolving into the ≡ at the close.
// It is what turns ten sections into a single route — *claridad · dirección ·
// crecimiento* made visible, per the Manual de Identidad Visual.
//
// Centred, never in a side gutter: the brand's through-line belongs in the
// middle, on every breakpoint. It sits ABOVE the section backgrounds (z-10)
// but BELOW the text, which `Container` lifts to z-20 — so the thread reads
// as a faint watermark spine behind the reading, never crossing it.
//
// Page-level useScroll() with no target ref on purpose: no element measuring,
// no "container has a static position" warnings, one listener for the page.
const TRAMOS = 4;
const ALTO = 1200;
const PASO = ALTO / TRAMOS;
// A band, not a thread. Held in real pixels by vector-effect below, so it never
// again collapses to a hairline when the viewBox scale changes.
const GROSOR = 14;
// Four long swells rather than one page-long curve or ten short ones. Stretched
// over the whole document with preserveAspectRatio="none", a single swell reads
// as a straight rule and ten read as a fidget; four put roughly one screen's
// worth of travel — some 180px across the column — into every viewport.
const TRAZO = Array.from({ length: TRAMOS }, (_, i) => {
const y = i * PASO;
const x = i % 2 === 0 ? 72 : 28;
// The final bend straightens as it lands so the thread enters the ≡ vertically.
const salida = i === TRAMOS - 1 ? 50 : x;
return `C ${x} ${y + PASO * 0.25}, ${salida} ${y + PASO * 0.75}, 50 ${y + PASO}`;
}).reduce((d, tramo) => `${d} ${tramo}`, 'M50 0');
// The draw finishes at 85% of the document instead of at the very bottom:
// scrollYProgress only reaches 1 past the footer, so tying the thread to it
// left the tip trailing in mid-screen and the ending never seen.
const FIN = 0.85;
export function HiloConductor({ className = '' }: { className?: string }) {
const reduced = usePrefersReducedMotion();
const { scrollYProgress } = useScroll();
const recorrido = useTransform(scrollYProgress, [0, FIN], [0, 1], { clamp: true });
const avance = useSpring(recorrido, { stiffness: 90, damping: 30, restDelta: 0.001 });
const [dibujado, setDibujado] = useState(false);
useMotionValueEvent(avance, 'change', (v) => {
if (v > 0.98) setDibujado(true);
});
const resuelto = reduced || dibujado;
const wrapper = `pointer-events-none absolute inset-0 z-10 ${className}`;
// Plain <path> for reduced motion: motion.path applies dash scaffolding even
// without initial/animate and would leave the thread invisible.
// vector-effect keeps the stroke at its declared width — the non-uniform
// viewBox scale squeezes a vertical line's stroke by the x factor alone.
const trazo = reduced ? (
<path
d={TRAZO}
fill="none"
stroke="currentColor"
strokeWidth={GROSOR}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
) : (
<motion.path
d={TRAZO}
fill="none"
stroke="currentColor"
strokeWidth={GROSOR}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
style={{ pathLength: avance }}
/>
);
return (
<div className={wrapper}>
{/* Mirrors Container's own centring (`mx-auto max-w-content`) so the
thread is measured from the real column edge and stays centred in it. */}
<div className="relative mx-auto h-full w-full max-w-content">
<svg
className="absolute top-0 bottom-20 left-1/2 w-full -translate-x-1/2 text-dorado/25"
viewBox={`0 0 100 ${ALTO}`}
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
{trazo}
</svg>
{/* The ending. The line arrives and becomes the mark — same geometry as
the wordmark's ≡, never a lookalike. Uniformly scaled in its own
svg, because the band's stretch would flatten the bars. */}
<svg
className="absolute bottom-4 left-1/2 w-24 -translate-x-1/2 text-dorado"
viewBox={VIEWBOX_SIMBOLO}
aria-hidden="true"
focusable="false"
>
{BARRAS_SIMBOLO.map((barra, i) =>
reduced ? (
<rect key={i} {...barra} rx={1.5} fill="currentColor" />
) : (
<motion.rect
key={i}
{...barra}
rx={1.5}
fill="currentColor"
initial={{ opacity: 0 }}
animate={{ opacity: resuelto ? 1 : 0 }}
transition={{ duration: 0.5, delay: i * 0.12, ease: [0.22, 1, 0.36, 1] }}
/>
),
)}
</svg>
</div>
</div>
);
}