Un <svg> con viewBox es un elemento reemplazado con proporción intrínseca (100:1200). Al dimensionarlo con top-0 + bottom-20 y dejar la altura en auto, el navegador la resolvía desde el ancho — 1152 × 12 = 13.824px — y esa altura ganaba silenciosamente a `bottom`. Consecuencias, todas visibles en el navegador y ninguna en los tests: - 5.840px de blanco vacío scrolleable por debajo del footer. - scrollYProgress repartido sobre 13.901px en vez de 8.061, así que el trazo nunca terminaba sobre contenido real. - la onda estirada 1,8x de más, otra vez casi recta. Altura explícita con h-[calc(100%-5rem)], y test que lo fija: jsdom no hace layout, así que el guardián es la clase. El trazo pasa a completarse a mitad de scroll (FIN 0.85 → 0.5). Con 0.85 la punta se quedaba a media pantalla durante el 42% de la página, que es justo lo que se leía como "sin terminar". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
5.4 KiB
TypeScript
133 lines
5.4 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 halfway down the document. Tied to raw scrollYProgress the
|
|
// leading tip sat in mid-viewport for most of the page, reading as an amputated
|
|
// stub — the "unfinished" complaint. Finishing early lets the tip outrun the
|
|
// reader within the hero and leaves a complete spine for everything after.
|
|
const FIN = 0.5;
|
|
|
|
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">
|
|
{/* Explicit height, never a top+bottom pair: an <svg> carrying a viewBox
|
|
is a replaced element with an intrinsic 100:1200 ratio, so `height:
|
|
auto` resolves from the width (1152 → 13824px) and silently wins
|
|
over `bottom`, adding ~6000px of blank scroll below the footer. */}
|
|
<svg
|
|
className="absolute top-0 left-1/2 h-[calc(100%-5rem)] 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>
|
|
);
|
|
}
|