From 3c2185e20070a76cf6785d4fc0b3d045651d7d27 Mon Sep 17 00:00:00 2001 From: erikgh Date: Fri, 31 Jul 2026 15:57:54 -0600 Subject: [PATCH] =?UTF-8?q?fix(hilo):=20la=20banda=20recupera=20su=20groso?= =?UTF-8?q?r=20y=20remata=20en=20el=20=E2=89=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/primitives/HiloConductor.tsx | 88 ++++++++++++++++--- .../__tests__/HiloConductor.test.tsx | 85 +++++++++++++++--- 2 files changed, 151 insertions(+), 22 deletions(-) diff --git a/components/primitives/HiloConductor.tsx b/components/primitives/HiloConductor.tsx index 8d60c21..64799e0 100644 --- a/components/primitives/HiloConductor.tsx +++ b/components/primitives/HiloConductor.tsx @@ -1,12 +1,14 @@ 'use client'; -import { motion, useScroll, useSpring } from 'framer-motion'; +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. It is what turns ten sections into a -// single route — *claridad · dirección · crecimiento* made visible, per the -// Manual de Identidad Visual. +// 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) @@ -16,27 +18,66 @@ import { usePrefersReducedMotion } from '@/lib/motion'; // 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 TRAZO = - 'M50 0 C 42 200, 58 400, 50 600 C 42 800, 58 1000, 50 1200'; +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 avance = useSpring(scrollYProgress, { stiffness: 90, damping: 30, restDelta: 0.001 }); + 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 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 ? ( - + ) : ( ); @@ -47,14 +88,39 @@ export function HiloConductor({ className = '' }: { className?: string }) { thread is measured from the real column edge and stays centred in it. */}
+ {/* 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. */} +
); diff --git a/components/primitives/__tests__/HiloConductor.test.tsx b/components/primitives/__tests__/HiloConductor.test.tsx index 0b7cacc..3575168 100644 --- a/components/primitives/__tests__/HiloConductor.test.tsx +++ b/components/primitives/__tests__/HiloConductor.test.tsx @@ -1,6 +1,26 @@ import { render } from '@testing-library/react'; import { describe, it, expect, afterEach } from 'vitest'; import { HiloConductor } from '../HiloConductor'; +import { BARRAS_SIMBOLO, VIEWBOX_SIMBOLO } from '@/lib/simbolo'; + +const preferirMenosMovimiento = () => { + window.matchMedia = (query: string) => + ({ + matches: true, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +}; + +const simboloFinal = (container: HTMLElement) => + Array.from(container.querySelectorAll('svg')).find( + (svg) => svg.getAttribute('viewBox') === VIEWBOX_SIMBOLO, + ); describe('', () => { const matchMediaOriginal = window.matchMedia; @@ -65,18 +85,61 @@ describe('', () => { expect(wrapperClass).not.toContain('2xl:block'); }); + it('keeps its declared weight on screen despite the non-uniform viewBox scale', () => { + const { container } = render(); + // The band is stretched with preserveAspectRatio="none": y over the whole + // document, x over a couple hundred pixels. That squeezes the stroke of a + // near-vertical line by the x factor alone, so a declared 1.5 rendered as + // well under 1 real pixel — a ghost hairline. non-scaling-stroke pins the + // width to the value we actually chose. + expect(container.querySelector('path')).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + }); + + it('reads as a band, not as a hairline', () => { + const { container } = render(); + const grosor = Number(container.querySelector('path')?.getAttribute('stroke-width')); + // Twice regressed into a thread nobody could see. The band is the page's + // spine and has to carry weight; the text stays legible because it sits a + // stacking level above, not because the band is faint. + expect(grosor).toBeGreaterThanOrEqual(10); + }); + + it('sweeps wide enough across the column to read as a route', () => { + const { container } = render(); + const d = container.querySelector('path')?.getAttribute('d') ?? ''; + // Coordinates come in x/y pairs, so the even ones are the horizontal + // excursion, in a 0–100 viewBox stretched across the content column. + // Too little travel and the swell collapses into a straight rule. + const numeros = (d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number); + const xs = numeros.filter((_, i) => i % 2 === 0); + expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThanOrEqual(30); + expect((d.match(/C/g) ?? []).length).toBeGreaterThanOrEqual(3); + }); + + it('resolves into the ≡ of the wordmark instead of stopping in mid-air', () => { + const { container } = render(); + const simbolo = simboloFinal(container); + // Per the design: the thread "resuelve en el ≡ del wordmark en el cierre". + // It reuses the brand geometry rather than drawing a lookalike. + expect(simbolo).toBeDefined(); + expect(simbolo?.querySelectorAll('rect')).toHaveLength(BARRAS_SIMBOLO.length); + }); + + it('shows the resolved ≡ from the start under reduced motion', () => { + preferirMenosMovimiento(); + + const { container } = render(); + const barras = Array.from(simboloFinal(container)?.querySelectorAll('rect') ?? []); + // These readers never get the scroll draw that reveals the mark, so the + // ending has to be there already — otherwise the thread ends in nothing. + expect(barras).toHaveLength(BARRAS_SIMBOLO.length); + barras.forEach((barra) => { + expect(barra.getAttribute('style') ?? '').not.toContain('opacity: 0'); + }); + }); + it('renders a genuine plain path under reduced motion, not a neutralised motion.path', () => { - window.matchMedia = (query: string) => - ({ - matches: true, - media: query, - onchange: null, - addEventListener: () => {}, - removeEventListener: () => {}, - addListener: () => {}, - removeListener: () => {}, - dispatchEvent: () => false, - }) as unknown as MediaQueryList; + preferirMenosMovimiento(); const { container } = render(); const path = container.querySelector('path');