feat(hilo): el conductor baja visible por el centro, tras el texto

This commit is contained in:
erikgh committed 2026-07-31 12:14:47 -06:00
1 parent 8d87ef8757
commit aa6b348674
4 files changed
+44 -45

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
import type { ElementType, ReactNode } from 'react';
export function Container({ as: Tag = 'div', className = '', children }: { as?: ElementType; className?: string; children: ReactNode }) {
return <Tag className={`mx-auto w-full max-w-content px-6 md:px-12 ${className}`}>{children}</Tag>;
return <Tag className={`relative z-20 mx-auto w-full max-w-content px-6 md:px-12 ${className}`}>{children}</Tag>;
}
+14 -31
View File
@@ -3,45 +3,28 @@
import { motion, useScroll, useSpring } from 'framer-motion';
import { usePrefersReducedMotion } from '@/lib/motion';
// One continuous gold line running the height of the page, drawing itself as
// the reader scrolls. It is the spine of the layout: it stitches the sections
// together so the landing reads as a route rather than a stack of blocks.
// 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.
//
// Positioned `absolute`, never `fixed` — the thread belongs to the document
// and scrolls with it. A fixed layer would read as an ornament stuck to the
// window.
// 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.
//
// 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.
// 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 20 140, 80 260, 50 400 C 20 540, 80 660, 50 800 C 20 940, 80 1060, 50 1200';
'M50 0 C 42 200, 58 400, 50 600 C 42 800, 58 1000, 50 1200';
export function HiloConductor({ className = '' }: { className?: string }) {
const reduced = usePrefersReducedMotion();
const { scrollYProgress } = useScroll();
const avance = useSpring(scrollYProgress, { stiffness: 90, damping: 30, restDelta: 0.001 });
// 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 `<main>`'s own width instead.
const wrapper = `pointer-events-none absolute inset-0 z-30 hidden 2xl:block ${className}`;
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.
@@ -61,10 +44,10 @@ export function HiloConductor({ className = '' }: { className?: string }) {
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. */}
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">
<svg
className="absolute inset-y-0 right-full mr-10 h-full w-14 text-dorado/25"
className="absolute inset-y-0 left-1/2 h-full w-14 -translate-x-1/2 text-dorado/25"
viewBox="0 0 100 1200"
preserveAspectRatio="none"
aria-hidden="true"
@@ -0,0 +1,15 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Container } from '../Container';
describe('<Container />', () => {
it('lifts its content above the conductor thread', () => {
const { container } = render(<Container>contenido</Container>);
const cls = container.firstElementChild?.className ?? '';
// The thread sits at z-10; the section backgrounds are z-auto. Lifting the
// content column to z-20 is what puts the text *above* a centred thread,
// so the spine reads behind the reading instead of across it.
expect(cls).toContain('relative');
expect(cls).toMatch(/\bz-20\b/);
});
});
@@ -33,15 +33,15 @@ describe('<HiloConductor />', () => {
expect(container.firstElementChild?.className).toContain('pointer-events-none');
});
it('sits in the margin band beside the column, never over it', () => {
it('runs down the centre of the column, not in a side gutter', () => {
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');
const cls = container.querySelector('svg')?.getAttribute('class') ?? '';
// `left-1/2 -translate-x-1/2` anchors the thread to the column's centre;
// the old `right-full` banished it to the left gutter, which is why the
// thread read as off to the side instead of as the page's spine.
expect(cls).toContain('left-1/2');
expect(cls).toContain('-translate-x-1/2');
expect(cls).not.toContain('right-full');
});
it('declares a stacking level that beats section roots but stays under the header', () => {
@@ -55,13 +55,14 @@ describe('<HiloConductor />', () => {
expect(wrapperClass).not.toContain('z-50');
});
it('is hidden below the breakpoint that guarantees a safe margin', () => {
it('renders on every breakpoint, not only on very wide screens', () => {
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');
// The thread is the brand's through-line; it belongs in the middle on a
// phone too. Legibility is handled by layering it behind the text, not by
// hiding it below a breakpoint.
expect(wrapperClass).not.toContain('hidden');
expect(wrapperClass).not.toContain('2xl:block');
});
it('renders a genuine plain path under reduced motion, not a neutralised motion.path', () => {