feat: rediseño «El documento» en curso, contacto real y enlaces externos

Primitivas y contenido del rediseño (escala tipográfica, Revelar,
MarcaSeccion, Seccion, Antetitulo, SimboloMarca, Resplandor, ClaridadHero,
sección Proceso de ocho etapas, lib/content.ts reestructurado en seis
secciones con alias para las viejas). Mecanismo y FormaTrabajar se retiran.

Encima, lo de esta sesión:
- recorrido: creeer vuelve a responder cada situación en vez de repetirla
  (el alias apuntaba respuesta a situacion); respuestas en plural. Créditos
  de las seis fotos de México.
- contacto: WhatsApp, email (con mailto:) y LinkedIn reales; iconos de las
  redes en el cierre y el footer (IconoRed, trazos de Simple Icons, CC0).
- enlaces: lo externo abre en otra pestaña con noopener y aviso para
  lectores de pantalla (lib/enlaces.ts); mailto y anclas se quedan.
- Proceso: las ocho tarjetas no aparecían nunca en móvil (amount 0.3 sobre
  una lista de ~3000px); ahora cada una se revela sola. El ≡ de la 08 ya no
  tapa un entregable, título equilibrado, etiqueta a 12px, sin elevar al
  hover lo que no es clicable.
- Disciplinas: la frase «No son la promesa — son los medios» gana peso y
  se agrupa con título e ilustración; sin cortes huérfanos en móvil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5.5 committed 2026-09-30 11:27:20 -06:00
1 parent 3242a8bf71
commit 89e54a6e84
73 files changed
+5261 -592

No files matched your search

+18 -3
View File
@@ -1,6 +1,8 @@
import { Wordmark } from '@/components/primitives/Wordmark';
import { Container } from '@/components/primitives/Container';
import { footer, creditosFotos } from '@/lib/content';
import { IconoRed, NOMBRES_RED } from '@/components/primitives/IconoRed';
import { footer, creditosFotos, empezar } from '@/lib/content';
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
export function Footer() {
return (
@@ -8,6 +10,19 @@ export function Footer() {
<Container className="flex flex-col items-center gap-3 py-10 text-center md:flex-row md:justify-between md:text-left">
<Wordmark size="sm" onDark />
<p className="text-sm text-gris-medio">{footer.tagline}</p>
<ul aria-label="Redes y contacto" className="flex items-center gap-2">
{NOMBRES_RED.map((red) => (
<li key={red}>
<a href={empezar.contacto[red].href} {...propsEnlace(empezar.contacto[red].href)}
aria-label={esExterno(empezar.contacto[red].href)
? `${empezar.contacto[red].label} (${AVISO_PESTANA})`
: empezar.contacto[red].label}
className="grid place-items-center w-10 h-10 rounded-full text-gris-medio hover:text-dorado transition-colors">
<IconoRed nombre={red} className="w-5 h-5" />
</a>
</li>
))}
</ul>
<p className="text-xs text-gris-medio">{footer.legal}</p>
</Container>
<Container className="pb-6 text-center md:text-left">
@@ -15,8 +30,8 @@ export function Footer() {
{creditosFotos.map((c, i) => (
<span key={c.href}>
{i > 0 && ' · '}
<a href={c.href} className="hover:text-gris-medio">{c.desc}</a>, modificada,{' '}
<a href={c.licenciaHref} className="hover:text-gris-medio">{c.licencia}</a>
<a href={c.href} {...propsEnlace(c.href)} className="hover:text-gris-medio">{c.desc}<span className="sr-only"> ({AVISO_PESTANA})</span></a>, modificada,{' '}
<a href={c.licenciaHref} {...propsEnlace(c.licenciaHref)} className="hover:text-gris-medio">{c.licencia}<span className="sr-only"> ({AVISO_PESTANA})</span></a>
</span>
))}
</p>
@@ -29,3 +29,29 @@ describe('<Footer />', () => {
expect(screen.getAllByText(/modificada/)).toHaveLength(creditosFotos.length);
});
});
describe('<Footer /> — redes', () => {
it('links every contact channel by name, since the icons alone say nothing to a screen reader', () => {
render(<Footer />);
const lista = screen.getByRole('list', { name: /redes y contacto/i });
expect(lista.querySelectorAll('a')).toHaveLength(3);
expect(screen.getByRole('link', { name: /^WhatsApp/ })).toHaveAttribute('href', expect.stringMatching(/^https:\/\/wa\.me\//));
expect(screen.getByRole('link', { name: 'Email' })).toHaveAttribute('href', expect.stringMatching(/^mailto:/));
expect(screen.getByRole('link', { name: /^LinkedIn/ })).toHaveAttribute('href', expect.stringMatching(/linkedin\./));
});
it('opens every outside link in a new tab, so the reader keeps the page', () => {
render(<Footer />);
for (const a of screen.getAllByRole('link')) {
const href = a.getAttribute('href') ?? '';
if (/^https?:/.test(href)) {
expect(a).toHaveAttribute('target', '_blank');
expect(a).toHaveAttribute('rel', 'noopener noreferrer');
expect(a).toHaveAccessibleName(/se abre en otra pestaña/);
} else {
// mailto: in a new tab only leaves an empty tab behind.
expect(a).not.toHaveAttribute('target');
}
}
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { ElementType, ReactNode } from 'react';
// The short gold line in letter-spaced caps that announces each section.
//
// It used to be hand-written in eight places, and the colour travelled with it:
// `text-dorado` reads at 6,07:1 on azul-profundo and at 2,76:1 on white, so
// half of those copies were illegible and every fix had to be made eight times.
// The rule lives here now, and the default is the safe one — forgetting the
// prop cannot produce an unreadable antetítulo.
//
// `as` takes `motion.p` where the section wants it to animate in, and `h2`
// where the antetítulo is the only heading the section has.
type Fondo = 'claro' | 'oscuro';
const tono: Record<Fondo, string> = {
claro: 'text-dorado-texto',
oscuro: 'text-dorado',
};
export function Antetitulo({
children,
sobre = 'claro',
as: Tag = 'p',
className = '',
...rest
}: {
children: ReactNode;
sobre?: Fondo;
as?: ElementType;
className?: string;
// Whatever the caller's `as` needs — in practice Framer's variants/viewport
// props when a section wants its antetítulo to animate in.
[prop: string]: unknown;
}) {
return (
<Tag className={['text-rotulo uppercase font-semibold', tono[sobre], className].filter(Boolean).join(' ')} {...rest}>
{children}
</Tag>
);
}
+7 -1
View File
@@ -1,9 +1,13 @@
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
type Variant = 'light' | 'dark' | 'ghost';
// Every variant carries border-2 so filled and outlined CTAs line up when
// they sit side by side.
const styles: Record<Variant, string> = {
light: 'border-2 border-transparent bg-azul-profundo text-white hover:bg-azul-profundo/90',
dark: 'border-2 border-transparent bg-dorado text-white hover:bg-dorado/90',
// Azul profundo sobre dorado, no blanco: blanco sobre #C4942E es 2,76:1 y
// esta es la variante del botón que cierra la página.
dark: 'border-2 border-transparent bg-dorado text-azul-profundo hover:bg-dorado/90',
ghost: 'border-2 border-azul-profundo text-azul-profundo hover:bg-azul-profundo hover:text-white',
};
@@ -11,10 +15,12 @@ export function CTA({ href, label, variant = 'light', className = '' }: { href:
return (
<a
href={href}
{...propsEnlace(href)}
className={`group inline-block font-sans font-bold uppercase tracking-wider px-7 py-3.5 text-sm transition-colors ${styles[variant]} ${className}`}
>
{label}{' '}
<span aria-hidden="true" className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transform-none motion-reduce:transition-none">→</span>
{esExterno(href) && <span className="sr-only"> ({AVISO_PESTANA})</span>}
</a>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
// The building's public door, and the only file a section should import.
//
// three.js is around 185 kB gzipped and the hero is the LCP — the page opens
// from a WhatsApp link on mobile data, so the headline, the CTAs and the Dream
// Outcome have to be painted and clickable long before any of this arrives.
// `dynamic` holds the scene back until after hydration, and `ssr: false` is not
// optional: three.js needs a real canvas, which the static export does not have
// at build time.
//
// The `dynamic` call lives here, behind 'use client', because Next 14 refuses
// `ssr: false` inside a Server Component — and `Hero` is one, deliberately: it
// is almost all text and has no reason to ship as JavaScript.
const EscenaClaridad = dynamic(
() => import('@/components/primitives/EscenaClaridad').then((m) => m.EscenaClaridad),
{ ssr: false, loading: () => null },
);
export function ClaridadHero({ className = '' }: { className?: string }) {
const contenedor = useRef<HTMLDivElement>(null);
const [cerca, setCerca] = useState(false);
// On a phone the drawing sits under the two CTAs, a screen and a half down,
// and plenty of readers tap through without ever reaching it. Deferring to
// hydration is not enough: the chunk waits until the box is nearly in view,
// the same deal the six photos of the recorrido got. On a laptop the box is
// beside the headline, so this resolves on the first observation and costs
// nothing.
useEffect(() => {
const el = contenedor.current;
if (!el) return;
if (typeof IntersectionObserver === 'undefined') {
setCerca(true);
return;
}
const obs = new IntersectionObserver(
([entrada]) => {
if (!entrada.isIntersecting) return;
setCerca(true);
obs.disconnect();
},
{ rootMargin: '300px' },
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div ref={contenedor} className={className} aria-hidden="true">
{cerca ? <EscenaClaridad className="w-full h-full" /> : null}
</div>
);
}
+255 -9
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
// Set of simple line icons — one per concept used across Mecanismo,
// Set of simple line icons — one per concept used across Proceso,
// 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
@@ -16,10 +16,39 @@ export type IconoNombre =
| 'comunicacion'
| 'digital'
| 'experiencias'
| 'reloj';
| 'consumidor'
| 'ventas'
| 'compania'
// Entregables — one mark per deliverable, the way the lámina gives every
// line its own.
| 'matriz'
| 'prioridades'
| 'rayo'
| 'mapa'
| 'restriccion'
| 'brote'
| 'segmentos'
| 'ficha'
| 'recorrido'
| 'hueco'
| 'diana'
| 'arquitectura'
| 'libro'
| 'megafono'
| 'calendario'
| 'ondas'
| 'documento'
| 'medidor'
| 'tablero'
| 'comite'
| 'hitos'
| 'cohete'
| 'bombilla'
| 'anio'
| 'escudo';
const PATHS: Record<IconoNombre, ReactNode> = {
// A line dives through two layers down to a core point — "me sumerjo
// A line dives through two layers down to a core point — "nos sumergimos
// hasta comprenderla desde dentro".
inmersion: (
<>
@@ -30,7 +59,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<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
// A circle (the assumption) cracked by a bolt — "cuestionamos lo que todos
// dan por hecho".
desafiar: (
<>
@@ -46,7 +75,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<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".
// A speech bubble mid-thought — "cuéntanos dónde está tu negocio hoy".
escribeme: (
<>
<path d="M4 5.5h16v10H10l-4 3.5v-3.5H4z" />
@@ -102,15 +131,232 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<circle cx="12" cy="12" r="9" strokeOpacity={0.55} />
</>
),
// Simple clock — "cada proceso tiene su ritmo".
reloj: (
// A person with the insight sitting inside the head, not beside it — the
// point of Consumer Knowledge is what you find in there.
consumidor: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3.5 3.5" />
<circle cx="12" cy="7.5" r="3.75" />
<circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none" />
<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
</>
),
// Three rising bars under an arrow — sell in, sell out, rotación.
ventas: (
<>
<path d="M3.5 20.5h17" />
<path d="M7 20.5v-4.5" />
<path d="M12 20.5v-8" />
<path d="M17 20.5v-9" />
<path d="m17 4 2.4 4h-4.8z" fill="currentColor" stroke="none" />
<path d="M17 8v3.5" />
</>
),
// Two towers — the company seen from outside. Stage 01 needs its own mark
// because "Diagnóstico ejecutivo", one of its own deliverables, already has
// the magnifier, and the two would sit repeated in the same card.
compania: (
<>
<path d="M3 20.5h18" />
<path d="M5.5 20.5V5.5l6.5-3v18" />
<path d="m12 10.5 6.5 2.8v7.2" />
<circle cx="8.3" cy="9" r=".75" fill="currentColor" stroke="none" />
<circle cx="8.3" cy="13.5" r=".75" fill="currentColor" stroke="none" />
<circle cx="15.3" cy="16" r=".75" fill="currentColor" stroke="none" />
</>
),
// ── Entregables ────────────────────────────────────────────────────────
// Deliberately plainer than the concept icons above: these render at 18px
// inside the deliverables list, where anything busier turns to mush.
// Four quadrants — the SWOT grid itself.
matriz: (
<>
<rect x="3.5" y="3.5" width="17" height="17" rx="1.5" />
<path d="M12 3.5v17M3.5 12h17" />
</>
),
// Three rules, longest first — a ranked list.
prioridades: (
<>
<path d="M4 6.5h16" />
<path d="M4 12h10.5" />
<path d="M4 17.5h5.5" />
</>
),
// A bolt — the win you can take this quarter.
rayo: <path d="M13.5 2.5 5 13.5h5.5l-1 8L19 10h-6z" />,
// Folded map.
mapa: (
<>
<path d="m3.5 6.5 5.5-2.5 6 2.5 5.5-2.5v13l-5.5 2.5-6-2.5-5.5 2.5z" />
<path d="M9 4v15M15 6.5v13" />
</>
),
// A funnel — where the business narrows.
restriccion: <path d="M3.5 4.5h17l-6.3 7.6v7.4l-4.4 2.5V12.1z" />,
// A sprout.
brote: (
<>
<path d="M12 21v-8.5" />
<path d="M12 12.5c0-3 2.2-5.2 5.2-5.2 0 3-2.2 5.2-5.2 5.2z" />
<path d="M12 16c0-2.4-1.8-4.2-4.2-4.2 0 2.4 1.8 4.2 4.2 4.2z" />
</>
),
// A disc cut into parts.
segmentos: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 3.5v8.5l7.4 4.3" />
</>
),
// An ID card — the persona, written down.
ficha: (
<>
<rect x="2.5" y="5" width="19" height="14" rx="1.6" />
<circle cx="8.5" cy="10.5" r="2" />
<path d="M5.4 15.6a3.3 3.3 0 0 1 6.2 0" />
<path d="M14.5 10.5h4.5M14.5 14h4.5" />
</>
),
// Waypoints along a dotted route.
recorrido: (
<>
<path d="M4.5 19.5c4.5 0 3-6 7-6s3.5-7 8-7" strokeDasharray="1.6 3" />
<circle cx="4.5" cy="19.5" r="1.6" fill="currentColor" stroke="none" />
<circle cx="19.5" cy="6.5" r="1.6" fill="currentColor" stroke="none" />
</>
),
// The gap between two solid runs — the need nobody covers.
hueco: (
<>
<path d="M2.5 12h5.5M16 12h5.5" />
<circle cx="12" cy="12" r="3.4" strokeDasharray="2 2.6" />
</>
),
// Crosshair on the target.
diana: (
<>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none" />
<path d="M12 1.5V5M12 19v3.5M1.5 12H5M19 12h3.5" />
</>
),
// A parent and two children.
arquitectura: (
<>
<rect x="9" y="2.5" width="6" height="4.5" rx="1" />
<rect x="2" y="17" width="6" height="4.5" rx="1" />
<rect x="16" y="17" width="6" height="4.5" rx="1" />
<path d="M12 7v3.5M5 17v-3.5h14V17" />
</>
),
// An open book.
libro: (
<>
<path d="M12 6.5v13" />
<path d="M12 6.5C10.5 5 8.5 4.5 4 4.5v13c4.5 0 6.5.5 8 2" />
<path d="M12 6.5c1.5-1.5 3.5-2 8-2v13c-4.5 0-6.5.5-8 2" />
</>
),
// A megaphone.
megafono: (
<>
<path d="M3.5 10.5v3A1.5 1.5 0 0 0 5 15h2.5l7 4V5l-7 4H5a1.5 1.5 0 0 0-1.5 1.5z" />
<path d="M18 9.5a4 4 0 0 1 0 5" />
</>
),
// A month blocked out.
calendario: (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="1.5" />
<path d="M3.5 9.5h17M8 2.5V6M16 2.5V6" />
<path d="M7.5 14h6" />
</>
),
// Broadcast.
ondas: (
<>
<circle cx="12" cy="15" r="1.8" fill="currentColor" stroke="none" />
<path d="M8.8 11.6a4.5 4.5 0 0 1 6.4 0" />
<path d="M5.8 8.4a9 9 0 0 1 12.4 0" />
<path d="M12 16.8V21" />
</>
),
// A written page.
documento: (
<>
<path d="M5.5 3.5h8l5 5v12h-13z" />
<path d="M13.5 3.5V9h5" />
<path d="M8.5 13h7M8.5 16.5h4.5" />
</>
),
// A gauge — the number the CEO watches.
medidor: (
<>
<path d="M3.5 17.5a8.5 8.5 0 0 1 17 0" />
<path d="m12 17.5 4.6-5.2" />
<circle cx="12" cy="17.5" r="1.4" fill="currentColor" stroke="none" />
</>
),
// Bars inside a frame.
tablero: (
<>
<rect x="3" y="4" width="18" height="16" rx="1.5" />
<path d="M7.5 16v-3.5M12 16v-6.5M16.5 16v-4.5" />
</>
),
// Three round a table.
comite: (
<>
<circle cx="12" cy="6" r="2.4" />
<circle cx="5.2" cy="9.5" r="2" />
<circle cx="18.8" cy="9.5" r="2" />
<path d="M2.5 19.5h19" />
<path d="M8 19.5a4 4 0 0 1 8 0" />
</>
),
// Milestones on the line, the last one reached.
hitos: (
<>
<path d="M2.5 12h19" />
<circle cx="6.5" cy="12" r="2.1" />
<circle cx="12" cy="12" r="2.1" />
<circle cx="17.5" cy="12" r="2.1" fill="currentColor" stroke="none" />
</>
),
// Launch.
cohete: (
<>
<path d="M12 2.5c3 2.4 4.5 5.7 4.5 9.5L12 16l-4.5-4c0-3.8 1.5-7.1 4.5-9.5z" />
<circle cx="12" cy="9.5" r="1.6" fill="currentColor" stroke="none" />
<path d="M9.2 16.2 6.5 21l4-1.6M14.8 16.2 17.5 21l-4-1.6" />
</>
),
// A lit idea.
bombilla: (
<>
<path d="M8.5 14.5a5 5 0 1 1 7 0c-.8.8-1.2 1.5-1.2 2.5h-4.6c0-1-.4-1.7-1.2-2.5z" />
<path d="M10 20h4" />
</>
),
// A planner full of months.
anio: (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="1.5" />
<path d="M3.5 9.5h17M8 2.5V6M16 2.5V6" />
<path d="M7 13h1.6M11.2 13h1.6M15.4 13h1.6M7 16.8h1.6M11.2 16.8h1.6M15.4 16.8h1.6" />
</>
),
// A shield — what keeps the growth from drifting.
escudo: <path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8.6 7.5 10 4.4-1.4 7.5-5.4 7.5-10v-6z" />,
};
// Every drawn icon, straight from the registry above — so a test iterating
// these can never fall behind the set that actually exists.
export const NOMBRES_ICONO = Object.keys(PATHS) as IconoNombre[];
export function Icono({ nombre, className = '' }: { nombre: IconoNombre; className?: string }) {
return (
<svg
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
// Contact marks. Unlike Icono these are not hand-drawn: a brand logo has to
// be the brand's own shape or it stops being recognisable, so WhatsApp and
// LinkedIn are the Simple Icons paths (CC0), filled with currentColor so they
// take the page's colour instead of their brand green and blue. Email is not a
// brand — it is an envelope in Icono's own line language.
export type RedNombre = 'whatsapp' | 'email' | 'linkedin';
const MARCAS: Record<RedNombre, ReactNode> = {
whatsapp: (
<path
fill="currentColor"
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"
/>
),
email: (
<g fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<rect x="2.5" y="4.5" width="19" height="15" rx="2" />
<path d="m3 6 9 7 9-7" />
</g>
),
linkedin: (
<path
fill="currentColor"
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
),
};
// Key order is display order: WhatsApp first, it is the primary channel.
export const NOMBRES_RED = Object.keys(MARCAS) as RedNombre[];
export function IconoRed({ nombre, className = '' }: { nombre: RedNombre; className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
{MARCAS[nombre]}
</svg>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo';
// The manual's philosophy — Complejidad → Enfoque → Claridad — as the
// document's own furniture instead of as an illustration parked in a corner.
// Each section carries one step of it in the margin column, so the reader walks
// the tangle resolving into the ≡ by scrolling.
//
// Static: the interpolation happens at render, there is no animation and no
// client JavaScript. The geometry it resolves into comes from lib/simbolo.ts,
// the same source the wordmark traces, so the mark it produces is provably the
// brand's and not a lookalike.
export const PASOS = 6;
// Hand-placed rather than random, so the mark is identical on every render and
// between server and client. Three lines per bar of the ≡.
const DISPERSAS = [
{ x1: 4, y1: 10, x2: 44, y2: 4, barra: 0 },
{ x1: 18, y1: 3, x2: 58, y2: 22, barra: 0 },
{ x1: 6, y1: 24, x2: 50, y2: 12, barra: 0 },
{ x1: 3, y1: 30, x2: 56, y2: 26, barra: 1 },
{ x1: 14, y1: 40, x2: 52, y2: 20, barra: 1 },
{ x1: 8, y1: 34, x2: 60, y2: 38, barra: 1 },
{ x1: 5, y1: 46, x2: 47, y2: 60, barra: 2 },
{ x1: 22, y1: 62, x2: 58, y2: 44, barra: 2 },
{ x1: 10, y1: 56, x2: 54, y2: 52, barra: 2 },
];
// One line per group survives to become a bar; the other six fade out as the
// page converges — many/disordered becomes few/aligned, per the manual.
const SOBREVIVE = [0, 3, 6];
const GROSOR_DISPERSO = 2;
export function MarcaSeccion({ paso, className = '' }: { paso: number; className?: string }) {
const t = Math.min(1, Math.max(0, paso / (PASOS - 1)));
const mezcla = (a: number, b: number) => a + (b - a) * t;
return (
<svg viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
{DISPERSAS.map((d, i) => {
const destino = LINEAS_SIMBOLO[d.barra];
const sobrevive = SOBREVIVE.includes(i);
return (
<line
key={i}
x1={mezcla(d.x1, destino.x1)}
y1={mezcla(d.y1, destino.y1)}
x2={mezcla(d.x2, destino.x2)}
y2={mezcla(d.y2, destino.y2)}
stroke="currentColor"
strokeWidth={sobrevive ? mezcla(GROSOR_DISPERSO, destino.grosor) : GROSOR_DISPERSO}
strokeLinecap="round"
opacity={sobrevive ? 1 : 1 - t}
/>
);
})}
</svg>
);
}
+2 -2
View File
@@ -94,7 +94,7 @@ export function ParDialogo({
resuelto ? 'opacity-100' : 'opacity-55'
}`}
>
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado">
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado-texto">
{String(n).padStart(2, '0')} · {name}
</p>
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
@@ -122,7 +122,7 @@ export function ParDialogo({
}`}
/>
<Wordmark size="sm" />
<p className="mt-3 text-gris-medio leading-relaxed">{respuesta}</p>
<p className="mt-3 text-azul-profundo/70 leading-relaxed">{respuesta}</p>
</div>
</motion.div>
</li>
+25
View File
@@ -0,0 +1,25 @@
// A soft light source dropped behind a section's content.
//
// A flat fill reads as a printed block: nothing tells the eye where the light
// comes from, so every section weighs the same. One of these, placed off to a
// side, gives the surface a direction and lets the gradient underneath do its
// work. Purely decorative — it carries no meaning and is hidden from assistive
// tech.
//
// The caller positions and sizes it; only the colour and the falloff live here.
export function Resplandor({
className = '',
tono = 'dorado',
}: {
className?: string;
tono?: 'dorado' | 'frio';
}) {
return (
<div
aria-hidden="true"
className={`pointer-events-none absolute rounded-full blur-[1px] ${
tono === 'dorado' ? 'bg-velo-dorado' : 'bg-velo-frio'
} ${className}`}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
// The page's only motion, and it is built the opposite way round from the
// system it replaces.
//
// The hidden state lives in CSS and applies only under `html.js`, a class an
// inline script in <head> adds before first paint. Nothing here writes
// `opacity: 0` into the markup, so the static export ships a page that reads in
// full — the previous architecture shipped 112 elements at opacity zero and
// went blank whenever the JavaScript failed to arrive.
//
// One module-level observer for the whole page rather than one per block.
let observador: IntersectionObserver | null = null;
function observadorCompartido() {
if (observador) return observador;
observador = new IntersectionObserver(
(entradas) => {
for (const entrada of entradas) {
if (!entrada.isIntersecting) continue;
entrada.target.setAttribute('data-visible', '');
observador?.unobserve(entrada.target);
}
},
// Fires a touch before the block is fully in, so the gesture has finished
// by the time the reader's eye lands on it.
{ rootMargin: '0px 0px -10% 0px' },
);
return observador;
}
export function Revelar({
children,
indice = 0,
className = '',
}: {
children: ReactNode;
indice?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const obs = observadorCompartido();
obs.observe(el);
return () => obs.unobserve(el);
}, []);
return (
<div ref={ref} data-revelar className={className} style={{ '--i': indice } as CSSProperties}>
{children}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
import { Container } from '@/components/primitives/Container';
import { MarcaSeccion } from '@/components/primitives/MarcaSeccion';
// The document's grid: a narrow margin column carrying the section number and
// its mark, and a wide column carrying everything else. That asymmetry is what
// makes the page read as a document rather than as a stack of landing blocks.
//
// Three vertical rhythms rather than one. Every section used to share
// `py-16 md:py-24`, which made ten sections go past like a metronome; the page
// needs somewhere to breathe and somewhere to hurry.
type Ritmo = 'compacta' | 'normal' | 'momento';
type Fondo = 'claro' | 'oscuro';
const RITMO: Record<Ritmo, string> = {
compacta: 'py-20 md:py-28',
normal: 'py-28 md:py-40',
momento: 'py-36 md:py-56',
};
const FONDO: Record<Fondo, string> = {
claro: 'bg-white text-azul-profundo',
oscuro: 'bg-azul-profundo bg-hondo-navy text-white',
};
// Gold on navy is 6,07:1; on white it is 2,76:1 and the mark is a graphic that
// carries meaning, so it needs 3:1.
const MARCA: Record<Fondo, string> = {
claro: 'text-dorado-texto',
oscuro: 'text-dorado',
};
export function Seccion({
numero,
paso,
ritmo = 'compacta',
fondo = 'claro',
id,
children,
}: {
numero: string;
paso: number;
ritmo?: Ritmo;
fondo?: Fondo;
id?: string;
children: ReactNode;
}) {
return (
<section id={id} className={`${FONDO[fondo]}${id ? ' scroll-mt-24' : ''}`}>
<Container className={RITMO[ritmo]}>
<div className="grid gap-y-8 md:grid-cols-[7rem_minmax(0,1fr)] md:gap-x-10">
<div
aria-hidden="true"
className="flex items-center gap-4 md:flex-col md:items-start md:gap-5"
>
<span className="font-sans font-semibold text-xs tracking-[0.2em] tabular-nums opacity-50">
{numero}
</span>
<MarcaSeccion paso={paso} className={`w-10 shrink-0 ${MARCA[fondo]}`} />
</div>
<div className="min-w-0">{children}</div>
</div>
</Container>
</section>
);
}
+66 -22
View File
@@ -8,8 +8,9 @@ import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo';
// Eighteen crossing lines converge in three groups; in the "completa" variant
// each group collapses onto one of the three bars of the ≡, so the mark is
// literally produced by the resolution of the tangle rather than placed next
// to it. The "hero" variant stops at convergence and leaves the tension open —
// FraseAncla pays it off further down the page.
// to it. The "hero" variant resolves the chaos into a skyline silhouette —
// structure emerging from disorder, what creeer builds for its clients.
// FraseAncla pays off the full ≡ resolution further down the page.
type Variant = 'completa' | 'hero';
@@ -40,6 +41,55 @@ const DISPERSAS = [
// converge — many/disordered becomes few/aligned, per the manual.
const SOBREVIVE = [0, 6, 12];
// Skyline destination for the hero variant. Eighteen lines form a city
// silhouette: vertical segments are building walls, horizontal segments
// are rooftops and the ground line. The skyline reads left to right as
// buildings of increasing height — chaos becoming structure, the visual
// promise of what creeer delivers.
//
// ViewBox: 64×64. Ground at y=58. Tallest building peaks at y=10.
//
// ┌──┐
// ┌──┐ │ │ ┌─┐
// │ │ │ │ │ │┌──┐
// ┌─┤ │ │ │ │ ││ │
// │ │ │ │ │ │ ││ │
// └─┴──┴─┴──┴─┴─┴┴──┘
//
const SKYLINE = [
// Building 1 (left, medium) — x: 4–14, top: 28
{ x1: 4, y1: 58, x2: 4, y2: 28 }, // 0: left wall
{ x1: 4, y1: 28, x2: 14, y2: 28 }, // 1: rooftop
{ x1: 14, y1: 28, x2: 14, y2: 58 }, // 2: right wall
// Building 2 (tall) — x: 18–30, top: 12
{ x1: 18, y1: 58, x2: 18, y2: 12 }, // 3: left wall
{ x1: 18, y1: 12, x2: 30, y2: 12 }, // 4: rooftop
{ x1: 30, y1: 12, x2: 30, y2: 58 }, // 5: right wall
// Building 3 (short) — x: 34–40, top: 32
{ x1: 34, y1: 58, x2: 34, y2: 32 }, // 6: left wall
{ x1: 34, y1: 32, x2: 40, y2: 32 }, // 7: rooftop
{ x1: 40, y1: 32, x2: 40, y2: 58 }, // 8: right wall
// Building 4 (medium-tall) — x: 42–54, top: 20
{ x1: 42, y1: 58, x2: 42, y2: 20 }, // 9: left wall
{ x1: 42, y1: 20, x2: 54, y2: 20 }, // 10: rooftop
{ x1: 54, y1: 20, x2: 54, y2: 58 }, // 11: right wall
// Small tower on building 1 — x: 7–11, top: 22
{ x1: 7, y1: 28, x2: 7, y2: 22 }, // 12: left wall
{ x1: 7, y1: 22, x2: 11, y2: 22 }, // 13: rooftop
{ x1: 11, y1: 22, x2: 11, y2: 28 }, // 14: right wall
// Ground line segments
{ x1: 2, y1: 58, x2: 30, y2: 58 }, // 15: ground left
{ x1: 30, y1: 58, x2: 56, y2: 58 }, // 16: ground right
// Antenna on tallest building — a small vertical detail
{ x1: 24, y1: 12, x2: 24, y2: 6 }, // 17: antenna
];
const EASE = [0.22, 1, 0.36, 1] as const;
export function SecuenciaClaridad({
@@ -54,26 +104,23 @@ export function SecuenciaClaridad({
const completa = variant === 'completa';
// Where each line ends up. In "completa" the survivors land exactly on the
// symbol's own coordinates; in "hero" they merely tighten toward their
// group's axis without ever aligning.
// symbol's own coordinates; in "hero" the lines form a skyline silhouette.
const destino = (i: number) => {
if (!completa) {
// Hero variant: lines converge into the skyline shape.
const s = SKYLINE[i];
return {
x1: s.x1,
y1: s.y1,
x2: s.x2,
y2: s.y2,
opacity: i === 17 ? 0.7 : 0.85, // antenna slightly dimmer
};
}
// Completa variant: survivors land on the ≡ bars.
const { barra } = DISPERSAS[i];
const linea = LINEAS_SIMBOLO[barra];
const sobrevive = SOBREVIVE.includes(i);
if (!completa) {
const eje = linea.y1;
const d = DISPERSAS[i];
// Pulled a good way further in than it used to be: at a third of the way
// the tangle still read as a scratched-out scribble instead of as lines
// finding their axis. It must arrive close to aligned and stop short.
return {
x1: d.x1 + (26 - d.x1) * 0.5,
y1: d.y1 + (eje - d.y1) * 0.7,
x2: d.x2 + (38 - d.x2) * 0.5,
y2: d.y2 + (eje - d.y2) * 0.7,
opacity: sobrevive ? 0.95 : 0.28,
};
}
return {
x1: linea.x1,
y1: linea.y1,
@@ -87,8 +134,7 @@ export function SecuenciaClaridad({
completa && SOBREVIVE.includes(i) ? LINEAS_SIMBOLO[DISPERSAS[i].barra].grosor : 1.5;
// Reduced motion renders plain <line> elements: motion.line would apply its
// own dash scaffolding and leave the drawing invisible (see the same trap in
// IlustracionInflexion).
// own dash scaffolding and leave the drawing invisible.
if (reduced) {
return (
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
@@ -118,8 +164,6 @@ export function SecuenciaClaridad({
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
{DISPERSAS.map(({ barra: _barra, ...inicio }, i) => {
const d = destino(i);
// `barra` is grouping metadata, not an SVG attribute — destructured out
// so it never reaches the DOM.
const reposo = { ...inicio, opacity: 0.55, strokeWidth: 1.5 };
return (
<motion.line
+29
View File
@@ -0,0 +1,29 @@
import { BARRAS_SIMBOLO, VIEWBOX_SIMBOLO } from '@/lib/simbolo';
// The ≡ mark on its own, at any scale — the manual lists it under "recursos
// visuales" as "las tres líneas del símbolo en diversas orientaciones y
// escalas". Geometry comes from lib/simbolo.ts, the same source the wordmark
// reads, so this is provably the mark and not a lookalike drawn by hand.
// Decorative by definition: it never carries meaning a reader would miss.
export function SimboloMarca({ className = '' }: { className?: string }) {
return (
<svg
viewBox={VIEWBOX_SIMBOLO}
className={className}
aria-hidden="true"
focusable="false"
fill="currentColor"
>
{BARRAS_SIMBOLO.map((barra) => (
<rect
key={barra.y}
x={barra.x}
y={barra.y}
width={barra.width}
height={barra.height}
rx={barra.height / 2}
/>
))}
</svg>
);
}
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Antetitulo } from '../Antetitulo';
describe('<Antetitulo />', () => {
it('renders its copy', () => {
render(<Antetitulo>el contexto</Antetitulo>);
expect(screen.getByText('el contexto')).toBeInTheDocument();
});
it('sobre fondo oscuro usa el dorado del manual', () => {
// Sobre azul profundo, #C4942E da 6,07:1: es donde el dorado de la marca
// trabaja mejor y ahí se queda intacto.
render(<Antetitulo sobre="oscuro">las herramientas</Antetitulo>);
expect(screen.getByText('las herramientas').className.split(/\s+/)).toContain('text-dorado');
});
it('sobre fondo claro baja al dorado legible', () => {
// El mismo #C4942E cae a 2,76:1 sobre blanco y a 2,46:1 sobre gris-claro.
// A 12px y en negrita eso no se lee: el antetítulo pasa a `dorado-texto`.
render(<Antetitulo sobre="claro">lo que realmente sientes</Antetitulo>);
const classes = screen.getByText('lo que realmente sientes').className.split(/\s+/);
expect(classes).toContain('text-dorado-texto');
expect(classes).not.toContain('text-dorado');
});
it('asume fondo claro cuando no se le dice nada', () => {
// El caso por defecto tiene que ser el seguro: olvidarse del prop no puede
// producir un antetítulo ilegible.
render(<Antetitulo>cómo empezar</Antetitulo>);
expect(screen.getByText('cómo empezar').className.split(/\s+/)).toContain('text-dorado-texto');
});
it('puede ser el encabezado de su sección', () => {
// En el recorrido el antetítulo es el único <h2> que tiene la sección.
render(<Antetitulo as="h2">¿Te reconoces?</Antetitulo>);
expect(screen.getByRole('heading', { name: '¿Te reconoces?', level: 2 })).toBeInTheDocument();
});
it('lee su tamaño del token rotulo en vez de recomponerlo a mano', () => {
// `rotulo` already declares 0.75rem with 0.3em of tracking. Repeating it
// in the className left the token unused and the two definitions free
// to drift apart.
render(<Antetitulo>el contexto</Antetitulo>);
const classes = screen.getByText('el contexto').className.split(/\s+/);
expect(classes).toContain('text-rotulo');
expect(classes).not.toContain('text-xs');
expect(classes).not.toContain('tracking-[0.3em]');
});
it('usa un peso que la tipografía tiene', () => {
// Open Sans tops out at 800 and layout.tsx loads up to 800: `font-black`
// (900) fell back to 800 anyway. The eyebrow goes at 600, the weight the
// scale reserves for labels.
render(<Antetitulo>el contexto</Antetitulo>);
const classes = screen.getByText('el contexto').className.split(/\s+/);
expect(classes).toContain('font-semibold');
expect(classes).not.toContain('font-black');
});
});
+11 -1
View File
@@ -12,12 +12,22 @@ describe('<CTA />', () => {
expect(screen.getByRole('link').getAttribute('href')).toBe('#contacto');
});
it('renders the ghost variant outlined, so it reads as secondary', () => {
render(<CTA href="#mecanismo" label="Ver cómo trabajo" variant="ghost" />);
render(<CTA href="#proceso" label="Ver cómo trabajamos" variant="ghost" />);
const classes = screen.getByRole('link').className.split(/\s+/);
expect(classes).toContain('border-azul-profundo');
expect(classes).not.toContain('bg-azul-profundo'); // sólo se rellena en hover
expect(classes).toContain('hover:bg-azul-profundo');
});
it('rellena en dorado con la etiqueta en azul profundo', () => {
// Blanco sobre el dorado del manual (#C4942E) es 2,76:1 — no pasa AA para
// texto pequeño, y esta es la variante del botón que cierra la página.
// Azul profundo sobre ese mismo dorado da 6,07:1.
render(<CTA href="#contacto" label="Define tu siguiente movimiento" variant="dark" />);
const classes = screen.getByRole('link').className.split(/\s+/);
expect(classes).toContain('bg-dorado');
expect(classes).toContain('text-azul-profundo');
expect(classes).not.toContain('text-white');
});
it('keeps every variant the same height', () => {
const { container } = render(
<>
@@ -0,0 +1,23 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ClaridadHero } from '../ClaridadHero';
describe('<ClaridadHero />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<ClaridadHero className="w-full h-full" />);
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
});
it('holds the box open so nothing shifts when the scene arrives', () => {
const { container } = render(<ClaridadHero className="w-full h-full" />);
expect(container.firstElementChild).toHaveClass('w-full', 'h-full');
});
// The stub observer in the test setup never reports an intersection, which is
// exactly the case worth pinning: until the box comes near the viewport,
// nothing mounts and three.js is never fetched.
it('does not fetch three.js before the box comes near the viewport', () => {
const { container } = render(<ClaridadHero className="w-full h-full" />);
expect(container.firstElementChild).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,54 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
// jsdom has no WebGL, so the real <Canvas> cannot mount. The mock keeps the
// wrapper — the part that is plain DOM and that carries the decisions worth
// protecting — testable, and reports back the frameloop it was handed.
vi.mock('@react-three/fiber', () => ({
Canvas: ({ frameloop }: { frameloop?: string }) => (
<div data-testid="lienzo" data-frameloop={frameloop} />
),
useFrame: () => {},
useThree: () => ({ camera: { position: { set: () => {} }, lookAt: () => {} }, invalidate: () => {} }),
}));
import { EscenaClaridad } from '../EscenaClaridad';
function conMovimientoReducido(reducido: boolean) {
window.matchMedia = ((query: string) => ({
matches: reducido && query.includes('reduce'),
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
afterEach(() => conMovimientoReducido(false));
describe('<EscenaClaridad />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<EscenaClaridad />);
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
});
it('takes its size from the caller, so the hero reserves the box', () => {
const { container } = render(<EscenaClaridad className="w-full h-full" />);
expect(container.firstElementChild).toHaveClass('w-full', 'h-full');
});
it('runs the loop while the hero is on screen', () => {
conMovimientoReducido(false);
const { getByTestId } = render(<EscenaClaridad />);
expect(getByTestId('lienzo')).toHaveAttribute('data-frameloop', 'always');
});
it('stops the loop for readers who asked for reduced motion', () => {
conMovimientoReducido(true);
const { getByTestId } = render(<EscenaClaridad />);
expect(getByTestId('lienzo')).toHaveAttribute('data-frameloop', 'demand');
});
});
+2 -14
View File
@@ -1,18 +1,6 @@
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',
];
import { Icono, NOMBRES_ICONO } from '../Icono';
describe('<Icono />', () => {
it('is decorative: hidden from assistive tech', () => {
@@ -20,7 +8,7 @@ describe('<Icono />', () => {
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it.each(NOMBRES)('renders a non-empty svg for "%s"', (nombre) => {
it.each(NOMBRES_ICONO)('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);
@@ -0,0 +1,26 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { IconoRed, NOMBRES_RED } from '../IconoRed';
describe('<IconoRed />', () => {
it('is decorative: the link around it carries the name', () => {
const { container } = render(<IconoRed nombre="whatsapp" />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it.each(NOMBRES_RED)('renders a non-empty svg for "%s"', (nombre) => {
const { container } = render(<IconoRed nombre={nombre} />);
expect(container.querySelector('svg')?.children.length).toBeGreaterThan(0);
});
it('never brings its own brand colour onto the page', () => {
for (const nombre of NOMBRES_RED) {
const { container } = render(<IconoRed nombre={nombre} />);
const pintados = container.querySelectorAll('[fill]:not([fill="none"]), [stroke]');
pintados.forEach((el) => {
const color = el.getAttribute('fill') ?? el.getAttribute('stroke');
if (color !== 'none') expect(color).toBe('currentColor');
});
}
});
});
@@ -0,0 +1,55 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MarcaSeccion, PASOS } from '../MarcaSeccion';
import { LINEAS_SIMBOLO } from '@/lib/simbolo';
const lineas = (paso: number) => {
const { container } = render(<MarcaSeccion paso={paso} />);
return Array.from(container.querySelectorAll('line'));
};
const visibles = (paso: number) => lineas(paso).filter((l) => l.getAttribute('opacity') !== '0');
const coords = (paso: number) =>
lineas(paso).map((l) =>
(['x1', 'y1', 'x2', 'y2'] as const).map((a) => Number(l.getAttribute(a))),
);
describe('<MarcaSeccion />', () => {
it('opens the document with all nine lines tangled', () => {
expect(visibles(0)).toHaveLength(9);
});
it("resolves into the manual's ≡ exactly, not into a lookalike", () => {
// Not a lookalike: the exact three bars that the wordmark draws.
const finales = visibles(PASOS - 1);
expect(finales).toHaveLength(3);
finales.forEach((l, i) => {
(['x1', 'y1', 'x2', 'y2'] as const).forEach((attr) => {
expect(Number(l.getAttribute(attr)), attr).toBeCloseTo(LINEAS_SIMBOLO[i][attr], 5);
});
expect(Number(l.getAttribute('stroke-width'))).toBeCloseTo(LINEAS_SIMBOLO[i].grosor, 5);
});
});
it('converges step by step: each one leaves the lines closer to where they end up', () => {
// Counting visible lines proves nothing here: the six that fade do it
// continuously as `1 - t` and only reach a literal "0" at the last step,
// so the count is [9,9,9,9,9,3] and the assertion cannot fail. What does
// move at every step is how far each line still is from its final place.
const destino = coords(PASOS - 1);
const desvio = (paso: number) =>
coords(paso).reduce(
(total, linea, i) => total + Math.hypot(...linea.map((v, j) => v - destino[i][j])),
0,
);
const serie = Array.from({ length: PASOS }, (_, p) => desvio(p));
serie.slice(1).forEach((d, i) => expect(d, `paso ${i + 1}`).toBeLessThan(serie[i]));
expect(serie[PASOS - 1]).toBe(0);
});
it('is decorative for screen readers', () => {
const { container } = render(<MarcaSeccion paso={2} />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
});
@@ -6,7 +6,7 @@ const par = {
n: 1,
name: 'Crecer',
situacion: 'El siguiente salto no tiene mapa.',
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro.',
respuesta: 'Nos sumergimos hasta entender tu empresa desde dentro.',
};
const montar = (extra: Partial<Parameters<typeof ParDialogo>[0]> = {}) =>
@@ -20,7 +20,7 @@ describe('<ParDialogo />', () => {
it('renders both voices of the exchange', () => {
montar();
expect(screen.getByText(/El siguiente salto no tiene mapa/)).toBeInTheDocument();
expect(screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/)).toBeInTheDocument();
expect(screen.getByText(/Nos sumergimos hasta entender tu empresa desde dentro/)).toBeInTheDocument();
});
it('attributes the answer to creeer, not to the reader', () => {
@@ -56,7 +56,7 @@ describe('<ParDialogo />', () => {
// document. `hidden` or `display: none` would take it away from screen
// readers and from the static export the crawler sees — half the section's
// message, gone, to buy an animation.
const respuesta = screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/);
const respuesta = screen.getByText(/Nos sumergimos hasta entender tu empresa desde dentro/);
expect(respuesta).toBeInTheDocument();
expect(respuesta.closest('[hidden]')).toBeNull();
expect(screen.getByLabelText(/Respuesta de creeer a: Crecer/).className).toMatch(/opacity-0\b/);
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Revelar } from '../Revelar';
// The stub in vitest.setup.ts is inert: it observes but never fires. This one
// captures the callback so we can simulate the element entering the viewport.
let disparar: (entradas: Partial<IntersectionObserverEntry>[]) => void;
beforeEach(() => {
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: IntersectionObserverCallback) {
disparar = (entradas) => cb(entradas as IntersectionObserverEntry[], this as never);
}
observe() {}
unobserve() {}
disconnect() {}
},
);
});
describe('<Revelar />', () => {
it('no oculta nada por sí mismo', () => {
// The hidden state is CSS-only, gated on `html.js`. If the component wrote
// opacity 0 into the inline style we'd be back to the bug this redesign
// fixes: 112 invisible elements in the export and a blank page whenever
// the JS doesn't arrive.
const { container } = render(<Revelar>Tu negocio no necesita más esfuerzo.</Revelar>);
const el = container.firstElementChild as HTMLElement;
expect(el.style.opacity).toBe('');
expect(screen.getByText('Tu negocio no necesita más esfuerzo.')).toBeInTheDocument();
});
it('marca el bloque para que el CSS lo pueda gobernar', () => {
const { container } = render(<Revelar>x</Revelar>);
expect(container.firstElementChild).toHaveAttribute('data-revelar');
});
it('escalona el retardo con el índice', () => {
const { container } = render(<Revelar indice={3}>x</Revelar>);
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--i')).toBe('3');
});
it('se marca visible al entrar en viewport', () => {
const { container } = render(<Revelar>x</Revelar>);
const el = container.firstElementChild as HTMLElement;
expect(el).not.toHaveAttribute('data-visible');
disparar([{ isIntersecting: true, target: el }]);
expect(el).toHaveAttribute('data-visible');
});
});
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Seccion } from '../Seccion';
describe('<Seccion />', () => {
it('renders its content and its number', () => {
render(<Seccion numero="02" paso={1}>Dónde estás</Seccion>);
expect(screen.getByText('Dónde estás')).toBeInTheDocument();
expect(screen.getByText('02')).toBeInTheDocument();
});
it('carries its mark at the step it is given', () => {
const { container } = render(<Seccion numero="06" paso={5}>x</Seccion>);
// Step 5 resolves the ≡: three of the nine lines stay visible.
const visibles = Array.from(container.querySelectorAll('line')).filter(
(l) => l.getAttribute('opacity') !== '0',
);
expect(visibles).toHaveLength(3);
});
it('uses the manual gold on dark backgrounds, the readable gold on light ones', () => {
const { container: oscuro } = render(<Seccion numero="03" paso={2} fondo="oscuro">x</Seccion>);
expect(oscuro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado');
expect(oscuro.querySelector('svg')?.getAttribute('class')).not.toContain('text-dorado-texto');
const { container: claro } = render(<Seccion numero="04" paso={3}>x</Seccion>);
expect(claro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado-texto');
});
it('is not announced to screen readers', () => {
// It is document furniture, not content: the section's own heading
// already says what it is about.
const { container } = render(<Seccion numero="05" paso={4}>x</Seccion>);
expect(container.querySelector('[aria-hidden="true"]')?.textContent).toContain('05');
});
it('only becomes an anchor target when it has an id', () => {
const { container } = render(<Seccion numero="06" paso={5} id="contacto">x</Seccion>);
const seccion = container.querySelector('section');
expect(seccion).toHaveAttribute('id', 'contacto');
expect(seccion?.className).toContain('scroll-mt-24');
});
});
@@ -0,0 +1,35 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { SimboloMarca } from '../SimboloMarca';
import { BARRAS_SIMBOLO } from '@/lib/simbolo';
describe('<SimboloMarca />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<SimboloMarca />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
// The dueña's 28/jul instruction was that the ≡ has to read as an E. That
// depends entirely on the proportions in lib/simbolo.ts — squat bars and
// tight gaps turn it into a hamburger menu icon. Drawing from the canonical
// geometry rather than hand-typed numbers is what keeps that guarantee, so
// the test checks the bars really came from there.
it('draws the three canonical bars, straight from lib/simbolo', () => {
const { container } = render(<SimboloMarca />);
const barras = container.querySelectorAll('rect');
expect(barras).toHaveLength(3);
barras.forEach((barra, i) => {
expect(barra.getAttribute('x')).toBe(String(BARRAS_SIMBOLO[i].x));
expect(barra.getAttribute('y')).toBe(String(BARRAS_SIMBOLO[i].y));
expect(barra.getAttribute('width')).toBe(String(BARRAS_SIMBOLO[i].width));
expect(barra.getAttribute('height')).toBe(String(BARRAS_SIMBOLO[i].height));
});
});
it('takes its colour from currentColor via className, not a hardcoded fill', () => {
const { container } = render(<SimboloMarca className="text-dorado" />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('fill', 'currentColor');
expect(svg).toHaveClass('text-dorado');
});
});
+16 -5
View File
@@ -3,16 +3,20 @@ import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { CTA } from '@/components/primitives/CTA';
import { Wordmark } from '@/components/primitives/Wordmark';
import { IconoRed, NOMBRES_RED } from '@/components/primitives/IconoRed';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { Resplandor } from '@/components/primitives/Resplandor';
import { cierre } from '@/lib/content';
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Cierre() {
const reduced = usePrefersReducedMotion();
const { whatsapp, email, linkedin } = cierre.contact;
const { whatsapp } = cierre.contact;
return (
<section id="contacto" className="bg-azul-profundo text-white scroll-mt-24 relative overflow-hidden">
<section id="contacto" className="bg-azul-profundo bg-hondo-navy text-white scroll-mt-24 relative overflow-hidden">
<FondoPuntos className="top-12 -left-16 w-72 md:w-[30rem] text-dorado/25" distance={36} />
<Resplandor className="-top-32 left-1/2 -translate-x-1/2 w-[46rem] h-[46rem]" />
<Container className="relative py-16 md:py-24 text-center">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex flex-col items-center">
@@ -26,9 +30,16 @@ export function Cierre() {
</motion.div>
<motion.nav variants={fadeInUp} aria-label="Contacto" className="mt-14">
<ul className="flex flex-wrap justify-center gap-3 text-sm">
<li><a href={whatsapp.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{whatsapp.label}</a></li>
<li><a href={email.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{email.label}</a></li>
<li><a href={linkedin.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{linkedin.label}</a></li>
{NOMBRES_RED.map((red) => (
<li key={red}>
<a href={cierre.contact[red].href} {...propsEnlace(cierre.contact[red].href)}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">
<IconoRed nombre={red} className="w-4 h-4 shrink-0" />
{cierre.contact[red].label}
{esExterno(cierre.contact[red].href) && <span className="sr-only"> ({AVISO_PESTANA})</span>}
</a>
</li>
))}
</ul>
</motion.nav>
</motion.div>
+4 -3
View File
@@ -3,6 +3,7 @@
import { motion, useScroll, useTransform } from 'framer-motion';
import Image from 'next/image';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { CTA } from '@/components/primitives/CTA';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { comoEmpezar, cierre } from '@/lib/content';
@@ -18,10 +19,10 @@ export function ComoEmpezar() {
const { scrollYProgress } = useScroll();
const desplazamiento = useTransform(scrollYProgress, [0, 1], [20, -20]);
return (
<section className="bg-white">
<section className="bg-white bg-hondo-blanco">
<Container className="py-16 md:py-24 grid gap-12 md:grid-cols-[1fr_auto] md:items-center">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{comoEmpezar.eyebrow}</motion.p>
<Antetitulo as={motion.p} variants={fadeInUp}>{comoEmpezar.eyebrow}</Antetitulo>
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-xl">{comoEmpezar.title}</motion.h2>
<motion.ol variants={staggerContainer} className="mt-12 grid gap-10 sm:grid-cols-3 md:grid-cols-1 md:gap-8">
{comoEmpezar.steps.map((step, i) => (
@@ -38,7 +39,7 @@ export function ComoEmpezar() {
<Icono nombre={ICONOS[i]} className="w-6 h-6 text-dorado shrink-0" />
<h3 className="font-sans font-bold text-xl text-azul-profundo">{step.name}</h3>
</div>
<p className="mt-3 md:mt-1 text-gris-medio leading-relaxed">{step.desc}</p>
<p className="mt-3 md:mt-1 text-azul-profundo/70 leading-relaxed">{step.desc}</p>
</div>
</motion.li>
))}
+50 -13
View File
@@ -2,7 +2,9 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Resplandor } from '@/components/primitives/Resplandor';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { IlustracionConvergencia } from '@/components/primitives/IlustracionConvergencia';
import { disciplinas } from '@/lib/content';
@@ -18,16 +20,45 @@ export function Disciplinas() {
// nobody loses anything by not having a mouse.
const [senalada, setSenalada] = useState<number | null>(null);
return (
<section className="bg-white">
<Container className="py-16 md:py-24">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{disciplinas.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo">{disciplinas.title}</motion.h2>
// Azul profundo, no blanco: al mover esta sección delante de Proceso
// quedaba pegada a Dolores, que también es blanca, y las dos se fundían.
// Además rompe el tramo de seis secciones claras seguidas que había entre
// FraseAncla y Cierre, y le da a la página un latido oscuro a media altura.
<section className="bg-azul-profundo bg-hondo-navy text-white relative overflow-hidden">
<Resplandor className="-top-40 -right-32 w-[38rem] h-[38rem]" />
<Resplandor className="bottom-[-14rem] -left-40 w-[32rem] h-[32rem]" tono="frio" />
<Container className="relative py-16 md:py-24">
{/* Title and lead on the left, the drawing on the right at the same
height. The lead is the sentence this section exists for — Proceso
sits after it so it lands first — and it used to be small grey
text lost in the gap under the title, with the drawing floating
loose below. */}
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 md:flex md:items-center md:justify-between md:gap-10">
<p className="text-gris-medio text-lg">{disciplinas.lead}</p>
<IlustracionConvergencia className="mt-6 md:mt-0 w-56 md:w-64 h-auto shrink-0" activa={senalada} />
className="md:flex md:items-end md:justify-between md:gap-12">
<div>
{/* sobre="oscuro": la sección es azul profundo, así que aquí el dorado
del manual se queda tal cual — 6,07:1. */}
<Antetitulo sobre="oscuro" as="p">
{disciplinas.eyebrow}
</Antetitulo>
<h2 className="mt-4 font-sans font-black text-seccion text-balance">{disciplinas.title}</h2>
<p className="mt-4 text-xl md:text-2xl font-semibold text-white/85">
{/* Two unbreakable halves, the dash closing the first. On a
phone the line used to break wherever it fell — before the
dash, or before «medios» — and left a stray fragment alone. */}
{(() => {
const [antes, despues] = disciplinas.lead.split(' — ');
if (!despues) return disciplinas.lead; // copy without a dash
return (
<>
<span className="whitespace-nowrap">{antes} —</span>{' '}
<span className="whitespace-nowrap">{despues}</span>
</>
);
})()}
</p>
</div>
<IlustracionConvergencia className="mt-8 md:mt-0 w-56 md:w-72 h-auto shrink-0" activa={senalada} />
</motion.div>
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 grid gap-6 md:grid-cols-3 list-none p-0">
@@ -35,13 +66,19 @@ export function Disciplinas() {
<motion.li key={item.name} variants={fadeInUp}
onPointerEnter={() => setSenalada(i)}
onPointerLeave={() => setSenalada(null)}
className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none">
<TarjetaSpotlight className="h-full rounded-lg bg-azul-profundo text-white p-7">
<div className="grid place-items-center w-14 h-14 rounded-full bg-white/10">
// No lift: the cards are not links. What answers the pointer is
// the card's own strand lighting up in the drawing above, plus a
// gold edge so the reader sees which card that strand belongs to.
className="group">
{/* Tarjetas invertidas al pasar la sección a azul: `text-gris-medio`
se leía bien sobre navy, pero sobre blanco cae a ~2,3:1 y no
pasa contraste. La descripción va en azul rebajado. */}
<TarjetaSpotlight className="h-full rounded-xl bg-white text-azul-profundo p-7 shadow-tarjeta border-t-4 border-transparent transition-colors duration-200 group-hover:border-dorado motion-reduce:transition-none">
<div className="grid place-items-center w-14 h-14 rounded-full bg-dorado/15">
<Icono nombre={ICONOS[i]} className="w-7 h-7 text-dorado" />
</div>
<h3 className="mt-5 font-sans font-black text-2xl">{item.name}</h3>
<p className="mt-3 text-gris-medio leading-relaxed">{item.desc}</p>
<p className="mt-3 text-azul-profundo/75 leading-relaxed text-pretty">{item.desc}</p>
</TarjetaSpotlight>
</motion.li>
))}
+5 -3
View File
@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { dolores } from '@/lib/content';
@@ -28,15 +29,16 @@ export function Dolores() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-white relative overflow-hidden">
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
{/* Smaller and a touch stronger than it was: sprawling at 8% across the
whole title it read as a smudge over the headline rather than as a
knot. Stays centred. */}
<IlustracionNudo className="pointer-events-none select-none absolute top-0 left-1/2 -translate-x-1/2 w-56 md:w-80 text-azul-profundo/[0.11]" />
<Container className="relative py-16 md:py-24">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{dolores.eyebrow}</motion.p>
<Antetitulo as={motion.p} variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
{dolores.eyebrow}
</Antetitulo>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-3xl">{dolores.title}</motion.h2>
+14 -33
View File
@@ -1,49 +1,30 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Wordmark } from '@/components/primitives/Wordmark';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { ContadorNumero } from '@/components/primitives/ContadorNumero';
import { MarcoRetrato } from '@/components/primitives/MarcoRetrato';
import { estratega } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Estratega() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-gris-claro">
<section className="bg-gris-claro bg-hondo-claro">
<Container className="py-16 md:py-24">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center">
{/* TODO dueña: real portrait. The frame is built to take it as-is —
drop an <Image fill /> in place of the placeholder below. */}
<motion.div variants={fadeInUp}>
<MarcoRetrato>
{/* Reads as a brand plate, not as a broken image. The dot grid
that used to sit behind the wordmark looked like the
checkerboard a browser shows for a picture that failed to
load; the ≡ alone, meanwhile, reads as a hamburger menu once
it stands by itself in a circle. The wordmark is neither. */}
<div className="w-full h-full bg-azul-profundo grid place-items-center" role="img" aria-label="Retrato de la estratega — pendiente">
<Wordmark size="md" onDark />
</div>
</MarcoRetrato>
className="max-w-2xl">
<Antetitulo as={motion.p} variants={fadeInUp}>
{estratega.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span>
</Antetitulo>
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo">{estratega.title}</motion.h2>
<motion.div variants={fadeInUp} className="mt-6 flex items-baseline gap-3">
<ContadorNumero hasta={25} sufijo="+" className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none" />
<p className="text-xs uppercase tracking-[0.14em] text-azul-profundo/70 font-semibold max-w-[12rem] leading-snug">años construyendo marcas</p>
</motion.div>
<div>
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">
{estratega.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span>
</motion.p>
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo">{estratega.title}</motion.h2>
<motion.div variants={fadeInUp} className="mt-6 flex items-baseline gap-3">
<ContadorNumero hasta={30} sufijo="+" className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none" />
{/* 8rem was narrower than the word "construyendo" itself, which
broke the label into three ragged lines beside the number. */}
<p className="text-xs uppercase tracking-[0.14em] text-gris-medio font-semibold max-w-[12rem] leading-snug">años construyendo marcas</p>
</motion.div>
<motion.p variants={fadeInUp} className="mt-6 text-lg text-gris-medio leading-relaxed max-w-2xl">{estratega.bio}</motion.p>
<motion.blockquote variants={fadeInUp} className="mt-6 font-sans italic text-xl md:text-2xl text-azul-profundo border-l-4 border-dorado pl-4">
{estratega.quote}
</motion.blockquote>
</div>
<motion.p variants={fadeInUp} className="mt-6 text-lg text-azul-profundo/70 leading-relaxed">{estratega.bio}</motion.p>
<motion.blockquote variants={fadeInUp} className="mt-6 font-sans italic text-xl md:text-2xl text-azul-profundo border-l-4 border-dorado pl-4">
{estratega.quote}
</motion.blockquote>
</motion.div>
</Container>
</section>
-77
View File
@@ -1,77 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { formaTrabajar } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
// Reuses existing icons where the metaphor fits; only 'reloj' is new.
const ICONOS: IconoNombre[] = [
'estrategia', // Metodología — ascending path to a flag
'comunicacion', // Quién participa — dialogue bubbles
'inmersion', // De dónde partimos — diving through layers
'plan', // Qué te llevas — checklist
'reloj', // Duración — clock
];
/**
* "Así es trabajar conmigo" — practical details of the engagement.
* Inspired by en-ti.com's card-based solutions grid, adapted to creeer's
* identity: white cards on gris-claro, dorado accents, TarjetaSpotlight glow.
* Sits between Mecanismo (high-level method) and Disciplinas (the tools).
*/
export function FormaTrabajar() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-gris-claro relative overflow-hidden">
<FondoPuntos className="-top-10 -left-12 w-72 md:w-[28rem] text-azul-profundo/[0.06]" distance={32} />
<Container className="relative py-16 md:py-24">
{/* ── Header ── */}
<motion.p
variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado"
>
{formaTrabajar.eyebrow}
</motion.p>
<motion.h2
variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo"
>
{formaTrabajar.title}
</motion.h2>
{/* ── Card grid ── */}
<motion.ul
variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 list-none p-0"
>
{formaTrabajar.items.map((item, i) => (
<motion.li
key={item.name}
variants={fadeInUp}
className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none"
>
<TarjetaSpotlight className="h-full rounded-2xl border border-white bg-white p-7 transition-[border-color,box-shadow] duration-300 hover:border-dorado/30 hover:shadow-[0_20px_50px_-20px_rgba(196,148,46,0.2)]">
{/* Icon */}
<div className="grid place-items-center w-14 h-14 rounded-full bg-azul-profundo">
<Icono nombre={ICONOS[i]} className="w-7 h-7 text-dorado" />
</div>
{/* Copy */}
<h3 className="mt-5 font-sans font-black text-xl text-azul-profundo">
{item.name}
</h3>
<p className="mt-3 text-gris-medio leading-relaxed text-sm">
{item.desc}
</p>
</TarjetaSpotlight>
</motion.li>
))}
</motion.ul>
</Container>
</section>
);
}
+18 -1
View File
@@ -3,6 +3,7 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
import { Resplandor } from '@/components/primitives/Resplandor';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { fraseAncla } from '@/lib/content';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -10,7 +11,10 @@ import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/
export function FraseAncla() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-azul-profundo relative overflow-hidden">
<section className="bg-azul-profundo bg-hondo-navy relative overflow-hidden">
{/* Centrado: la frase es el momento más quieto de la página, así que la
luz viene de detrás de ella y no de un lado. */}
<Resplandor className="top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[52rem] h-[52rem]" />
<Container className="relative py-20 md:py-28 text-center max-w-5xl">
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex justify-center">
@@ -33,6 +37,19 @@ export function FraseAncla() {
]}
/>
</motion.p>
{/* El "después": la ancla nombra el diagnóstico; esta línea pinta el
resultado en la vida del lector. Sin ella, el único futuro que
existe en la página es el negativo (los dolores). */}
<motion.p
variants={fadeInUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
{...motionProps(reduced)}
className="mt-8 max-w-[42ch] mx-auto text-white/70 leading-relaxed"
>
{fraseAncla.despues}
</motion.p>
</Container>
</section>
);
+26 -38
View File
@@ -1,19 +1,14 @@
'use client';
import { motion } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark';
import { CTA } from '@/components/primitives/CTA';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { hero } from '@/lib/content';
import { fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion';
import { ClaridadHero } from '@/components/primitives/ClaridadHero';
import { hero, apertura } from '@/lib/content';
type Variant = 'hormozi' | 'editorial';
export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
const reduced = usePrefersReducedMotion();
const isHormozi = variant === 'hormozi';
const headlineBase = isHormozi
? 'font-sans font-black uppercase tracking-tight'
@@ -22,7 +17,7 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
const clarityBorder = isHormozi ? 'border-b-[5px] border-dorado pb-0.5' : 'border-b-[3px] border-dorado pb-0.5';
return (
<section className="bg-white relative overflow-hidden">
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
{/* Kept clear of the wordmark on narrow screens: at 390px the md field
reached back across the logotype and the tagline, so the pattern read
as collision rather than texture. */}
@@ -30,39 +25,32 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
<FondoPuntos className="bottom-16 -left-20 w-64 md:w-96 text-dorado/20" distance={28} rows={2} />
<Container className="relative min-h-[80vh] lg:min-h-[88vh] flex items-center py-16 md:py-24">
<div className="grid gap-8 lg:gap-14 lg:grid-cols-[1.1fr_0.9fr] lg:items-center w-full">
<motion.div variants={staggerContainer} initial="hidden" animate="visible" {...motionProps(reduced)}
className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
<motion.div variants={fadeInUp}><Wordmark size="xl" showTagline /></motion.div>
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-black text-dorado">
{hero.eyebrow}
</motion.p>
<motion.h1 variants={fadeInUp} className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
<TextoDesenfoque
as="span"
segments={[
{ text: hero.headline.line1 },
{ text: hero.headline.effort, className: 'text-dorado' },
{ text: hero.headline.line3 },
{ text: hero.headline.clarity, className: clarityBorder },
]}
/>
</motion.h1>
<div className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
<Wordmark size="xl" showTagline />
<Antetitulo className="mt-6 md:text-sm">{hero.eyebrow}</Antetitulo>
<h1 className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
{hero.headline.line1}{' '}
<span className="text-dorado">{hero.headline.effort}</span>{' '}
{hero.headline.line3}{' '}
<span className={clarityBorder}>{hero.headline.clarity}</span>
</h1>
<p className="mt-6 text-lg md:text-xl text-azul-profundo/70 leading-relaxed max-w-xl">
{apertura.dreamOutcome}
</p>
{/* Full width while stacked: at their natural widths the two CTAs
came out ragged and unequal, which read as an accident rather
than as hierarchy. */}
<motion.div variants={fadeInUp} className="mt-10 w-full flex flex-col md:flex-row items-stretch md:items-center md:w-auto gap-4">
<div className="mt-10 w-full flex flex-col md:flex-row items-stretch md:items-center md:w-auto gap-4">
<CTA href="#contacto" label={hero.cta} variant="light" className="text-center md:whitespace-nowrap" />
<CTA href="#mecanismo" label={hero.ctaSecundario} variant="ghost" className="text-center md:whitespace-nowrap" />
</motion.div>
</motion.div>
{/* Complexity → focus, left unresolved: the hero poses the tension
and FraseAncla pays it off by resolving into the ≡. Shown on every
breakpoint — hidden below lg it left the phone hero with no piece
of the brand's own graphic language at all. */}
<motion.div variants={fadeInUp} initial="hidden" animate="visible" {...motionProps(reduced)}
className="min-w-0">
<SecuenciaClaridad variant="hero" className="w-32 lg:w-full max-w-md mx-auto text-azul-profundo" />
</motion.div>
<CTA href="#proceso" label={hero.ctaSecundario} variant="ghost" className="text-center md:whitespace-nowrap" />
</div>
</div>
{/* A visual representation of clarity, direction, and growth.
The box is sized here rather than by the scene, so the space is
reserved before three.js arrives and nothing shifts when it does. */}
<div className="min-w-0 h-64 lg:h-[28rem]">
<ClaridadHero className="w-full h-full" />
</div>
</div>
</Container>
</section>
-113
View File
@@ -1,113 +0,0 @@
'use client';
import { motion, useScroll, useTransform } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoFoto } from '@/components/primitives/FondoFoto';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { mecanismo } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps, useAutoStep } from '@/lib/motion';
const ICONOS: IconoNombre[] = ['inmersion', 'desafiar', 'estrategia'];
export function Mecanismo() {
const reduced = usePrefersReducedMotion();
const { active, setActive, hoverProps } = useAutoStep(mecanismo.steps.length, 3200);
const { scrollYProgress } = useScroll();
const desplazamientoFoto = useTransform(scrollYProgress, [0, 1], [-24, 24]);
return (
<section id="mecanismo" className="bg-azul-profundo text-white scroll-mt-24 relative overflow-hidden">
{/* Foto: Golden Gate Bridge, Dietmar Rabich, CC BY-SA 4.0, modificada (duotono) — créditos en el footer */}
<motion.div
aria-hidden="true"
className="absolute inset-0 pointer-events-none"
style={reduced ? undefined : { y: desplazamientoFoto }}
>
<FondoFoto src="/images/mecanismo-bg.jpg" className="inset-0 opacity-25" wash="bg-azul-profundo/80" />
</motion.div>
<Container className="relative py-16 md:py-24">
{/* The step you are on, set enormous and almost invisible behind the
column — the same device the recorrido uses, so the two sections
read as one page rather than as one showpiece and its neighbour.
It follows the autoplay, which gives the section a slow pulse
without adding anything the reader has to attend to. */}
{reduced ? (
<span aria-hidden="true" className="pointer-events-none absolute top-10 right-6 md:right-12 select-none font-sans font-black leading-none text-white/[0.05] text-[9rem] md:text-[18rem]">
{mecanismo.steps[active].n}
</span>
) : (
<motion.span
key={active}
aria-hidden="true"
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="pointer-events-none absolute top-10 right-6 md:right-12 select-none font-sans font-black leading-none text-white/[0.05] text-[9rem] md:text-[18rem]"
>
{mecanismo.steps[active].n}
</motion.span>
)}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{mecanismo.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion">{mecanismo.title}</motion.h2>
{/* Step connector: advances on its own every ~3s (paused on hover or
once the reader picks a step) and pulls the progress line along with
it — the same "connected process" language as en-ti.com. */}
<div {...hoverProps} className="mt-14 hidden md:flex items-center" role="tablist" aria-label="Pasos del mecanismo">
{mecanismo.steps.map((step, i) => (
<div key={step.n} className="flex items-center flex-1 last:flex-none">
<button
type="button"
role="tab"
aria-selected={active === i}
aria-label={`Paso ${step.n}: ${step.name}`}
onClick={() => setActive(i)}
onMouseEnter={() => setActive(i)}
className={`shrink-0 grid place-items-center w-14 h-14 rounded-full border-2 font-sans font-black text-lg transition-colors duration-300 ${
active === i ? 'bg-dorado border-dorado text-azul-profundo' : 'border-white/25 text-white/60 hover:border-dorado/60 hover:text-white'
}`}
>
{step.n}
</button>
{i < mecanismo.steps.length - 1 && (
<div className="flex-1 h-px mx-2 bg-white/15 relative overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 w-full origin-left bg-dorado motion-reduce:transition-none"
initial={false}
animate={{ scaleX: active > i ? 1 : 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
</div>
)}
</div>
))}
</div>
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} {...hoverProps}
className="mt-12 grid gap-10 md:grid-cols-3">
{mecanismo.steps.map((step, i) => (
<motion.li key={step.n} variants={fadeInUp}>
<button
type="button"
onClick={() => setActive(i)}
aria-pressed={active === i}
className={`w-full text-left border-t pt-6 transition-all duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none ${
active === i ? 'border-dorado md:-translate-y-1.5' : 'border-white/20'
}`}
>
<div className="flex items-center justify-between">
<div className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none">{step.n}</div>
<Icono nombre={ICONOS[i]} className="w-10 h-10 md:w-12 md:h-12 text-white/70" />
</div>
<h3 className="mt-4 font-sans font-bold text-xl">{step.name}</h3>
<p className="mt-3 text-gris-medio leading-relaxed">{step.desc}</p>
</button>
</motion.li>
))}
</motion.ol>
</Container>
</section>
);
}
+4 -3
View File
@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { IlustracionBifurcacion } from '@/components/primitives/IlustracionBifurcacion';
import { paraQuien } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -9,7 +10,7 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
export function ParaQuien() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-white relative overflow-hidden">
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
<Container className="relative pt-16 md:pt-24 pb-8 md:pb-12 md:flex md:items-center md:justify-between md:gap-12">
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="font-sans font-black text-seccion text-azul-profundo max-w-3xl">{paraQuien.title}</motion.h2>
@@ -24,7 +25,7 @@ export function ParaQuien() {
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid md:grid-cols-2">
<motion.div variants={fadeInUp} className="bg-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-dorado">
<p className="text-xs tracking-[0.3em] uppercase font-black text-dorado">Sí</p>
<Antetitulo>Sí</Antetitulo>
<p className="mt-3 text-2xl font-sans font-bold text-azul-profundo max-w-sm">{paraQuien.lead}</p>
<ul className="mt-8 space-y-4">
{paraQuien.criteria.map((c) => (
@@ -35,7 +36,7 @@ export function ParaQuien() {
))}
</ul>
</motion.div>
<motion.div variants={fadeInUp} className="bg-azul-profundo text-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-white/10">
<motion.div variants={fadeInUp} className="bg-azul-profundo bg-hondo-navy text-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-white/10">
<p className="text-xs tracking-[0.3em] uppercase font-black text-white/50">No</p>
<p className="mt-3 text-2xl font-sans font-bold text-white/70 max-w-sm">{paraQuien.leadNo}</p>
<ul className="mt-8 space-y-4">
+218
View File
@@ -0,0 +1,218 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoFoto } from '@/components/primitives/FondoFoto';
import { PatronPuntos } from '@/components/primitives/PatronPuntos';
import { SimboloMarca } from '@/components/primitives/SimboloMarca';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { proceso } from '@/lib/content';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
// The eight stages of the dueña's working lámina for a real client, rebuilt to
// its own anatomy: eight blocks laid flat, each with its number in a circle,
// its own icon, and a block labelled ENTREGABLES where every line carries its
// own mark. Everything identifying that client is stripped out in
// lib/content.ts.
//
// The three phases of deck slide 7 survive as a strip under the lead: the
// framework is the dueña's and worth keeping, it just does not dictate the
// layout — the lámina runs its eight blocks flat, and grouping them into three
// banded sections split one grid into three.
//
// Stage 08 sits full width at the foot, where the lámina puts One Year Plan,
// and goes navy — it is the payoff the other seven point at.
const ICONOS: Record<string, IconoNombre> = {
'01': 'compania', // company analysis + FODA — the company from outside
'02': 'inmersion', // internal analysis — dives through the layers
'03': 'consumidor', // Consumer Knowledge — the insight inside the person
'04': 'estrategia', // brand strategy — the path to the flag
'05': 'comunicacion', // communication and branding — the bubbles
'06': 'ventas', // marketing and sales — the rising bars
'07': 'desafiar', // commercial innovation — the cracked assumption
'08': 'plan', // One Year Plan — the checklist
};
// One mark per deliverable, the way the lámina gives every line its own rather
// than repeating a tick. Keyed by the copy itself, so a Proceso.test guard
// checks every deliverable in lib/content.ts has an icon here — otherwise a
// reworded line would silently render blank.
const ICONOS_ENTREGABLE: Record<string, IconoNombre> = {
// 01 · Análisis de compañía y FODA
'FODA': 'matriz',
'Diagnóstico ejecutivo': 'diagnostico',
'Prioridades': 'prioridades',
'Quick wins': 'rayo',
// 02 · Análisis interno
'Mapa de capacidades': 'mapa',
'Restricciones del negocio': 'restriccion',
'Oportunidades de crecimiento': 'brote',
// 03 · Consumer Knowledge
'Segmentación': 'segmentos',
'Buyer persona': 'ficha',
'Customer journey': 'recorrido',
'Necesidades no cubiertas': 'hueco',
// 04 · Estrategia de marca
'Posicionamiento': 'diana',
'Arquitectura de marcas': 'arquitectura',
'Manual estratégico': 'libro',
// 05 · Comunicación y branding
'Plataforma de comunicación': 'megafono',
'Campaña anual': 'calendario',
'Estrategia de medios': 'ondas',
'Plan de contenido': 'documento',
// 06 · Marketing y ventas
'Dashboard CEO': 'medidor',
'Dashboard comercial': 'tablero',
'Comité de crecimiento': 'comite',
// 07 · Innovación comercial
'Roadmap de innovación': 'hitos',
'Go to market': 'cohete',
'Prioridades de innovación': 'bombilla',
// 08 · One Year Plan
'Roadmap a 12 meses': 'anio',
'Iniciativas prioritarias': 'prioridades',
'Gobernanza del crecimiento': 'escudo',
};
export function Proceso() {
const reduced = usePrefersReducedMotion();
return (
<section id="proceso" className="bg-gris-claro bg-hondo-claro scroll-mt-24 relative overflow-hidden">
<Container className="relative py-16 md:py-24">
{/* ── Header ── */}
<Antetitulo as={motion.p} variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
{proceso.eyebrow}
</Antetitulo>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-4xl text-balance">{proceso.title}</motion.h2>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-5 text-azul-profundo/70 leading-relaxed max-w-2xl">{proceso.lead}</motion.p>
{/* ── The three approved phases, as a strip rather than as structure ── */}
<motion.ol
variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-8 flex flex-wrap items-center gap-x-3 gap-y-2 list-none p-0"
>
{proceso.fases.map((fase, i) => (
<li key={fase} className="flex items-center gap-3">
{i > 0 && <span aria-hidden="true" className="text-dorado/60">→</span>}
<span className="text-xs tracking-[0.18em] uppercase font-black text-azul-profundo/70">{fase}</span>
</li>
))}
</motion.ol>
{/* ── The eight blocks, flat. An <ol>, because the sequence is the
point: it carries the order for assistive tech, which lets the
drawn number stay decorative. ── */}
{/* Each card reveals on its own. The list used to be one stagger
container with viewportOnce's amount of 0.3 — on a phone the eight
stacked cards run ~3000px against an ~850px screen, so 30% of the
list is never on screen at once and nothing below the header ever
appeared. */}
<ol className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 list-none p-0">
{proceso.etapas.map((etapa, i) => {
const destacada = 'destacada' in etapa && etapa.destacada;
return (
<motion.li
key={etapa.n}
variants={fadeInUp}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
// The cards in one row still arrive one after another, the way
// the stagger did, without depending on the whole list.
transition={{ delay: (i % 3) * 0.08 }}
{...motionProps(reduced)}
// No hover lift: these cards are not links, and a card that
// rises under the pointer promises a click that goes nowhere.
className={destacada ? 'sm:col-span-2' : ''}
>
<TarjetaSpotlight
className={`relative h-full rounded-2xl p-6 md:p-7 shadow-tarjeta ${
destacada ? 'bg-azul-profundo bg-hondo-navy' : 'border border-azul-profundo/[0.07] bg-white'
}`}
>
{destacada && (
// In the foot of the card, which the grid's equal-height rows
// leave empty — decoration that fills dead space instead of
// crowding the deliverables.
<PatronPuntos rows={3} cols={8} className="absolute -left-2 bottom-5 w-1/3 text-white/[0.08]" />
)}
{/* ── Photo band, with the number and the icon riding on it.
The duotone is what makes eight photographs by eight
different people read as one set: FondoFoto throws away
their colour with mix-blend-luminosity and relights them
navy-to-gold. Every file here is CC0 and credited by path
in `creditosFotos`, which a test enforces. ── */}
<div className={`relative -mx-6 -mt-6 md:-mx-7 md:-mt-7 mb-6 overflow-hidden ${destacada ? 'h-40 md:h-52' : 'h-32'}`}>
<FondoFoto
src={`/images/proceso/${etapa.n}.jpg`}
className="inset-0"
wash={destacada ? 'bg-azul-profundo/55' : 'bg-azul-profundo/60'}
/>
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-4 md:p-5">
<span
aria-hidden="true"
className="grid place-items-center w-10 h-10 rounded-full bg-dorado bg-dorado-metal font-sans font-black text-sm text-azul-profundo shadow-[0_4px_12px_-2px_rgba(10,29,61,0.5)]"
>
{etapa.n}
</span>
<div className="grid place-items-center w-11 h-11 rounded-full bg-azul-profundo/80 backdrop-blur-sm">
<Icono nombre={ICONOS[etapa.n]} className="w-6 h-6 text-dorado" />
</div>
</div>
</div>
<div className="relative flex items-center justify-between gap-4">
<h3 className={`font-sans font-black leading-snug ${
destacada ? 'text-2xl md:text-3xl text-white' : 'text-lg text-azul-profundo'
}`}>
{etapa.name}
</h3>
{/* Beside the title, not in the card's foot: pinned to the
bottom-right corner it sat on top of the third
deliverable once the three ran in a row. Gold needs
real alpha on navy — at 25% it reads as a skeleton. */}
{destacada && <SimboloMarca className="w-12 md:w-14 shrink-0 text-dorado/60" />}
</div>
{/* ── ENTREGABLES ── */}
<div className={`relative mt-5 border-t pt-4 ${destacada ? 'border-white/15' : 'border-azul-profundo/10'}`}>
{/* La 08 va sobre navy y conserva el dorado del manual; las
otras siete son tarjetas blancas y necesitan el tono
legible. */}
<p className={`text-xs tracking-[0.2em] uppercase font-black ${
destacada ? 'text-dorado' : 'text-dorado-texto'
}`}>
{proceso.etiquetaEntregables}
</p>
<ul className={`mt-3 grid gap-2 list-none p-0 ${destacada ? 'sm:grid-cols-3' : ''}`}>
{etapa.entregables.map((entregable) => (
<li
key={entregable}
className={`flex items-start gap-2.5 text-sm ${destacada ? 'text-white' : 'text-azul-profundo/85'}`}
>
<Icono
nombre={ICONOS_ENTREGABLE[entregable]}
className="w-[18px] h-[18px] mt-px shrink-0 text-dorado"
/>
<span>{entregable}</span>
</li>
))}
</ul>
</div>
</TarjetaSpotlight>
</motion.li>
);
})}
</ol>
</Container>
</section>
);
}
+11 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { FondoFoto } from '@/components/primitives/FondoFoto';
import { ParDialogo } from '@/components/primitives/ParDialogo';
@@ -23,12 +24,12 @@ const TOTAL = items.length;
// ever brings its own colour onto the page. Under the veils below they read as
// texture, not as stock photography.
const FOTOS = [
'/images/recorrido/crecer.jpg', // a financial district still going up, cranes and all
'/images/recorrido/consolidarse.jpg', // an empty meeting room: order before more weight
'/images/recorrido/rentabilidad.jpg', // the harvest — effort actually turned into result
'/images/recorrido/simplificar.jpg', // a trading screen too dense to read
'/images/recorrido/expandirse.jpg', // a departures board: markets you have not flown to
'/images/recorrido/portafolio.jpg', // a skyline of towers, every one of them a front
'/images/recorrido/crecer.jpg', // Torre Mitikah, CDMX, still topped by its crane
'/images/recorrido/consolidarse.jpg', // an office built in a hurry: boxes before order
'/images/recorrido/rentabilidad.jpg', // the agave harvest — effort actually turned into result
'/images/recorrido/simplificar.jpg', // overpasses stacked over overpasses, CDMX
'/images/recorrido/expandirse.jpg', // Cancún's boarding gates: markets you have not flown to
'/images/recorrido/portafolio.jpg', // Monterrey's skyline, every tower a front
];
// Reduced motion, and the server's first paint, get the whole conversation laid
@@ -37,10 +38,10 @@ const FOTOS = [
// sixths of the copy behind a scroll position is not an option either way.
function Estatica() {
return (
<section className="bg-gris-claro relative overflow-hidden">
<section className="bg-gris-claro bg-hondo-claro relative overflow-hidden">
<FondoPuntos className="-bottom-6 -right-12 w-80 md:w-[32rem] text-azul-profundo/10" distance={32} />
<Container className="relative py-16 md:py-24">
<h2 className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</h2>
<Antetitulo as="h2">{puntosInflexion.lead}</Antetitulo>
<ul className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
{items.map((item, i) => (
<ParDialogo key={item.name} n={i + 1} {...item} activo reducido onActivar={() => {}} />
@@ -185,9 +186,9 @@ function Escenificado() {
the recorrido its <h2>, and a section this size with no heading
at all is a hole in the document outline — for a screen reader
the six exchanges would hang off nothing. */}
<h2 className="absolute top-24 left-6 md:left-12 text-xs tracking-[0.3em] uppercase font-black text-dorado md:top-28">
<Antetitulo as="h2" sobre="oscuro" className="absolute top-24 left-6 md:left-12 md:top-28">
{puntosInflexion.lead}
</h2>
</Antetitulo>
{/* The rail. Where you are in the conversation, and how much is
left — a pinned section without it feels like a page that
@@ -21,3 +21,16 @@ describe('<Cierre />', () => {
expect(screen.getByRole('navigation', { name: /contacto/i })).toBeInTheDocument();
});
});
describe('<Cierre /> — pestañas', () => {
it('opens WhatsApp and LinkedIn in a new tab and keeps email in place', () => {
render(<Cierre />);
for (const nombre of [/whatsapp/i, /linkedin/i]) {
for (const a of screen.getAllByRole('link', { name: nombre })) {
expect(a).toHaveAttribute('target', '_blank');
expect(a).toHaveAttribute('rel', 'noopener noreferrer');
}
}
expect(screen.getByRole('link', { name: /email/i })).not.toHaveAttribute('target');
});
});
@@ -5,12 +5,12 @@ import { ComoEmpezar } from '../ComoEmpezar';
describe('<ComoEmpezar />', () => {
it('renders the on-ramp steps', () => {
render(<ComoEmpezar />);
expect(screen.getByText('Escríbeme')).toBeInTheDocument();
expect(screen.getByText('Sesión de diagnóstico')).toBeInTheDocument();
expect(screen.getByText('Escríbenos')).toBeInTheDocument();
expect(screen.getByText('Sesión de claridad')).toBeInTheDocument();
});
it('links the CTA to WhatsApp with a prefilled message', () => {
render(<ComoEmpezar />);
const cta = screen.getByRole('link', { name: /Agenda tu sesión de diagnóstico/i });
const cta = screen.getByRole('link', { name: /Agenda tu sesión de claridad/i });
const href = cta.getAttribute('href') ?? '';
expect(href).toMatch(/^https:\/\/wa\.me\//);
expect(href).toContain('text=');
@@ -5,11 +5,18 @@ import { Estratega } from '../Estratega';
describe('<Estratega />', () => {
it('renders the bio', () => {
render(<Estratega />);
expect(screen.getByText(/30 años/i)).toBeInTheDocument();
expect(screen.getByText(/años construyendo marcas/i)).toBeInTheDocument();
expect(screen.getByText(/Hemos acompañado a más de 40 empresas/i)).toBeInTheDocument();
});
it('shows the confirmed figure — 25+, never the stale 30', () => {
// La dueña confirmó 25+ (19/ago/2026); el 30 fue un borrador que
// sobrevivió como hardcode después de que el copy pasó a 25.
const { container } = render(<Estratega />);
expect(container.textContent).not.toMatch(/30/);
});
it('renders the institutional quote', () => {
render(<Estratega />);
expect(screen.getByText(/El tiempo no se compra/i)).toBeInTheDocument();
expect(screen.getByText(/Tu negocio ya demostró que funciona/i)).toBeInTheDocument();
});
it('never lets CSS uppercase turn the brand name into CREEER', () => {
const { container } = render(<Estratega />);
@@ -9,6 +9,11 @@ describe('<FraseAncla />', () => {
expect(container.textContent).toMatch(/falta de claridad/i);
});
it('paints the "after": the anchor names the problem, this line names the outcome', () => {
const { container } = render(<FraseAncla />);
expect(container.textContent).toMatch(/Sabes exactamente dónde invertir/i);
});
it('resolves the brand metaphor into the mark', () => {
const { container } = render(<FraseAncla />);
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(3);
+37 -10
View File
@@ -5,7 +5,7 @@ import { Hero } from '../Hero';
describe('<Hero />', () => {
it('renders the eyebrow', () => {
render(<Hero variant="editorial" />);
expect(screen.getByText(/tu estratega de crecimiento/i)).toBeInTheDocument();
expect(screen.getByText(/claridad estratégica para tu negocio/i)).toBeInTheDocument();
});
it('renders the headline copy', () => {
const { container } = render(<Hero variant="hormozi" />);
@@ -15,11 +15,11 @@ describe('<Hero />', () => {
});
it('renders the CTA', () => {
render(<Hero variant="editorial" />);
expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toHaveAttribute('href', '#contacto');
expect(screen.getByRole('link', { name: /agenda una sesión de claridad/i })).toHaveAttribute('href', '#contacto');
});
it('offers a secondary route into the process for readers not ready to convert', () => {
render(<Hero variant="editorial" />);
expect(screen.getByRole('link', { name: /ver cómo trabajo/i })).toHaveAttribute('href', '#mecanismo');
expect(screen.getByRole('link', { name: /ver cómo trabajamos/i })).toHaveAttribute('href', '#proceso');
});
it('does NOT render scarcity (cupos/cohorte)', () => {
const { container } = render(<Hero variant="editorial" />);
@@ -33,13 +33,40 @@ describe('<Hero />', () => {
expect(container.textContent).not.toMatch(/Trabajo más y gano casi lo mismo/i);
});
it('poses the brand metaphor without resolving it', () => {
// El hero es el LCP de la página, y la página se abre desde un enlace de
// WhatsApp con datos móviles. Mientras el titular naciera en opacity 0
// esperando a que hidratara React, montara el IntersectionObserver y corriera
// el stagger, lo primero que veía el lector era una pantalla en blanco — y si
// el JS fallaba, se quedaba así. El export estático llegó a salir con 126
// elementos en opacity 0, el <h1> entre ellos.
it('entra pintado: nada del hero nace oculto', () => {
const { container } = render(<Hero variant="editorial" />);
// SecuenciaClaridad is the only thing here drawn from <line>; the dot
// fields are circles. variant="hero" plays dispersion and convergence
// only — the ≡ resolution is FraseAncla's payoff, and a hero that
// resolved would spend the beat before the copy earns it.
expect(container.querySelectorAll('line').length).toBeGreaterThan(10);
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(0);
const ocultos = Array.from(container.querySelectorAll<HTMLElement>('*')).filter(
(el) => el.style.opacity === '0',
);
expect(ocultos).toHaveLength(0);
});
it('el titular no se difumina palabra a palabra', () => {
const { container } = render(<Hero variant="editorial" />);
const difuminados = Array.from(container.querySelectorAll<HTMLElement>('*')).filter((el) =>
el.style.filter.includes('blur'),
);
expect(difuminados).toHaveLength(0);
});
// El edificio es three.js: ~185 kB que el titular no puede esperar. Vive tras
// next/dynamic con ssr:false, así que el hero se pinta entero sin él y el
// export estático no lleva ni un <canvas>. Cuando el dynamic() vivía en el
// propio Hero — un server component — Next 14 se negaba a construir.
it('el hero se pinta sin esperar al edificio', () => {
const { container } = render(<Hero variant="editorial" />);
expect(container.querySelector('h1')).toBeInTheDocument();
expect(container.querySelector('canvas')).toBeNull();
});
it('renders the Dream Outcome below the headline', () => {
const { container } = render(<Hero variant="editorial" />);
expect(container.textContent).toMatch(/plan a 12 meses/i);
});
});
@@ -1,43 +0,0 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Mecanismo } from '../Mecanismo';
describe('<Mecanismo />', () => {
it('renders the 3 steps in order', () => {
render(<Mecanismo />);
expect(screen.getByText('Inmersión')).toBeInTheDocument();
expect(screen.getByText('Desafiar supuestos')).toBeInTheDocument();
expect(screen.getByText('Estrategia ejecutable')).toBeInTheDocument();
});
it('renders step numbers 01/02/03', () => {
const { container } = render(<Mecanismo />);
expect(container.textContent).toMatch(/01.+02.+03/);
});
it('keeps the background photo subtle enough not to fight the text', () => {
const { container } = render(<Mecanismo />);
const bg = container.querySelector('img');
expect(bg).toHaveAttribute('src', '/images/mecanismo-bg.jpg');
expect(bg?.closest('[class*="opacity-"]')).not.toBeNull();
});
describe('auto-advancing step indicator', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('highlights the first step by default, then advances on its own', () => {
render(<Mecanismo />);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
act(() => { vi.advanceTimersByTime(3300); });
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
it('stops advancing while the pointer is over the steps', () => {
render(<Mecanismo />);
const tabs = screen.getAllByRole('tab');
fireEvent.mouseEnter(screen.getByRole('tablist'));
act(() => { vi.advanceTimersByTime(6000); });
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
});
});
@@ -14,11 +14,11 @@ describe('<ParaQuien />', () => {
expect(screen.getByText(/necesitas claridad/i)).toBeInTheDocument();
expect(screen.getByText(/sin perder tu esencia/i)).toBeInTheDocument();
});
it('renders the disqualifying criteria from the dueña deck', () => {
it('renders the disqualifying criteria', () => {
render(<ParaQuien />);
expect(screen.getByText(/Esto no es para ti si/i)).toBeInTheDocument();
expect(screen.getByText(/fase inicial o sin tracción/i)).toBeInTheDocument();
expect(screen.getByText(/solución rápida de venta/i)).toBeInTheDocument();
expect(screen.getByText(/solución rápida, no un cambio de fondo/i)).toBeInTheDocument();
expect(screen.getByText(/un curso, no una transformación/i)).toBeInTheDocument();
expect(screen.getByText(/crisis de supervivencia/i)).toBeInTheDocument();
});
@@ -0,0 +1,119 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Proceso } from '../Proceso';
import { proceso, creditosFotos } from '@/lib/content';
describe('<Proceso />', () => {
it('renders the three approved phases as a strip', () => {
render(<Proceso />);
expect(proceso.fases).toHaveLength(3);
for (const fase of proceso.fases) {
expect(screen.getByText(fase)).toBeInTheDocument();
}
});
it('renders all eight stages, numbered 01 through 08', () => {
render(<Proceso />);
expect(proceso.etapas).toHaveLength(8);
for (const etapa of proceso.etapas) {
expect(screen.getByText(etapa.name)).toBeInTheDocument();
expect(screen.getByText(etapa.n)).toBeInTheDocument();
}
});
// The argument of the section: "claridad estratégica" is abstract until it
// is a document you can hold. The lámina labels this block on all eight of
// its cards and marks every line — losing either turns the deliverables back
// into a list of service names.
it('labels the deliverables block on every card and marks every line', () => {
const { container } = render(<Proceso />);
expect(screen.getAllByText(proceso.etiquetaEntregables)).toHaveLength(8);
for (const entregable of proceso.etapas.flatMap((e) => e.entregables)) {
expect(screen.getByText(entregable)).toBeInTheDocument();
}
// One <ul> of deliverables per stage, every line of it carrying an icon.
const listas = container.querySelectorAll('ul');
expect(listas).toHaveLength(proceso.etapas.length);
listas.forEach((lista, i) => {
const esperados = proceso.etapas[i].entregables.length;
expect(lista.querySelectorAll(':scope > li')).toHaveLength(esperados);
expect(lista.querySelectorAll(':scope > li > svg')).toHaveLength(esperados);
});
});
// The icon map is keyed by the copy itself, so rewording a deliverable in
// lib/content.ts without updating the map would render a blank space where
// its mark should be. Nothing else would fail.
it('gives every deliverable its own icon, none of them empty', () => {
const { container } = render(<Proceso />);
const items = Array.from(container.querySelectorAll('ul > li'));
expect(items).toHaveLength(proceso.etapas.reduce((n, e) => n + e.entregables.length, 0));
for (const item of items) {
const svg = item.querySelector(':scope > svg');
expect(svg, `«${item.textContent}» se quedó sin ícono`).not.toBeNull();
expect(svg!.children.length, `el ícono de «${item.textContent}» está vacío`).toBeGreaterThan(0);
}
});
// Stage 01's medallion was the magnifier, and so was its own "Diagnóstico
// ejecutivo" line — the same glyph twice in one card, which reads as a
// mistake. Nothing but the eye catches this, so the eye gets help.
it('never repeats a glyph inside the same card', () => {
const { container } = render(<Proceso />);
const tarjetas = Array.from(container.querySelectorAll('ol'))[1].querySelectorAll(':scope > li');
tarjetas.forEach((tarjeta, i) => {
const glifos = Array.from(tarjeta.querySelectorAll('svg')).map((s) => s.innerHTML);
expect(new Set(glifos).size, `la etapa ${proceso.etapas[i].n} repite un ícono`).toBe(glifos.length);
});
});
it('closes on the One Year Plan, the payoff the other seven point at', () => {
render(<Proceso />);
expect(screen.getByText('One Year Plan')).toBeInTheDocument();
expect(screen.getByText('Roadmap a 12 meses')).toBeInTheDocument();
expect(screen.getByText('Gobernanza del crecimiento')).toBeInTheDocument();
});
// The stages are a sequence, so the order has to reach assistive tech
// through the markup — otherwise it lives only in the drawn number, which is
// decorative and never announced.
it('carries the order in the markup, not just in the drawn number', () => {
const { container } = render(<Proceso />);
const listas = container.querySelectorAll('ol');
expect(listas).toHaveLength(2); // the phase strip and the eight stages
expect(listas[1].querySelectorAll(':scope > li')).toHaveLength(8);
});
// The footer is the only place in the project where the photo licences are
// honoured, and it renders `creditosFotos` verbatim. An image added here
// without an entry there would go uncredited and nothing else would notice.
it('credits every photograph it shows', () => {
const { container } = render(<Proceso />);
const fotos = Array.from(container.querySelectorAll('img')).map((i) => i.getAttribute('src'));
expect(fotos).toHaveLength(proceso.etapas.length);
for (const src of fotos) {
expect(
creditosFotos.some((c) => 'archivo' in c && c.archivo === src),
`«${src}» no tiene crédito en creditosFotos`,
).toBe(true);
}
});
it('loads the photographs lazily, since none of them is above the fold', () => {
const { container } = render(<Proceso />);
for (const img of Array.from(container.querySelectorAll('img'))) {
expect(img).toHaveAttribute('loading', 'lazy');
}
});
// Everything here is adapted from the dueña's working deck for a real
// client. None of that client's detail may reach a public page.
it('leaks nothing that identifies the client the deck was built for', () => {
const { container } = render(<Proceso />);
expect(container.textContent).not.toMatch(
/cápsulas|gomitas|tabletas|planta|sell in|sell out|mercado mexicano|líderes de la categoría/i,
);
});
});
@@ -38,6 +38,15 @@ describe('<PuntosInflexion />', () => {
}
});
it('never lets creeer answer by repeating the situation back', () => {
// The restructure of lib/content.ts once aliased `respuesta` to
// `situacion`: problem and answer read the same, and the test above still
// passed because the situation renders inside «».
for (const { situacion, respuesta } of puntosInflexion.items) {
expect(respuesta).not.toBe(situacion);
}
});
it('states each situation in the second person, as the dueña reads them', () => {
// Her own phrasing in the 31/jul call: "estás atrapado en tu compañía",
// "creciste muy rápido pero quieres expandirte". The deck's infinitives