+ );
+}
diff --git a/components/primitives/ContadorNumero.tsx b/components/primitives/ContadorNumero.tsx
new file mode 100644
index 0000000..193fd6e
--- /dev/null
+++ b/components/primitives/ContadorNumero.tsx
@@ -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(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 (
+
+ {display}
+ {sufijo}
+
+ );
+}
diff --git a/components/primitives/FondoFoto.tsx b/components/primitives/FondoFoto.tsx
new file mode 100644
index 0000000..f901521
--- /dev/null
+++ b/components/primitives/FondoFoto.tsx
@@ -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 (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- decorative background on a `output: export` site with images.unoptimized */}
+
+
+
+ );
+}
diff --git a/components/primitives/FondoPuntos.tsx b/components/primitives/FondoPuntos.tsx
new file mode 100644
index 0000000..30f3d04
--- /dev/null
+++ b/components/primitives/FondoPuntos.tsx
@@ -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(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 (
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/primitives/Icono.tsx b/components/primitives/Icono.tsx
new file mode 100644
index 0000000..6c3da48
--- /dev/null
+++ b/components/primitives/Icono.tsx
@@ -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 = {
+ // A line dives through two layers down to a core point — "me sumerjo
+ // hasta comprenderla desde dentro".
+ inmersion: (
+ <>
+
+
+
+
+
+ >
+ ),
+ // A circle (the assumption) cracked by a bolt — "cuestiono lo que todos
+ // dan por hecho".
+ desafiar: (
+ <>
+
+
+ >
+ ),
+ // A path rising to a flag — "una estrategia que puedas ejecutar".
+ estrategia: (
+ <>
+
+
+
+ >
+ ),
+ // A speech bubble mid-thought — "cuéntame dónde está tu negocio hoy".
+ escribeme: (
+ <>
+
+
+
+ >
+ ),
+ // A magnifying glass over a short list — "una conversación para ubicar tu
+ // punto de inflexión".
+ diagnostico: (
+ <>
+
+
+
+
+
+ >
+ ),
+ // A short checklist — "sales con claridad y tu siguiente movimiento
+ // definido".
+ plan: (
+ <>
+
+
+
+
+
+ >
+ ),
+ // Two overlapping bubbles — mensaje que alinea a tu equipo y tu mercado.
+ comunicacion: (
+ <>
+
+
+ >
+ ),
+ // A screen with a signal — presencia y procesos que escalan.
+ digital: (
+ <>
+
+
+
+
+
+ >
+ ),
+ // A ripple from a single touch — cada toque con tu marca construye
+ // confianza.
+ experiencias: (
+ <>
+
+
+
+ >
+ ),
+};
+
+export function Icono({ nombre, className = '' }: { nombre: IconoNombre; className?: string }) {
+ return (
+
+ );
+}
diff --git a/components/primitives/IlustracionInflexion.tsx b/components/primitives/IlustracionInflexion.tsx
new file mode 100644
index 0000000..6caa26c
--- /dev/null
+++ b/components/primitives/IlustracionInflexion.tsx
@@ -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(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 s
+ // instead of trying to neutralise the animated ones.
+ if (reduced) {
+ return (
+
+ );
+ }
+
+ 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 (
+
+ );
+}
diff --git a/components/primitives/PatronPuntos.tsx b/components/primitives/PatronPuntos.tsx
new file mode 100644
index 0000000..5cb5fb2
--- /dev/null
+++ b/components/primitives/PatronPuntos.tsx
@@ -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 (
+
+ );
+}
diff --git a/components/primitives/TarjetaSpotlight.tsx b/components/primitives/TarjetaSpotlight.tsx
new file mode 100644
index 0000000..45797ea
--- /dev/null
+++ b/components/primitives/TarjetaSpotlight.tsx
@@ -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(null);
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ 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 (
+
+ {children}
+
+ );
+}
diff --git a/components/primitives/TextoDesenfoque.tsx b/components/primitives/TextoDesenfoque.tsx
new file mode 100644
index 0000000..81a93a2
--- /dev/null
+++ b/components/primitives/TextoDesenfoque.tsx
@@ -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(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 (
+
+ {segments.map((seg, i) => (
+
+ {seg.text}
+ {i < segments.length - 1 ? ' ' : ''}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {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.
+
+
+ {w}
+
+ {i < words.length - 1 ? ' ' : ''}
+
+ ))}
+
+ );
+}
diff --git a/components/primitives/__tests__/Carrusel.test.tsx b/components/primitives/__tests__/Carrusel.test.tsx
new file mode 100644
index 0000000..f801919
--- /dev/null
+++ b/components/primitives/__tests__/Carrusel.test.tsx
@@ -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('', () => {
+ it('renders every slide', () => {
+ render(Uno
,
Dos
,
Tres
]} />);
+ 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(Uno,
Dos
]} />);
+ 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(Uno,
Dos
]} />);
+ 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(Solo una]} />);
+ expect(screen.queryByRole('button', { name: /ir a la tarjeta/i })).not.toBeInTheDocument();
+ });
+ it('exposes the slide group with the given accessible name', () => {
+ render(Uno]} />);
+ 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(Uno,
Dos
,
Tres
]} />);
+ 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(Uno,
Dos
]} />);
+ await user.click(screen.getByRole('button', { name: 'Siguiente' }));
+ expect(screen.getByText('02')).toBeInTheDocument();
+ });
+});
diff --git a/components/primitives/__tests__/ContadorNumero.test.tsx b/components/primitives/__tests__/ContadorNumero.test.tsx
new file mode 100644
index 0000000..9129ef6
--- /dev/null
+++ b/components/primitives/__tests__/ContadorNumero.test.tsx
@@ -0,0 +1,20 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { ContadorNumero } from '../ContadorNumero';
+
+describe('', () => {
+ it('starts at 0 and only counts up once scrolled into view', () => {
+ const { container } = render();
+ expect(container.textContent).toBe('0');
+ });
+
+ it('appends the given suffix', () => {
+ const { container } = render();
+ expect(container.textContent).toBe('0+');
+ });
+
+ it('applies the given className to the counter span', () => {
+ const { container } = render();
+ expect(container.querySelector('span')).toHaveClass('text-dorado');
+ });
+});
diff --git a/components/primitives/__tests__/FondoFoto.test.tsx b/components/primitives/__tests__/FondoFoto.test.tsx
new file mode 100644
index 0000000..7e239dc
--- /dev/null
+++ b/components/primitives/__tests__/FondoFoto.test.tsx
@@ -0,0 +1,19 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { FondoFoto } from '../FondoFoto';
+
+describe('', () => {
+ it('is decorative: hidden from assistive tech, empty alt text', () => {
+ const { container } = render();
+ expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
+ expect(container.querySelector('img')).toHaveAttribute('alt', '');
+ });
+ it('lazy-loads the image', () => {
+ const { container } = render();
+ 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();
+ expect(container.querySelector('img')?.className).toContain('mix-blend-luminosity');
+ });
+});
diff --git a/components/primitives/__tests__/FondoPuntos.test.tsx b/components/primitives/__tests__/FondoPuntos.test.tsx
new file mode 100644
index 0000000..5f81a41
--- /dev/null
+++ b/components/primitives/__tests__/FondoPuntos.test.tsx
@@ -0,0 +1,21 @@
+import { render, fireEvent } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { FondoPuntos } from '../FondoPuntos';
+
+describe('', () => {
+ it('is decorative: hidden from assistive tech', () => {
+ const { container } = render();
+ expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('renders the dot pattern', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('circle').length).toBeGreaterThan(0);
+ });
+
+ it('does not throw when the cursor moves, with or without interactivo', () => {
+ const { container } = render();
+ expect(() => fireEvent.mouseMove(window, { clientX: 100, clientY: 100 })).not.toThrow();
+ expect(container.querySelectorAll('circle').length).toBeGreaterThan(0);
+ });
+});
diff --git a/components/primitives/__tests__/Icono.test.tsx b/components/primitives/__tests__/Icono.test.tsx
new file mode 100644
index 0000000..cf9452f
--- /dev/null
+++ b/components/primitives/__tests__/Icono.test.tsx
@@ -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('', () => {
+ it('is decorative: hidden from assistive tech', () => {
+ const { container } = render();
+ expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it.each(NOMBRES)('renders a non-empty svg for "%s"', (nombre) => {
+ const { container } = render();
+ 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();
+ expect(container.querySelector('svg')).toHaveAttribute('stroke', 'currentColor');
+ expect(container.querySelector('svg')).toHaveClass('text-dorado');
+ });
+});
diff --git a/components/primitives/__tests__/IlustracionInflexion.test.tsx b/components/primitives/__tests__/IlustracionInflexion.test.tsx
new file mode 100644
index 0000000..32ec849
--- /dev/null
+++ b/components/primitives/__tests__/IlustracionInflexion.test.tsx
@@ -0,0 +1,20 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { IlustracionInflexion } from '../IlustracionInflexion';
+
+describe('', () => {
+ it('is decorative: hidden from assistive tech', () => {
+ const { container } = render();
+ expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('draws the growth line, the drift-away line and the strategy line', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('path')).toHaveLength(3);
+ });
+
+ it('marks the inflection point itself', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('circle').length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/components/primitives/__tests__/PatronPuntos.test.tsx b/components/primitives/__tests__/PatronPuntos.test.tsx
new file mode 100644
index 0000000..df0e094
--- /dev/null
+++ b/components/primitives/__tests__/PatronPuntos.test.tsx
@@ -0,0 +1,26 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { PatronPuntos } from '../PatronPuntos';
+
+describe('', () => {
+ it('draws rows x cols dots', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('circle')).toHaveLength(24);
+ });
+ it('shrinks the dots left to right, as the manual does', () => {
+ const { container } = render();
+ 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();
+ 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();
+ for (const c of Array.from(container.querySelectorAll('circle'))) {
+ expect(c.getAttribute('fill')).toBe('currentColor');
+ }
+ });
+});
diff --git a/components/primitives/__tests__/TarjetaSpotlight.test.tsx b/components/primitives/__tests__/TarjetaSpotlight.test.tsx
new file mode 100644
index 0000000..9659107
--- /dev/null
+++ b/components/primitives/__tests__/TarjetaSpotlight.test.tsx
@@ -0,0 +1,35 @@
+import { render, fireEvent } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { TarjetaSpotlight } from '../TarjetaSpotlight';
+
+describe('', () => {
+ it('renders its children', () => {
+ const { getByText } = render(
+
+
Contenido de la tarjeta
+ ,
+ );
+ expect(getByText('Contenido de la tarjeta')).toBeInTheDocument();
+ });
+
+ it('disables the glow for reduced-motion users via a motion-reduce guard', () => {
+ const { container } = render(hola);
+ expect(container.firstElementChild?.className).toContain('motion-reduce:before:hidden');
+ });
+
+ it('tracks the cursor position as CSS custom properties on mouse move', () => {
+ const { container } = render(hola);
+ 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(
+ hola,
+ );
+ const card = container.firstElementChild as HTMLDivElement;
+ expect(card.style.getPropertyValue('--spot-color')).toBe('rgba(0,0,0,0.5)');
+ });
+});
diff --git a/components/primitives/__tests__/TextoDesenfoque.test.tsx b/components/primitives/__tests__/TextoDesenfoque.test.tsx
new file mode 100644
index 0000000..7fee9c1
--- /dev/null
+++ b/components/primitives/__tests__/TextoDesenfoque.test.tsx
@@ -0,0 +1,28 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { TextoDesenfoque } from '../TextoDesenfoque';
+
+describe('', () => {
+ it('renders the full phrase across all segments', () => {
+ const { container } = render(
+ ,
+ );
+ 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();
+ 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(
+ ,
+ );
+ const goldWords = Array.from(container.querySelectorAll('.text-dorado')).map((el) => el.textContent?.trim());
+ expect(goldWords).toEqual(['texto', 'dorado']);
+ });
+});
diff --git a/lib/motion.ts b/lib/motion.ts
index e3f5c49..4c40caf 100644
--- a/lib/motion.ts
+++ b/lib/motion.ts
@@ -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) },
+ };
+}