Revisada la página con Chromium a 1440x900 y 390x844 en nueve alturas de scroll, y verificado cada cambio contra capturas nuevas. - El ≡ deja de leerse como icono de menú. lib/simbolo.ts se anunciaba como single source of truth pero no coincidía con Wordmark.tsx, que traza la marca con las proporciones medidas del Manual: barra de largo 5.919× su grosor, hueco de 2×, extremos de píldora. Ahora se derivan de ahí. De paso, las líneas se meten medio grosor por cada extremo: el remate redondo alargaba el trazo un grosor entero sobre la barra que debía reproducir. - Banda: 12px en escritorio y 7px en móvil vía CSS (14px era el 3,6% de una pantalla de 390px y partía los párrafos), opacidad al 18%, y muelle de 90/30 a 210/40 — sobreamortiguado tardaba ~2s en asentar y la punta se arrastraba a media pantalla. El punto suelto que aparecía centrado arriba al cargar era el remate redondo pintándose en el origen con longitud cero: ahora la opacidad sigue al avance. - Ritmo vertical un 25-30% más apretado en las diez secciones. La página pasa de 8.061 a 7.457px sin tocar contenido. - Números 01-06: de marca de agua de 48px al 6% (que chocaba con títulos y reglas) a índice dorado sentado sobre la regla. - Hero: los dos CTA al mismo ancho en móvil, el patrón de puntos fuera del logotipo, y la secuencia visible en todos los tamaños — oculta bajo lg dejaba el hero del móvil sin ninguna pieza gráfica. La convergencia de la variante "hero" se cierra más para que se lea como líneas buscando su eje y no como un garabato tachado. - Estratega: fuera la retícula de puntos del retrato, que parecía el recuadro de imagen rota del navegador; la etiqueta del 30+ cabía en menos de lo que mide "construyendo" y salía en tres líneas. - El nudo de Dolores, más pequeño y con algo más de contraste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
7.8 KiB
TypeScript
169 lines
7.8 KiB
TypeScript
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');
|
||
});
|
||
});
|