feat(hilo): retira el hilo conductor de la página
La dueña pidió quitarlo. Con él se van las dos cosas que solo existían para sostenerlo: el `relative` de <main> —ninguna sección lo necesita, todas son relative por su cuenta salvo Disciplinas, que no tiene hijos absolutos— y el export RADIO_BARRA, sin consumidores. BARRAS_SIMBOLO se queda como forma canónica del ≡ aunque ya nadie dibuje las barras: LINEAS_SIMBOLO se deriva de ella y el test que obliga a las dos representaciones a coincidir es lo que mantiene honesta la geometría frente al Manual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fa86605b39
commit
2383e63bc5
4 files changed
+6
-319
No files matched your search
+1
-3
@@ -1,6 +1,5 @@
|
|||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { Footer } from '@/components/layout/Footer';
|
import { Footer } from '@/components/layout/Footer';
|
||||||
import { HiloConductor } from '@/components/primitives/HiloConductor';
|
|
||||||
import { Hero } from '@/components/sections/Hero';
|
import { Hero } from '@/components/sections/Hero';
|
||||||
import { FraseAncla } from '@/components/sections/FraseAncla';
|
import { FraseAncla } from '@/components/sections/FraseAncla';
|
||||||
import { PuntosInflexion } from '@/components/sections/PuntosInflexion';
|
import { PuntosInflexion } from '@/components/sections/PuntosInflexion';
|
||||||
@@ -25,8 +24,7 @@ export default function Home() {
|
|||||||
<>
|
<>
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="relative">
|
<main>
|
||||||
<HiloConductor />
|
|
||||||
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
|
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
|
||||||
<FraseAncla />
|
<FraseAncla />
|
||||||
<PuntosInflexion />
|
<PuntosInflexion />
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { motion, useMotionValueEvent, useScroll, useSpring, useTransform } from 'framer-motion';
|
|
||||||
import { usePrefersReducedMotion } from '@/lib/motion';
|
|
||||||
import { BARRAS_SIMBOLO, RADIO_BARRA, VIEWBOX_SIMBOLO } from '@/lib/simbolo';
|
|
||||||
|
|
||||||
// The gold spine of the page: one continuous line down the vertical centre,
|
|
||||||
// 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)
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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 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. The attribute
|
|
||||||
// is the desktop weight and the SSR fallback; GROSORES overrides it in CSS,
|
|
||||||
// because 14px is 3.6% of a 390px screen and cut straight through the body
|
|
||||||
// copy there. CSS stroke-width wins over the presentation attribute.
|
|
||||||
const GROSOR = 12;
|
|
||||||
const GROSORES = '[stroke-width:7px] md:[stroke-width:12px]';
|
|
||||||
|
|
||||||
// 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 halfway down the document. Tied to raw scrollYProgress the
|
|
||||||
// leading tip sat in mid-viewport for most of the page, reading as an amputated
|
|
||||||
// stub — the "unfinished" complaint. Finishing early lets the tip outrun the
|
|
||||||
// reader within the hero and leaves a complete spine for everything after.
|
|
||||||
const FIN = 0.5;
|
|
||||||
|
|
||||||
export function HiloConductor({ className = '' }: { className?: string }) {
|
|
||||||
const reduced = usePrefersReducedMotion();
|
|
||||||
const { scrollYProgress } = useScroll();
|
|
||||||
const recorrido = useTransform(scrollYProgress, [0, FIN], [0, 1], { clamp: true });
|
|
||||||
// Stiff enough to keep up with the reader. At 90/30 the spring is heavily
|
|
||||||
// overdamped and takes some two seconds to settle, so the leading edge
|
|
||||||
// visibly trailed behind the scroll and read as an amputated stub.
|
|
||||||
const avance = useSpring(recorrido, { stiffness: 210, damping: 40, restDelta: 0.001 });
|
|
||||||
// At rest the round cap still paints its own dot at the path's origin, which
|
|
||||||
// landed as a stray blob dead centre at the top of the page on first load.
|
|
||||||
const aparicion = useTransform(avance, [0, 0.015], [0, 1]);
|
|
||||||
|
|
||||||
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 <path> 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 ? (
|
|
||||||
<path
|
|
||||||
d={TRAZO}
|
|
||||||
className={GROSORES}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={GROSOR}
|
|
||||||
strokeLinecap="round"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<motion.path
|
|
||||||
d={TRAZO}
|
|
||||||
className={GROSORES}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={GROSOR}
|
|
||||||
strokeLinecap="round"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
style={{ pathLength: avance, opacity: aparicion }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={wrapper}>
|
|
||||||
{/* Mirrors Container's own centring (`mx-auto max-w-content`) so the
|
|
||||||
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">
|
|
||||||
{/* Explicit height, never a top+bottom pair: an <svg> carrying a viewBox
|
|
||||||
is a replaced element with an intrinsic 100:1200 ratio, so `height:
|
|
||||||
auto` resolves from the width (1152 → 13824px) and silently wins
|
|
||||||
over `bottom`, adding ~6000px of blank scroll below the footer. */}
|
|
||||||
<svg
|
|
||||||
className="absolute top-0 left-1/2 h-[calc(100%-5rem)] w-full -translate-x-1/2 text-dorado/[0.18]"
|
|
||||||
viewBox={`0 0 100 ${ALTO}`}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
>
|
|
||||||
{trazo}
|
|
||||||
</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-20 -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={RADIO_BARRA} fill="currentColor" />
|
|
||||||
) : (
|
|
||||||
<motion.rect
|
|
||||||
key={i}
|
|
||||||
{...barra}
|
|
||||||
rx={RADIO_BARRA}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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('<HiloConductor />', () => {
|
|
||||||
const matchMediaOriginal = window.matchMedia;
|
|
||||||
afterEach(() => {
|
|
||||||
window.matchMedia = matchMediaOriginal;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is decorative: hidden from assistive tech', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
const svg = container.querySelector('svg');
|
|
||||||
expect(svg).toHaveAttribute('aria-hidden', 'true');
|
|
||||||
expect(svg).toHaveAttribute('focusable', 'false');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('draws a single continuous thread', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
expect(container.querySelectorAll('path')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('travels with the document instead of sticking to the viewport', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
// `fixed` would read as an ornament pinned to the window; the thread
|
|
||||||
// belongs to the page and must scroll with it.
|
|
||||||
expect(container.firstElementChild?.className).toContain('absolute');
|
|
||||||
expect(container.firstElementChild?.className).not.toContain('fixed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays out of the way of pointer input', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
expect(container.firstElementChild?.className).toContain('pointer-events-none');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs down the centre of the column, not in a side gutter', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
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', () => {
|
|
||||||
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('renders on every breakpoint, not only on very wide screens', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
const wrapperClass = container.firstElementChild?.className ?? '';
|
|
||||||
// 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('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('sizes the band with an explicit height, never a top+bottom pair', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
const cls = container.querySelector('svg')?.getAttribute('class') ?? '';
|
|
||||||
// An <svg> carrying a viewBox is a replaced element with an intrinsic
|
|
||||||
// ratio, so `height: auto` resolves from the width and silently beats
|
|
||||||
// `bottom` — which stretched this one to 13824px and hung ~6000px of blank
|
|
||||||
// scroll below the footer. jsdom has no layout, so the class is the guard.
|
|
||||||
expect(cls).toMatch(/\bh-\[/);
|
|
||||||
expect(cls).not.toMatch(/\bbottom-/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads as a band, not as a hairline', () => {
|
|
||||||
const { container } = render(<HiloConductor />);
|
|
||||||
const path = container.querySelector('path');
|
|
||||||
// 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. The attribute is the
|
|
||||||
// desktop weight, the classes narrow it on phones where 12px is 3% of the
|
|
||||||
// screen — CSS stroke-width beats the presentation attribute.
|
|
||||||
expect(Number(path?.getAttribute('stroke-width'))).toBeGreaterThanOrEqual(10);
|
|
||||||
expect(path?.getAttribute('class') ?? '').toMatch(/md:\[stroke-width:/);
|
|
||||||
});
|
|
||||||
|
|
||||||
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', () => {
|
|
||||||
preferirMenosMovimiento();
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+5
-4
@@ -1,7 +1,9 @@
|
|||||||
// Single source of truth for the ≡ glyph geometry. Everything that draws the
|
// Single source of truth for the ≡ glyph geometry. Everything that draws the
|
||||||
// mark reads from here — HiloConductor and SecuenciaClaridad resolve into it,
|
// mark reads from here — today that is SecuenciaClaridad, which resolves its
|
||||||
// the former with <rect>, the latter with <line> — so the symbol they produce
|
// tangle into it — so the symbol it produces is provably the same one and not a
|
||||||
// is provably the same one and not a lookalike.
|
// lookalike. BARRAS_SIMBOLO stays the canonical form even with no one drawing
|
||||||
|
// the bars directly: the strokes below are derived from it, and the test that
|
||||||
|
// holds the two representations to each other is what keeps them honest.
|
||||||
//
|
//
|
||||||
// The proportions are the Manual de Identidad Visual's, already measured and
|
// The proportions are the Manual de Identidad Visual's, already measured and
|
||||||
// traced in Wordmark.tsx: bar length = 5.919 × thickness, gap = 2 × thickness,
|
// traced in Wordmark.tsx: bar length = 5.919 × thickness, gap = 2 × thickness,
|
||||||
@@ -17,7 +19,6 @@ const LARGO_POR_GROSOR = 5.919;
|
|||||||
const HUECO_POR_GROSOR = 2;
|
const HUECO_POR_GROSOR = 2;
|
||||||
|
|
||||||
export const GROSOR_BARRA = 8;
|
export const GROSOR_BARRA = 8;
|
||||||
export const RADIO_BARRA = GROSOR_BARRA / 2;
|
|
||||||
|
|
||||||
const LARGO_BARRA = GROSOR_BARRA * LARGO_POR_GROSOR;
|
const LARGO_BARRA = GROSOR_BARRA * LARGO_POR_GROSOR;
|
||||||
const HUECO = GROSOR_BARRA * HUECO_POR_GROSOR;
|
const HUECO = GROSOR_BARRA * HUECO_POR_GROSOR;
|
||||||
|
|||||||
Reference in new issue
Block a user