feat(primitivas): capa gráfica y de movimiento reutilizable

Diez primitivas que dan a la landing el "gráfico y movimiento" que pidió
la dueña, todas con guarda de prefers-reduced-motion:

- FondoPuntos / PatronPuntos — el patrón de puntos del manual
- TarjetaSpotlight, TextoDesenfoque, ContadorNumero — realce y reveal
- Carrusel — deslizable, con contador, autoplay pausable
- Icono, SimboloTresLineas, IlustracionInflexion — línea, sin relleno
- FondoFoto — duotono azul/dorado sobre foto

useAutoStep en lib/motion.ts comparte la forma del autoplay del carrusel
para indicadores de paso que no son carrusel.

IlustracionInflexion documenta un detalle que muerde: motion.path aplica
su propio andamiaje de dasharray aunque no reciba initial/animate, así
que la rama de reduced motion renderiza <path> planos en vez de intentar
neutralizar los animados.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-07-31 10:44:28 -06:00
1 parent a9758a92f9
commit 5b400cb161
19 files changed
+881

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
'use client';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import Autoplay from 'embla-carousel-autoplay';
import { usePrefersReducedMotion } from '@/lib/motion';
// Thin Embla wrapper shared by the hero's pain-point rotator and the mobile
// row of inflection points. Autoplay pauses on hover/focus/interaction, and
// never runs at all for users who asked for less motion — it's still a fully
// usable carousel (drag, dots, keyboard) with it off, just static.
export function Carrusel({
slides,
autoplayMs,
className = '',
slideClassName = '',
ariaLabel,
mostrarContador = false,
}: {
slides: ReactNode[];
autoplayMs?: number;
className?: string;
slideClassName?: string;
ariaLabel: string;
// "01 / 06" + flechas prev/next, en vez de puntos — el patrón de
// en-ti.com ("desliza para explorar") para carruseles con más peso.
mostrarContador?: boolean;
}) {
const reduced = usePrefersReducedMotion();
const plugins = autoplayMs && !reduced ? [Autoplay({ delay: autoplayMs, stopOnInteraction: false, stopOnMouseEnter: true })] : [];
const [emblaRef, embla] = useEmblaCarousel({ loop: true }, plugins);
const [selected, setSelected] = useState(0);
useEffect(() => {
if (!embla) return;
const onSelect = () => setSelected(embla.selectedScrollSnap());
embla.on('select', onSelect);
onSelect();
return () => {
embla.off('select', onSelect);
};
}, [embla]);
const scrollTo = useCallback((i: number) => embla?.scrollTo(i), [embla]);
const scrollPrev = useCallback(() => embla?.scrollPrev(), [embla]);
const scrollNext = useCallback(() => embla?.scrollNext(), [embla]);
return (
<div className={`min-w-0 ${className}`}>
<div className="overflow-hidden" ref={emblaRef} role="group" aria-label={ariaLabel}>
<div className="flex">
{slides.map((slide, i) => (
<div key={i} className={`min-w-0 shrink-0 grow-0 basis-full ${slideClassName}`}>
{slide}
</div>
))}
</div>
</div>
{slides.length > 1 && mostrarContador && (
<div className="mt-6 flex items-center justify-center gap-5">
<button type="button" onClick={scrollPrev} aria-label="Anterior"
className="grid place-items-center w-9 h-9 rounded-full border border-azul-profundo/20 text-azul-profundo transition-colors hover:border-dorado hover:text-dorado">
←
</button>
<p className="font-sans font-bold text-sm tabular-nums text-azul-profundo tracking-wider">
{String(selected + 1).padStart(2, '0')} <span className="text-gris-medio font-normal">/ {String(slides.length).padStart(2, '0')}</span>
</p>
<button type="button" onClick={scrollNext} aria-label="Siguiente"
className="grid place-items-center w-9 h-9 rounded-full border border-azul-profundo/20 text-azul-profundo transition-colors hover:border-dorado hover:text-dorado">
→
</button>
</div>
)}
{slides.length > 1 && !mostrarContador && (
<div className="mt-5 flex justify-center gap-2">
{slides.map((_, i) => (
<button
key={i}
type="button"
onClick={() => scrollTo(i)}
aria-label={`Ir a la tarjeta ${i + 1} de ${slides.length}`}
aria-current={selected === i}
className={`h-2 rounded-full transition-all duration-300 ${selected === i ? 'w-6 bg-dorado' : 'w-2 bg-gris-medio/50 hover:bg-gris-medio'}`}
/>
))}
</div>
)}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { animate } from 'framer-motion';
import { usePrefersReducedMotion } from '@/lib/motion';
// Counts up to `hasta` once scrolled into view. Only ever used with a number
// that's already written in the approved copy (e.g. "30+ años") — a visual
// echo of real content, never a stand-in for real metrics we don't have yet.
export function ContadorNumero({ hasta, sufijo = '', className = '' }: { hasta: number; sufijo?: string; className?: string }) {
const reduced = usePrefersReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const [display, setDisplay] = useState(reduced ? hasta : 0);
useEffect(() => {
const el = ref.current;
if (!el || reduced) return;
let controls: { stop: () => void } | undefined;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
observer.unobserve(el);
controls = animate(0, hasta, {
duration: 1.4,
ease: [0.22, 1, 0.36, 1],
onUpdate: (v) => setDisplay(Math.round(v)),
});
},
{ threshold: 0.5 },
);
observer.observe(el);
return () => {
observer.disconnect();
controls?.stop();
};
}, [reduced, hasta]);
return (
<span ref={ref} className={className}>
{display}
{sufijo}
</span>
);
}
+29
View File
@@ -0,0 +1,29 @@
// Full-bleed photo background, tinted into the brand's navy/gold duotone via
// `mix-blend-mode: luminosity` — the gradient behind supplies the colour, the
// photo only contributes its tonal range. Keeps "blanco con azul" even when a
// photograph is on screen. Decorative only: aria-hidden, lazy-loaded.
export function FondoFoto({
src,
className = '',
imgClassName = '',
wash = 'bg-azul-profundo/70',
}: {
src: string;
className?: string;
imgClassName?: string;
wash?: string;
}) {
return (
<div aria-hidden="true" className={`absolute overflow-hidden pointer-events-none select-none ${className}`}>
<div className="absolute inset-0 bg-gradient-to-br from-azul-profundo to-dorado" />
{/* eslint-disable-next-line @next/next/no-img-element -- decorative background on a `output: export` site with images.unoptimized */}
<img
src={src}
alt=""
loading="lazy"
className={`absolute inset-0 w-full h-full object-cover mix-blend-luminosity ${imgClassName}`}
/>
<div className={`absolute inset-0 ${wash}`} />
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useEffect, useRef } from 'react';
import { motion, useMotionValue, useScroll, useSpring, useTransform } from 'framer-motion';
import { PatronPuntos } from './PatronPuntos';
import { usePrefersReducedMotion } from '@/lib/motion';
// Decorative background: the manual's dot pattern, drifting slightly against
// the scroll. Purely presentational — hidden from assistive tech, and it holds
// still for anyone who asked for less movement.
export function FondoPuntos({
className = '',
distance = 40,
rows,
cols,
interactivo = false,
}: {
className?: string;
distance?: number;
rows?: number;
cols?: number;
// Además de derivar con el scroll, sigue el cursor levemente — reservado
// para los patrones más prominentes (el hero), no para cada instancia.
interactivo?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduced = usePrefersReducedMotion();
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'end start'] });
const scrollY = useTransform(scrollYProgress, [0, 1], [distance, -distance]);
const mouseXRaw = useMotionValue(0);
const mouseYRaw = useMotionValue(0);
const mouseX = useSpring(mouseXRaw, { stiffness: 40, damping: 15 });
const mouseY = useSpring(mouseYRaw, { stiffness: 40, damping: 15 });
useEffect(() => {
if (!interactivo || reduced) return;
const handler = (e: MouseEvent) => {
mouseXRaw.set((e.clientX / window.innerWidth - 0.5) * 16);
mouseYRaw.set((e.clientY / window.innerHeight - 0.5) * 16);
};
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, [interactivo, reduced, mouseXRaw, mouseYRaw]);
const y = useTransform([scrollY, mouseY], ([a, b]) => (a as number) + (b as number));
return (
<div ref={ref} aria-hidden="true" className={`pointer-events-none absolute select-none ${className}`}>
<motion.div style={reduced ? undefined : { y, x: interactivo ? mouseX : undefined }}>
<PatronPuntos rows={rows} cols={cols} className="w-full h-auto" />
</motion.div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { ReactNode } from 'react';
// Set of simple line icons — one per concept used across Mecanismo,
// ComoEmpezar and Disciplinas. Same stroke weight and geometric language as
// the manual's own iconography guidance ("líneas simples, geométricas,
// consistentes en grosor"), hand-drawn to match each step's specific idea
// rather than generic stock glyphs.
export type IconoNombre =
| 'inmersion'
| 'desafiar'
| 'estrategia'
| 'escribeme'
| 'diagnostico'
| 'plan'
| 'comunicacion'
| 'digital'
| 'experiencias';
const PATHS: Record<IconoNombre, ReactNode> = {
// A line dives through two layers down to a core point — "me sumerjo
// hasta comprenderla desde dentro".
inmersion: (
<>
<path d="M12 3v11" />
<path d="M7 8h10" />
<path d="M9 12h6" />
<path d="m8 14 4 4 4-4" />
<circle cx="12" cy="20.5" r="0.75" fill="currentColor" stroke="none" />
</>
),
// A circle (the assumption) cracked by a bolt — "cuestiono lo que todos
// dan por hecho".
desafiar: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="m13 6-4 6h4l-2 6 5-7.5h-4z" fill="currentColor" stroke="none" />
</>
),
// A path rising to a flag — "una estrategia que puedas ejecutar".
estrategia: (
<>
<path d="M4 20 10 12 14 15 20 6" strokeDasharray="1.5 3" />
<path d="M20 6v6" />
<path d="M20 6h-5l2.5-3.5L20 6" fill="currentColor" stroke="none" />
</>
),
// A speech bubble mid-thought — "cuéntame dónde está tu negocio hoy".
escribeme: (
<>
<path d="M4 5.5h16v10H10l-4 3.5v-3.5H4z" />
<path d="M8 9.5h8" />
<path d="M8 12.5h5" />
</>
),
// A magnifying glass over a short list — "una conversación para ubicar tu
// punto de inflexión".
diagnostico: (
<>
<path d="M5 4h9v9.5" />
<path d="M8 8h5" />
<path d="M8 11h2.5" />
<circle cx="15.5" cy="15.5" r="4" />
<path d="m18.6 18.6 2.4 2.4" />
</>
),
// A short checklist — "sales con claridad y tu siguiente movimiento
// definido".
plan: (
<>
<rect x="5" y="3.5" width="14" height="17" rx="1.5" />
<path d="m8.3 8.5 1.2 1.2L11.7 7.5" />
<path d="M14 8.5h5" />
<path d="m8.3 14 1.2 1.2 2.2-2.2" />
<path d="M14 14h5" />
</>
),
// Two overlapping bubbles — mensaje que alinea a tu equipo y tu mercado.
comunicacion: (
<>
<path d="M3 6.5h11v7.5H9l-3 2.5v-2.5H3z" />
<path d="M12 6.5A2.5 2.5 0 0 1 14.5 4H20v6.5h-3v2.5l-2.7-2.5" />
</>
),
// A screen with a signal — presencia y procesos que escalan.
digital: (
<>
<rect x="3.5" y="5" width="17" height="12" rx="1.2" />
<path d="M9 20.5h6" />
<path d="M12 17v3.5" />
<path d="M8.5 12.5a3.6 3.6 0 0 1 7 0" strokeDasharray="0.1 3" />
<circle cx="12" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
</>
),
// A ripple from a single touch — cada toque con tu marca construye
// confianza.
experiencias: (
<>
<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="9" strokeOpacity={0.55} />
</>
),
};
export function Icono({ nombre, className = '' }: { nombre: IconoNombre; className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
focusable="false"
>
{PATHS[nombre]}
</svg>
);
}
@@ -0,0 +1,89 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { usePrefersReducedMotion } from '@/lib/motion';
// A literal small illustration of the section's own idea: a business grows,
// hits its inflection point (the dot), and from there either drifts flat
// without direction (grey, dashed) or keeps climbing with a clear strategy
// (dorado). Draws itself in once scrolled into view; a decorative aside, not
// data, so it stays purely presentational.
export function IlustracionInflexion({ className = '' }: { className?: string }) {
const reduced = usePrefersReducedMotion();
const ref = useRef<SVGSVGElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || reduced) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.unobserve(el);
}
},
{ threshold: 0.4 },
);
observer.observe(el);
return () => observer.disconnect();
}, [reduced]);
// `motion.path` applies its own dash-based pathLength scaffolding even with
// no initial/animate props, which left the lines invisible for
// reduced-motion users — so that branch renders plain, non-motion <path>s
// instead of trying to neutralise the animated ones.
if (reduced) {
return (
<svg viewBox="0 0 320 180" className={className} aria-hidden="true" focusable="false">
<path d="M20 140 C 70 150, 110 40, 165 60" fill="none" stroke="currentColor" className="text-azul-profundo" strokeWidth={3} strokeLinecap="round" />
<path d="M165 60 C 210 76, 250 92, 300 88" fill="none" stroke="currentColor" className="text-gris-medio" strokeWidth={2.5} strokeLinecap="round" strokeDasharray="1 9" />
<path d="M165 60 C 205 48, 245 8, 300 14" fill="none" stroke="currentColor" className="text-dorado" strokeWidth={3} strokeLinecap="round" />
<circle cx="165" cy="60" r="6" fill="currentColor" className="text-dorado" />
<circle cx="165" cy="60" r="10" fill="none" stroke="currentColor" className="text-dorado" strokeWidth={1} strokeOpacity={0.4} />
</svg>
);
}
const draw = (delay: number) => ({
initial: { pathLength: 0 },
animate: inView ? { pathLength: 1 } : { pathLength: 0 },
transition: { duration: 1.1, delay, ease: [0.22, 1, 0.36, 1] as const },
});
return (
<svg ref={ref} viewBox="0 0 320 180" className={className} aria-hidden="true" focusable="false">
<motion.path
d="M20 140 C 70 150, 110 40, 165 60"
fill="none"
stroke="currentColor"
className="text-azul-profundo"
strokeWidth={3}
strokeLinecap="round"
{...draw(0)}
/>
<motion.path
d="M165 60 C 210 76, 250 92, 300 88"
fill="none"
stroke="currentColor"
className="text-gris-medio"
strokeWidth={2.5}
strokeLinecap="round"
strokeDasharray="1 9"
{...draw(0.5)}
/>
<motion.path
d="M165 60 C 205 48, 245 8, 300 14"
fill="none"
stroke="currentColor"
className="text-dorado"
strokeWidth={3}
strokeLinecap="round"
{...draw(0.5)}
/>
<circle cx="165" cy="60" r="6" fill="currentColor" className="text-dorado" />
<circle cx="165" cy="60" r="10" fill="none" stroke="currentColor" className="text-dorado" strokeWidth={1} strokeOpacity={0.4} />
</svg>
);
}
+46
View File
@@ -0,0 +1,46 @@
// "Patrón de puntos" from the Manual de Identidad Visual (recursos visuales).
// Geometry measured off the manual: the dots shrink and tighten from left to
// right, resolving complexity into focus — the same idea as the ≡ symbol.
// Colour comes from `currentColor`, so callers set it with a text-* class.
const R0 = 5.5; // radius of the leftmost dot
const R_DECAY = 0.847; // per column
const S0 = 29; // gap between the first two columns
const S_DECAY = 0.97; // per column
const ROW_GAP = 24.5;
export function PatronPuntos({
rows = 3,
cols = 8,
className = '',
}: {
rows?: number;
cols?: number;
className?: string;
}) {
const columns: { cx: number; r: number }[] = [];
let cx = R0;
for (let i = 0; i < cols; i++) {
const r = R0 * R_DECAY ** i;
columns.push({ cx, r });
cx += S0 * S_DECAY ** i;
}
const last = columns[columns.length - 1];
const width = last.cx + last.r;
const height = (rows - 1) * ROW_GAP + 2 * R0;
return (
<svg
viewBox={`0 0 ${width.toFixed(1)} ${height.toFixed(1)}`}
className={className}
aria-hidden="true"
focusable="false"
>
{Array.from({ length: rows }, (_, row) =>
columns.map(({ cx: x, r }, col) => (
<circle key={`${row}-${col}`} cx={x.toFixed(2)} cy={(R0 + row * ROW_GAP).toFixed(2)} r={r.toFixed(2)} fill="currentColor" />
)),
)}
</svg>
);
}
@@ -0,0 +1,42 @@
'use client';
import { useRef, type ReactNode } from 'react';
// Adapted from React Bits' SpotlightCard (reactbits.dev, MIT) — a soft glow
// that follows the cursor, recoloured to the brand's dorado instead of the
// original's plain white. Pure CSS custom properties + a `before:` pseudo
// element, so no separate stylesheet is needed.
export function TarjetaSpotlight({
children,
className = '',
spotlightColor = 'rgba(196, 148, 46, 0.16)', // dorado, low alpha
}: {
children: ReactNode;
className?: string;
spotlightColor?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
el.style.setProperty('--spot-x', `${e.clientX - rect.left}px`);
el.style.setProperty('--spot-y', `${e.clientY - rect.top}px`);
};
return (
<div
ref={ref}
onMouseMove={handleMouseMove}
style={{ ['--spot-color' as string]: spotlightColor }}
className={`group relative overflow-hidden
before:absolute before:inset-0 before:pointer-events-none before:opacity-0 before:transition-opacity before:duration-500
before:[background:radial-gradient(circle_at_var(--spot-x,50%)_var(--spot-y,50%),var(--spot-color),transparent_70%)]
hover:before:opacity-100 focus-within:before:opacity-100 motion-reduce:before:hidden
${className}`}
>
{children}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useMemo, useRef, useState, type ElementType } from 'react';
import { usePrefersReducedMotion } from '@/lib/motion';
// Adapted from React Bits' BlurText (reactbits.dev, MIT) — generalised to take
// several independently-styled segments instead of one plain string, so a
// gold/underlined word can blur in beside plain navy text in the same headline.
type Segmento = { text: string; className?: string };
const FROM = { filter: 'blur(10px)', opacity: 0, y: 14 };
const KEYFRAMES = {
filter: ['blur(10px)', 'blur(4px)', 'blur(0px)'],
opacity: [0, 0.6, 1],
y: [14, 4, 0],
};
const TIMES = [0, 0.5, 1];
export function TextoDesenfoque({
segments,
delayMs = 45,
stepDuration = 0.35,
className = '',
as: Tag = 'span',
}: {
segments: Segmento[];
delayMs?: number;
stepDuration?: number;
className?: string;
as?: ElementType;
}) {
const reduced = usePrefersReducedMotion();
const [inView, setInView] = useState(false);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || reduced) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.unobserve(el);
}
},
{ threshold: 0.4 },
);
observer.observe(el);
return () => observer.disconnect();
}, [reduced]);
const words = useMemo(
() => segments.flatMap((seg, si) => seg.text.split(' ').map((w, wi) => ({ w, si, wi, className: seg.className }))),
[segments],
);
if (reduced) {
return (
<Tag ref={ref} className={className}>
{segments.map((seg, i) => (
<span key={i} className={seg.className}>
{seg.text}
{i < segments.length - 1 ? ' ' : ''}
</span>
))}
</Tag>
);
}
return (
<Tag ref={ref} className={className} style={{ display: 'inline' }}>
{words.map(({ w, si, wi, className: segClass }, i) => (
// The joining space is a sibling text node, not the span's own trailing
// character — a space at the very end of an inline-block's own content
// collapses to zero width, which silently glued every word together.
<span key={`${si}-${wi}`}>
<motion.span
className={`inline-block will-change-[transform,filter,opacity] ${segClass ?? ''}`}
initial={FROM}
animate={inView ? KEYFRAMES : FROM}
transition={{ duration: stepDuration * 2, times: TIMES, delay: (i * delayMs) / 1000 }}
>
{w}
</motion.span>
{i < words.length - 1 ? ' ' : ''}
</span>
))}
</Tag>
);
}
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Carrusel } from '../Carrusel';
describe('<Carrusel />', () => {
it('renders every slide', () => {
render(<Carrusel ariaLabel="test" slides={[<p key="a">Uno</p>, <p key="b">Dos</p>, <p key="c">Tres</p>]} />);
expect(screen.getByText('Uno')).toBeInTheDocument();
expect(screen.getByText('Dos')).toBeInTheDocument();
expect(screen.getByText('Tres')).toBeInTheDocument();
});
it('renders one position dot per slide, and marks the current one', () => {
render(<Carrusel ariaLabel="test" slides={[<p key="a">Uno</p>, <p key="b">Dos</p>]} />);
const dots = screen.getAllByRole('button', { name: /ir a la tarjeta/i });
expect(dots).toHaveLength(2);
expect(dots[0]).toHaveAttribute('aria-current', 'true');
});
it('moves to a slide when its dot is clicked', async () => {
const user = userEvent.setup();
render(<Carrusel ariaLabel="test" slides={[<p key="a">Uno</p>, <p key="b">Dos</p>]} />);
const dots = screen.getAllByRole('button', { name: /ir a la tarjeta/i });
await user.click(dots[1]);
expect(dots[1]).toHaveAttribute('aria-current', 'true');
});
it('hides the dots entirely for a single slide', () => {
render(<Carrusel ariaLabel="test" slides={[<p key="a">Solo una</p>]} />);
expect(screen.queryByRole('button', { name: /ir a la tarjeta/i })).not.toBeInTheDocument();
});
it('exposes the slide group with the given accessible name', () => {
render(<Carrusel ariaLabel="Frases de ejemplo" slides={[<p key="a">Uno</p>]} />);
expect(screen.getByRole('group', { name: 'Frases de ejemplo' })).toBeInTheDocument();
});
it('shows a numbered counter with prev/next arrows instead of dots when mostrarContador is set', () => {
render(<Carrusel ariaLabel="test" mostrarContador slides={[<p key="a">Uno</p>, <p key="b">Dos</p>, <p key="c">Tres</p>]} />);
expect(screen.getByText('01')).toBeInTheDocument();
expect(screen.getByText(/\/ 03/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Siguiente' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Anterior' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /ir a la tarjeta/i })).not.toBeInTheDocument();
});
it('advances the counter when the next arrow is clicked', async () => {
const user = userEvent.setup();
render(<Carrusel ariaLabel="test" mostrarContador slides={[<p key="a">Uno</p>, <p key="b">Dos</p>]} />);
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
expect(screen.getByText('02')).toBeInTheDocument();
});
});
@@ -0,0 +1,20 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ContadorNumero } from '../ContadorNumero';
describe('<ContadorNumero />', () => {
it('starts at 0 and only counts up once scrolled into view', () => {
const { container } = render(<ContadorNumero hasta={30} />);
expect(container.textContent).toBe('0');
});
it('appends the given suffix', () => {
const { container } = render(<ContadorNumero hasta={30} sufijo="+" />);
expect(container.textContent).toBe('0+');
});
it('applies the given className to the counter span', () => {
const { container } = render(<ContadorNumero hasta={30} className="text-dorado" />);
expect(container.querySelector('span')).toHaveClass('text-dorado');
});
});
@@ -0,0 +1,19 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { FondoFoto } from '../FondoFoto';
describe('<FondoFoto />', () => {
it('is decorative: hidden from assistive tech, empty alt text', () => {
const { container } = render(<FondoFoto src="/images/test.jpg" />);
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
expect(container.querySelector('img')).toHaveAttribute('alt', '');
});
it('lazy-loads the image', () => {
const { container } = render(<FondoFoto src="/images/test.jpg" />);
expect(container.querySelector('img')).toHaveAttribute('loading', 'lazy');
});
it('tints the photo into the brand duotone via mix-blend-mode, not raw colour', () => {
const { container } = render(<FondoFoto src="/images/test.jpg" />);
expect(container.querySelector('img')?.className).toContain('mix-blend-luminosity');
});
});
@@ -0,0 +1,21 @@
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { FondoPuntos } from '../FondoPuntos';
describe('<FondoPuntos />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<FondoPuntos />);
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
});
it('renders the dot pattern', () => {
const { container } = render(<FondoPuntos />);
expect(container.querySelectorAll('circle').length).toBeGreaterThan(0);
});
it('does not throw when the cursor moves, with or without interactivo', () => {
const { container } = render(<FondoPuntos interactivo />);
expect(() => fireEvent.mouseMove(window, { clientX: 100, clientY: 100 })).not.toThrow();
expect(container.querySelectorAll('circle').length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,34 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Icono, type IconoNombre } from '../Icono';
const NOMBRES: IconoNombre[] = [
'inmersion',
'desafiar',
'estrategia',
'escribeme',
'diagnostico',
'plan',
'comunicacion',
'digital',
'experiencias',
];
describe('<Icono />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<Icono nombre="inmersion" />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it.each(NOMBRES)('renders a non-empty svg for "%s"', (nombre) => {
const { container } = render(<Icono nombre={nombre} />);
const svg = container.querySelector('svg');
expect(svg?.children.length).toBeGreaterThan(0);
});
it('takes its colour from currentColor via className, not a hardcoded fill', () => {
const { container } = render(<Icono nombre="plan" className="text-dorado" />);
expect(container.querySelector('svg')).toHaveAttribute('stroke', 'currentColor');
expect(container.querySelector('svg')).toHaveClass('text-dorado');
});
});
@@ -0,0 +1,20 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { IlustracionInflexion } from '../IlustracionInflexion';
describe('<IlustracionInflexion />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<IlustracionInflexion />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it('draws the growth line, the drift-away line and the strategy line', () => {
const { container } = render(<IlustracionInflexion />);
expect(container.querySelectorAll('path')).toHaveLength(3);
});
it('marks the inflection point itself', () => {
const { container } = render(<IlustracionInflexion />);
expect(container.querySelectorAll('circle').length).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,26 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { PatronPuntos } from '../PatronPuntos';
describe('<PatronPuntos />', () => {
it('draws rows x cols dots', () => {
const { container } = render(<PatronPuntos rows={3} cols={8} />);
expect(container.querySelectorAll('circle')).toHaveLength(24);
});
it('shrinks the dots left to right, as the manual does', () => {
const { container } = render(<PatronPuntos rows={1} cols={6} />);
const radii = Array.from(container.querySelectorAll('circle')).map((c) => Number(c.getAttribute('r')));
for (let i = 1; i < radii.length; i++) expect(radii[i]).toBeLessThan(radii[i - 1]);
});
it('is decorative, so it stays out of the accessibility tree', () => {
const { container } = render(<PatronPuntos />);
const svg = container.querySelector('svg');
expect(svg?.getAttribute('aria-hidden')).toBe('true');
});
it('takes its colour from currentColor so it works on light and dark', () => {
const { container } = render(<PatronPuntos rows={1} cols={2} />);
for (const c of Array.from(container.querySelectorAll('circle'))) {
expect(c.getAttribute('fill')).toBe('currentColor');
}
});
});
@@ -0,0 +1,35 @@
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { TarjetaSpotlight } from '../TarjetaSpotlight';
describe('<TarjetaSpotlight />', () => {
it('renders its children', () => {
const { getByText } = render(
<TarjetaSpotlight>
<p>Contenido de la tarjeta</p>
</TarjetaSpotlight>,
);
expect(getByText('Contenido de la tarjeta')).toBeInTheDocument();
});
it('disables the glow for reduced-motion users via a motion-reduce guard', () => {
const { container } = render(<TarjetaSpotlight>hola</TarjetaSpotlight>);
expect(container.firstElementChild?.className).toContain('motion-reduce:before:hidden');
});
it('tracks the cursor position as CSS custom properties on mouse move', () => {
const { container } = render(<TarjetaSpotlight>hola</TarjetaSpotlight>);
const card = container.firstElementChild as HTMLDivElement;
fireEvent.mouseMove(card, { clientX: 40, clientY: 20 });
expect(card.style.getPropertyValue('--spot-x')).toMatch(/px$/);
expect(card.style.getPropertyValue('--spot-y')).toMatch(/px$/);
});
it('accepts a custom spotlight colour', () => {
const { container } = render(
<TarjetaSpotlight spotlightColor="rgba(0,0,0,0.5)">hola</TarjetaSpotlight>,
);
const card = container.firstElementChild as HTMLDivElement;
expect(card.style.getPropertyValue('--spot-color')).toBe('rgba(0,0,0,0.5)');
});
});
@@ -0,0 +1,28 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { TextoDesenfoque } from '../TextoDesenfoque';
describe('<TextoDesenfoque />', () => {
it('renders the full phrase across all segments', () => {
const { container } = render(
<TextoDesenfoque
segments={[{ text: 'Tu negocio no necesita' }, { text: 'más esfuerzo.', className: 'text-dorado' }]}
/>,
);
expect(container.textContent).toBe('Tu negocio no necesita más esfuerzo.');
});
it('joins words with a plain space, not a stray non-breaking space', () => {
const { container } = render(<TextoDesenfoque segments={[{ text: 'Uno dos tres' }]} />);
expect(container.textContent).toBe('Uno dos tres');
expect(container.textContent).not.toMatch(/ /);
});
it('applies each segment\'s className to its own words only', () => {
const { container } = render(
<TextoDesenfoque segments={[{ text: 'texto plano' }, { text: 'texto dorado', className: 'text-dorado' }]} />,
);
const goldWords = Array.from(container.querySelectorAll('.text-dorado')).map((el) => el.textContent?.trim());
expect(goldWords).toEqual(['texto', 'dorado']);
});
});
+22
View File
@@ -35,3 +35,25 @@ export const motionProps = (reduced: boolean) =>
reduced
? { initial: 'visible' as const, animate: 'visible' as const, whileInView: undefined, viewport: undefined }
: {};
// Cycles through `count` steps on its own (Mecanismo, ComoEmpezar): each
// manual change restarts the dwell timer, hovering pauses it, and it never
// advances at all for reduced-motion users — same shape as the Carrusel
// autoplay, for step indicators that aren't a carousel.
export function useAutoStep(count: number, intervalMs = 3500) {
const reduced = usePrefersReducedMotion();
const [active, setActive] = useState(0);
const [paused, setPaused] = useState(false);
useEffect(() => {
if (reduced || paused || count <= 1) return;
const id = setTimeout(() => setActive((a) => (a + 1) % count), intervalMs);
return () => clearTimeout(id);
}, [active, reduced, paused, count, intervalMs]);
return {
active,
setActive,
hoverProps: { onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false) },
};
}