fix(grafico): HiloConductor a la banda lateral, con stacking explícito

El hilo pintaba encima del texto en las secciones sin position:relative
(Dolores, Disciplinas, Estratega, ComoEmpezar) y detrás del texto en las
que sí la tienen, por el orden de pintado del navegador. Se mueve a la
banda vacía a la izquierda de la columna max-w-content (oculto bajo 2xl,
donde no hay margen seguro) y gana un z-index explícito por debajo del
header.
This commit is contained in:
erikgh committed 2026-07-31 11:25:34 -06:00
1 parent d6453ce197
commit 0239d4ac15
2 files changed
+98 -32

No files matched your search

+37 -31
View File
@@ -14,6 +14,20 @@ import { usePrefersReducedMotion } from '@/lib/motion';
// Uses the page-level useScroll() with no target ref on purpose: no element // Uses the page-level useScroll() with no target ref on purpose: no element
// measuring, no "container has a static position" warnings, and one scroll // measuring, no "container has a static position" warnings, and one scroll
// listener for the whole page. // listener for the whole page.
//
// It lives in the empty margin band to the left of the centred
// `max-w-content` (1152px) column, never over it — some sections are
// `position: relative` for unrelated reasons and some are not, so an
// absolutely-positioned overlay with no explicit stacking level paints
// inconsistently: above the static sections, behind the relative ones. `z-30`
// gives it its own stacking context that beats every section root regardless
// of that accident, while staying under the sticky header's `z-50`.
//
// Hidden below `2xl` (1536px): that's the first breakpoint that guarantees a
// safe margin outside the 1152px column — (1536 - 1152) / 2 = 192px per side,
// comfortable room for the thread's own offset plus breathing space to the
// viewport edge. Narrower viewports have no safe band, so the thread simply
// doesn't render there rather than creeping over the text.
const TRAZO = const TRAZO =
'M50 0 C 20 140, 80 260, 50 400 C 20 540, 80 660, 50 800 C 20 940, 80 1060, 50 1200'; 'M50 0 C 20 140, 80 260, 50 400 C 20 540, 80 660, 50 800 C 20 940, 80 1060, 50 1200';
@@ -23,41 +37,17 @@ export function HiloConductor({ className = '' }: { className?: string }) {
const { scrollYProgress } = useScroll(); const { scrollYProgress } = useScroll();
const avance = useSpring(scrollYProgress, { stiffness: 90, damping: 30, restDelta: 0.001 }); const avance = useSpring(scrollYProgress, { stiffness: 90, damping: 30, restDelta: 0.001 });
const base = `pointer-events-none absolute inset-y-0 left-1/2 -translate-x-1/2 w-full max-w-content h-full ${className}`; // Percentage-based band geometry against the containing block, never
// `100vw` — `100vw` includes the scrollbar and would overshoot the real
// The positioning/pointer-events classes live on a wrapping <div> rather // document width by ~15px on desktop. `inset-0` and the inner `mx-auto
// than the <svg> itself: per spec `SVGElement.className` is an // max-w-content` box both resolve against `<main>`'s own width instead.
// SVGAnimatedString, not a plain string, so tests (and any other reader of const wrapper = `pointer-events-none absolute inset-0 z-30 hidden 2xl:block ${className}`;
// `.className`) can't inspect it as text. A plain HTMLElement keeps that
// contract simple.
// 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.
if (reduced) { const trazo = reduced ? (
return (
<div className={base}>
<svg
className="h-full w-full text-dorado/25"
viewBox="0 0 100 1200"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
<path d={TRAZO} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" /> <path d={TRAZO} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
</svg> ) : (
</div>
);
}
return (
<div className={base}>
<svg
className="h-full w-full text-dorado/25"
viewBox="0 0 100 1200"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
<motion.path <motion.path
d={TRAZO} d={TRAZO}
fill="none" fill="none"
@@ -66,7 +56,23 @@ export function HiloConductor({ className = '' }: { className?: string }) {
strokeLinecap="round" strokeLinecap="round"
style={{ pathLength: avance }} style={{ pathLength: avance }}
/> />
);
return (
<div className={wrapper}>
{/* Mirrors Container's own centring (`mx-auto max-w-content`) so the
band below is measured from the real column edge. */}
<div className="relative mx-auto h-full w-full max-w-content">
<svg
className="absolute inset-y-0 right-full mr-10 h-full w-14 text-dorado/25"
viewBox="0 0 100 1200"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
{trazo}
</svg> </svg>
</div> </div>
</div>
); );
} }
@@ -1,8 +1,13 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { HiloConductor } from '../HiloConductor'; import { HiloConductor } from '../HiloConductor';
describe('<HiloConductor />', () => { describe('<HiloConductor />', () => {
const matchMediaOriginal = window.matchMedia;
afterEach(() => {
window.matchMedia = matchMediaOriginal;
});
it('is decorative: hidden from assistive tech', () => { it('is decorative: hidden from assistive tech', () => {
const { container } = render(<HiloConductor />); const { container } = render(<HiloConductor />);
const svg = container.querySelector('svg'); const svg = container.querySelector('svg');
@@ -27,4 +32,59 @@ describe('<HiloConductor />', () => {
const { container } = render(<HiloConductor />); const { container } = render(<HiloConductor />);
expect(container.firstElementChild?.className).toContain('pointer-events-none'); expect(container.firstElementChild?.className).toContain('pointer-events-none');
}); });
it('sits in the margin band beside the column, never over it', () => {
const { container } = render(<HiloConductor />);
const svg = container.querySelector('svg');
// `right-full` (`right: 100%`) anchors the thread's right edge to the
// left edge of the `max-w-content` column it shares a parent with —
// structurally outside the column, not merely visually offset within it.
// Read via getAttribute: on an SVGElement, `.className` is an
// SVGAnimatedString, not a plain string.
expect(svg?.getAttribute('class')).toContain('right-full');
});
it('declares a stacking level that beats section roots but stays under the header', () => {
const { container } = render(<HiloConductor />);
const wrapperClass = container.firstElementChild?.className ?? '';
// Section roots never set z-index (auto), so any explicit positive value
// paints above them regardless of whether the section itself happens to
// be `position: relative` or `static` — that inconsistency was the bug.
// The sticky header is z-50 and must still win.
expect(wrapperClass).toMatch(/\bz-\d+\b/);
expect(wrapperClass).not.toContain('z-50');
});
it('is hidden below the breakpoint that guarantees a safe margin', () => {
const { container } = render(<HiloConductor />);
const wrapperClass = container.firstElementChild?.className ?? '';
// No safe band exists under the content column's own breakpoint; hiding
// it there is what keeps it from ever creeping over the text.
expect(wrapperClass).toContain('hidden');
expect(wrapperClass).toContain('2xl:block');
});
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;
const { container } = render(<HiloConductor />);
const path = container.querySelector('path');
// motion.path stamps stroke-dasharray/stroke-dashoffset onto the DOM even
// without initial/animate — the dash-scaffolding trap that leaves the
// thread invisible for reduced-motion users. A real plain <path> never
// gets them, so their absence proves the branches weren't collapsed back
// into one.
expect(path).not.toHaveAttribute('stroke-dasharray');
expect(path).not.toHaveAttribute('stroke-dashoffset');
});
}); });