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>
This commit is contained in:
1 parent
367e769f5e
commit
3c2185e200
2 files changed
+151
-22
No files matched your search
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'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 { 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,
|
// 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
|
// drawing itself as the reader scrolls and resolving into the ≡ at the close.
|
||||||
// single route — *claridad · dirección · crecimiento* made visible, per the
|
// It is what turns ten sections into a single route — *claridad · dirección ·
|
||||||
// Manual de Identidad Visual.
|
// crecimiento* made visible, per the Manual de Identidad Visual.
|
||||||
//
|
//
|
||||||
// Centred, never in a side gutter: the brand's through-line belongs in the
|
// 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)
|
// 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,
|
// Page-level useScroll() with no target ref on purpose: no element measuring,
|
||||||
// no "container has a static position" warnings, one listener for the page.
|
// no "container has a static position" warnings, one listener for the page.
|
||||||
|
|
||||||
const TRAZO =
|
const TRAMOS = 4;
|
||||||
'M50 0 C 42 200, 58 400, 50 600 C 42 800, 58 1000, 50 1200';
|
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 }) {
|
export function HiloConductor({ className = '' }: { className?: string }) {
|
||||||
const reduced = usePrefersReducedMotion();
|
const reduced = usePrefersReducedMotion();
|
||||||
const { scrollYProgress } = useScroll();
|
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}`;
|
const wrapper = `pointer-events-none absolute inset-0 z-10 ${className}`;
|
||||||
|
|
||||||
// Plain <path> for reduced motion: motion.path applies dash scaffolding even
|
// Plain <path> for reduced motion: motion.path applies dash scaffolding even
|
||||||
// without initial/animate and would leave the thread invisible.
|
// 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 ? (
|
const trazo = reduced ? (
|
||||||
<path d={TRAZO} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
|
<path
|
||||||
|
d={TRAZO}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={GROSOR}
|
||||||
|
strokeLinecap="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.path
|
<motion.path
|
||||||
d={TRAZO}
|
d={TRAZO}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={1.5}
|
strokeWidth={GROSOR}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
style={{ pathLength: avance }}
|
style={{ pathLength: avance }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -47,14 +88,39 @@ export function HiloConductor({ className = '' }: { className?: string }) {
|
|||||||
thread is measured from the real column edge and stays centred in it. */}
|
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">
|
<div className="relative mx-auto h-full w-full max-w-content">
|
||||||
<svg
|
<svg
|
||||||
className="absolute inset-y-0 left-1/2 h-full w-14 -translate-x-1/2 text-dorado/25"
|
className="absolute top-0 bottom-20 left-1/2 w-full -translate-x-1/2 text-dorado/25"
|
||||||
viewBox="0 0 100 1200"
|
viewBox={`0 0 100 ${ALTO}`}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
>
|
>
|
||||||
{trazo}
|
{trazo}
|
||||||
</svg>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { HiloConductor } from '../HiloConductor';
|
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('<HiloConductor />', () => {
|
describe('<HiloConductor />', () => {
|
||||||
const matchMediaOriginal = window.matchMedia;
|
const matchMediaOriginal = window.matchMedia;
|
||||||
@@ -65,18 +85,61 @@ describe('<HiloConductor />', () => {
|
|||||||
expect(wrapperClass).not.toContain('2xl:block');
|
expect(wrapperClass).not.toContain('2xl:block');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps its declared weight on screen despite the non-uniform viewBox scale', () => {
|
||||||
|
const { container } = render(<HiloConductor />);
|
||||||
|
// 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(<HiloConductor />);
|
||||||
|
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(<HiloConductor />);
|
||||||
|
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(<HiloConductor />);
|
||||||
|
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(<HiloConductor />);
|
||||||
|
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', () => {
|
it('renders a genuine plain path under reduced motion, not a neutralised motion.path', () => {
|
||||||
window.matchMedia = (query: string) =>
|
preferirMenosMovimiento();
|
||||||
({
|
|
||||||
matches: true,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addEventListener: () => {},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
addListener: () => {},
|
|
||||||
removeListener: () => {},
|
|
||||||
dispatchEvent: () => false,
|
|
||||||
}) as unknown as MediaQueryList;
|
|
||||||
|
|
||||||
const { container } = render(<HiloConductor />);
|
const { container } = render(<HiloConductor />);
|
||||||
const path = container.querySelector('path');
|
const path = container.querySelector('path');
|
||||||
|
|||||||
Reference in new issue
Block a user