Files
Creeer-Web/components/primitives/__tests__/HiloConductor.test.tsx
T
erikghandClaude Opus 5 fa86605b39 feat(diseño): los diez arreglos de la revisión visual
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>
2026-07-31 16:34:07 -06:00

169 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});