diff --git a/components/primitives/HiloConductor.tsx b/components/primitives/HiloConductor.tsx index b576f44..911f158 100644 --- a/components/primitives/HiloConductor.tsx +++ b/components/primitives/HiloConductor.tsx @@ -14,6 +14,20 @@ import { usePrefersReducedMotion } from '@/lib/motion'; // Uses the page-level useScroll() with no target ref on purpose: no element // measuring, no "container has a static position" warnings, and one scroll // 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 = 'M50 0 C 20 140, 80 260, 50 400 C 20 540, 80 660, 50 800 C 20 940, 80 1060, 50 1200'; @@ -23,50 +37,42 @@ export function HiloConductor({ className = '' }: { className?: string }) { const { scrollYProgress } = useScroll(); 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}`; - - // The positioning/pointer-events classes live on a wrapping
rather - // than the itself: per spec `SVGElement.className` is an - // SVGAnimatedString, not a plain string, so tests (and any other reader of - // `.className`) can't inspect it as text. A plain HTMLElement keeps that - // contract simple. + // Percentage-based band geometry against the containing block, never + // `100vw` — `100vw` includes the scrollbar and would overshoot the real + // document width by ~15px on desktop. `inset-0` and the inner `mx-auto + // max-w-content` box both resolve against `
`'s own width instead. + const wrapper = `pointer-events-none absolute inset-0 z-30 hidden 2xl:block ${className}`; // Plain for reduced motion: motion.path applies dash scaffolding even // without initial/animate and would leave the thread invisible. - if (reduced) { - return ( -
+ const trazo = reduced ? ( + + ) : ( + + ); + + return ( +
+ {/* Mirrors Container's own centring (`mx-auto max-w-content`) so the + band below is measured from the real column edge. */} +
- ); - } - - return ( -
-
); } diff --git a/components/primitives/__tests__/HiloConductor.test.tsx b/components/primitives/__tests__/HiloConductor.test.tsx index 1270c1e..08eaea5 100644 --- a/components/primitives/__tests__/HiloConductor.test.tsx +++ b/components/primitives/__tests__/HiloConductor.test.tsx @@ -1,8 +1,13 @@ import { render } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { HiloConductor } from '../HiloConductor'; describe('', () => { + const matchMediaOriginal = window.matchMedia; + afterEach(() => { + window.matchMedia = matchMediaOriginal; + }); + it('is decorative: hidden from assistive tech', () => { const { container } = render(); const svg = container.querySelector('svg'); @@ -27,4 +32,59 @@ describe('', () => { const { container } = render(); expect(container.firstElementChild?.className).toContain('pointer-events-none'); }); + + it('sits in the margin band beside the column, never over it', () => { + const { container } = render(); + 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(); + 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(); + 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(); + 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 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'); + }); });