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

+3 -2
View File
@@ -1,6 +1,6 @@
# creeer — Landing page # creeer — Landing page
One-page landing para **creeer**, marca personal de una estratega de crecimiento. Stack: Next.js 14 (App Router) + TypeScript + Tailwind CSS + Framer Motion, exportada como sitio estático. Español. One-page landing para **creeer**, firma de claridad estratégica. Stack: Next.js 14 (App Router) + TypeScript + Tailwind CSS + Framer Motion, exportada como sitio estático. Español.
## Estado del proyecto ## Estado del proyecto
@@ -17,7 +17,8 @@ One-page landing para **creeer**, marca personal de una estratega de crecimiento
- **Paleta:** Azul Profundo `#0A1D3D` + Dorado Estratégico `#C4942E` + Blanco `#FFFFFF` + neutros (Azul Claro `#C8EEF5`, Gris `#F2F2F2`, Gris Medio `#AAAAAA`). **Sin amarillo eléctrico** tipo Hormozi. - **Paleta:** Azul Profundo `#0A1D3D` + Dorado Estratégico `#C4942E` + Blanco `#FFFFFF` + neutros (Azul Claro `#C8EEF5`, Gris `#F2F2F2`, Gris Medio `#AAAAAA`). **Sin amarillo eléctrico** tipo Hormozi.
- **Tipografía:** **Open Sans** (vía `next/font/google`). Es lo más cercano a Aptos en open-source (mismo diseñador: Steve Matteson). **Aptos descartada** en web: la EULA de Microsoft prohíbe servirla públicamente. Decisión de la dueña, 28/jul/2026. - **Tipografía:** **Open Sans** (vía `next/font/google`). Es lo más cercano a Aptos en open-source (mismo diseñador: Steve Matteson). **Aptos descartada** en web: la EULA de Microsoft prohíbe servirla públicamente. Decisión de la dueña, 28/jul/2026.
- **Tagline de marca:** "CLARIDAD · DIRECCIÓN · CRECIMIENTO". Eslogan: "Estrategia que transforma." - **Tagline de marca:** "CLARIDAD · DIRECCIÓN · CRECIMIENTO". Eslogan: "Estrategia que transforma."
- **Voz:** segunda persona ("tú"/"tu negocio"), sin jerga corporativa. - **Voz:** segunda persona ("tú"/"tu negocio"), sin jerga corporativa. **creeer habla en primera persona del plural** ("nos sumergimos", "cuestionamos") — decisión de Erik del 6/ago/2026, **visto bueno de la dueña el 19/ago/2026**; antes era singular. Única excepción: las citas de `dolores`, donde el "yo" es el cliente. Hay un test que lo blinda en `lib/__tests__/content.test.ts`.
- **Cifra de trayectoria: 25+** años construyendo marcas (confirmado por la dueña, 19/ago/2026). No 30+.
- **Sin escasez en el hero:** no mostrar cupos, cohorte ni "Generación Fundadora" arriba (decisión de la dueña). - **Sin escasez en el hero:** no mostrar cupos, cohorte ni "Generación Fundadora" arriba (decisión de la dueña).
## Stack y constraints ## Stack y constraints
+2 -2
View File
@@ -1,6 +1,6 @@
# creeer # creeer
Landing page one-page para **creeer**, marca personal de una estratega de crecimiento. Next.js 14 (App Router) + TypeScript + Tailwind CSS + Framer Motion, exportada como sitio estático. Landing page one-page para **creeer**, firma de claridad estratégica. Next.js 14 (App Router) + TypeScript + Tailwind CSS + Framer Motion, exportada como sitio estático.
## Requisitos ## Requisitos
@@ -23,7 +23,7 @@ pnpm lint # ESLint
app/ # App Router (layout, page, globals.css) app/ # App Router (layout, page, globals.css)
components/ components/
primitives/ # Wordmark, Container, CTA (reutilizables) primitives/ # Wordmark, Container, CTA (reutilizables)
sections/ # Hero, FraseAncla, ParaQuien, Mecanismo, Disciplinas, Estratega, Cierre, ComoEmpezar sections/ # Hero, PuntosInflexion, FraseAncla, Dolores, Proceso, Disciplinas, ParaQuien, Estratega, ComoEmpezar, Cierre
layout/ # Header, Footer layout/ # Header, Footer
lib/ lib/
content.ts # todo el copy (source of truth) content.ts # todo el copy (source of truth)
+36 -2
View File
@@ -18,9 +18,43 @@
} }
@layer base { @layer base {
a:focus-visible, button:focus-visible { /* The ring needs to contrast against the background it appears on, not
outline: 3px solid #C4942E; against the brand. The global gold gave 2.76:1 on white, below the 3:1
WCAG 2.2 requires for a focus indicator. Deep blue on white gives 16.7:1;
and on the two navy sections, where the navy would disappear, gold takes
over, which gives 6.07:1 there. */
a:focus-visible,
button:focus-visible {
outline: 3px solid #0a1d3d;
outline-offset: 3px; outline-offset: 3px;
border-radius: 2px; border-radius: 2px;
} }
.bg-azul-profundo a:focus-visible,
.bg-azul-profundo button:focus-visible {
outline-color: #c4942e;
}
}
/* The page's only motion gesture. The hidden state depends on `.js`, which an
inline script in <head> sets before first paint: without JavaScript the
class never lands, nothing is hidden, and the page reads in full. Anyone
who asked for less motion is also excluded here — the media query leaves
them out entirely rather than neutralising it afterwards. */
@media (prefers-reduced-motion: no-preference) {
.js [data-revelar] {
opacity: 0;
transform: translateY(12px);
}
.js [data-revelar][data-visible] {
animation: revelar 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 60ms);
}
}
@keyframes revelar {
to {
opacity: 1;
transform: none;
}
} }
+11 -5
View File
@@ -5,23 +5,29 @@ import './globals.css';
const openSans = Open_Sans({ subsets: ['latin'], weight: ['400', '600', '700', '800'], style: ['normal', 'italic'], variable: '--font-open-sans', display: 'swap' }); const openSans = Open_Sans({ subsets: ['latin'], weight: ['400', '600', '700', '800'], style: ['normal', 'italic'], variable: '--font-open-sans', display: 'swap' });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'creeer · tu estratega de crecimiento', title: 'creeer · claridad estratégica para tu negocio',
description: 'Claridad estratégica para empresarios con tracción que llegaron a su punto de inflexión. Inmersión profunda, estrategia ejecutable.', description: 'Tu negocio no necesita más esfuerzo — necesita más claridad. Estrategia ejecutable para empresarios con tracción en su punto de inflexión.',
metadataBase: new URL('https://creeer.com'), // TODO dueña: real domain metadataBase: new URL('https://creeer.com'), // TODO dueña: real domain
openGraph: { openGraph: {
title: 'creeer · tu estratega de crecimiento', title: 'creeer · claridad estratégica para tu negocio',
description: 'Claridad estratégica para empresarios con tracción en punto de inflexión.', description: 'Tu negocio no necesita más esfuerzo — necesita más claridad. Estrategia ejecutable para empresarios con tracción.',
type: 'website', type: 'website',
locale: 'es', locale: 'es',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'creeer' }], images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'creeer' }],
}, },
twitter: { card: 'summary_large_image', title: 'creeer', description: 'Claridad estratégica para empresarios.' }, twitter: { card: 'summary_large_image', title: 'creeer', description: 'Tu negocio no necesita más esfuerzo — necesita más claridad.' },
alternates: { canonical: 'https://creeer.com' }, alternates: { canonical: 'https://creeer.com' },
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="es" className={openSans.variable}> <html lang="es" className={openSans.variable}>
<head>
{/* Blocking and before first paint on purpose: it decides whether the
CSS can hide anything at all. If this doesn't run, there is no
hidden state to reveal. */}
<script dangerouslySetInnerHTML={{ __html: "document.documentElement.classList.add('js')" }} />
</head>
<body>{children}</body> <body>{children}</body>
</html> </html>
); );
+10 -7
View File
@@ -5,8 +5,7 @@ import { FraseAncla } from '@/components/sections/FraseAncla';
import { PuntosInflexion } from '@/components/sections/PuntosInflexion'; import { PuntosInflexion } from '@/components/sections/PuntosInflexion';
import { Dolores } from '@/components/sections/Dolores'; import { Dolores } from '@/components/sections/Dolores';
import { ParaQuien } from '@/components/sections/ParaQuien'; import { ParaQuien } from '@/components/sections/ParaQuien';
import { Mecanismo } from '@/components/sections/Mecanismo'; import { Proceso } from '@/components/sections/Proceso';
import { FormaTrabajar } from '@/components/sections/FormaTrabajar';
import { Disciplinas } from '@/components/sections/Disciplinas'; import { Disciplinas } from '@/components/sections/Disciplinas';
import { Estratega } from '@/components/sections/Estratega'; import { Estratega } from '@/components/sections/Estratega';
import { ComoEmpezar } from '@/components/sections/ComoEmpezar'; import { ComoEmpezar } from '@/components/sections/ComoEmpezar';
@@ -16,7 +15,7 @@ const jsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'ProfessionalService', '@type': 'ProfessionalService',
name: 'creeer', name: 'creeer',
description: 'Estratega de crecimiento. Claridad estratégica para empresarios con tracción.', description: 'Claridad estratégica para empresarios con tracción en su punto de inflexión.',
url: 'https://creeer.com', url: 'https://creeer.com',
}; };
@@ -27,16 +26,20 @@ export default function Home() {
<Header /> <Header />
{/* El recorrido va en segundo lugar por decisión de la dueña: el lector {/* El recorrido va en segundo lugar por decisión de la dueña: el lector
entra por el gancho y cae directo en la secuencia anclada, que plantea entra por el gancho y cae directo en la secuencia anclada, que plantea
seis situaciones y las contesta. FraseAncla y Dolores llegan después, seis situaciones y las contesta. FraseAncla y Dolores llegan después.
y Mecanismo cierra explicando el método detrás de esas respuestas. */} Disciplinas precede a Proceso a propósito: su lead —«no son la
promesa, son los medios»— tiene que aterrizar ANTES de que el lector
se encuentre ocho etapas llamadas «Comunicación y branding» y
«Marketing y ventas», o Proceso se lee como catálogo de agencia, que
es justo lo que el posicionamiento de la dueña descarta. Después de
Proceso llegaba tarde, como nota al pie. */}
<main> <main>
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */} <Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
<PuntosInflexion /> <PuntosInflexion />
<FraseAncla /> <FraseAncla />
<Dolores /> <Dolores />
<Mecanismo />
<FormaTrabajar />
<Disciplinas /> <Disciplinas />
<Proceso />
<ParaQuien /> <ParaQuien />
<Estratega /> <Estratega />
<ComoEmpezar /> <ComoEmpezar />
+18 -3
View File
@@ -1,6 +1,8 @@
import { Wordmark } from '@/components/primitives/Wordmark'; import { Wordmark } from '@/components/primitives/Wordmark';
import { Container } from '@/components/primitives/Container'; 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() { export function Footer() {
return ( 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"> <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 /> <Wordmark size="sm" onDark />
<p className="text-sm text-gris-medio">{footer.tagline}</p> <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> <p className="text-xs text-gris-medio">{footer.legal}</p>
</Container> </Container>
<Container className="pb-6 text-center md:text-left"> <Container className="pb-6 text-center md:text-left">
@@ -15,8 +30,8 @@ export function Footer() {
{creditosFotos.map((c, i) => ( {creditosFotos.map((c, i) => (
<span key={c.href}> <span key={c.href}>
{i > 0 && ' · '} {i > 0 && ' · '}
<a href={c.href} className="hover:text-gris-medio">{c.desc}</a>, modificada,{' '} <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} className="hover:text-gris-medio">{c.licencia}</a> <a href={c.licenciaHref} {...propsEnlace(c.licenciaHref)} className="hover:text-gris-medio">{c.licencia}<span className="sr-only"> ({AVISO_PESTANA})</span></a>
</span> </span>
))} ))}
</p> </p>
@@ -29,3 +29,29 @@ describe('<Footer />', () => {
expect(screen.getAllByText(/modificada/)).toHaveLength(creditosFotos.length); 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'; type Variant = 'light' | 'dark' | 'ghost';
// Every variant carries border-2 so filled and outlined CTAs line up when // Every variant carries border-2 so filled and outlined CTAs line up when
// they sit side by side. // they sit side by side.
const styles: Record<Variant, string> = { const styles: Record<Variant, string> = {
light: 'border-2 border-transparent bg-azul-profundo text-white hover:bg-azul-profundo/90', 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', 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 ( return (
<a <a
href={href} 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}`} className={`group inline-block font-sans font-bold uppercase tracking-wider px-7 py-3.5 text-sm transition-colors ${styles[variant]} ${className}`}
> >
{label}{' '} {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> <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> </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'; 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 // ComoEmpezar and Disciplinas. Same stroke weight and geometric language as
// the manual's own iconography guidance ("líneas simples, geométricas, // the manual's own iconography guidance ("líneas simples, geométricas,
// consistentes en grosor"), hand-drawn to match each step's specific idea // consistentes en grosor"), hand-drawn to match each step's specific idea
@@ -16,10 +16,39 @@ export type IconoNombre =
| 'comunicacion' | 'comunicacion'
| 'digital' | 'digital'
| 'experiencias' | '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> = { 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". // hasta comprenderla desde dentro".
inmersion: ( inmersion: (
<> <>
@@ -30,7 +59,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<circle cx="12" cy="20.5" r="0.75" fill="currentColor" stroke="none" /> <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". // dan por hecho".
desafiar: ( desafiar: (
<> <>
@@ -46,7 +75,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<path d="M20 6h-5l2.5-3.5L20 6" fill="currentColor" stroke="none" /> <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: ( escribeme: (
<> <>
<path d="M4 5.5h16v10H10l-4 3.5v-3.5H4z" /> <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} /> <circle cx="12" cy="12" r="9" strokeOpacity={0.55} />
</> </>
), ),
// Simple clock — "cada proceso tiene su ritmo". // A person with the insight sitting inside the head, not beside it — the
reloj: ( // point of Consumer Knowledge is what you find in there.
consumidor: (
<> <>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="7.5" r="3.75" />
<path d="M12 7v5l3.5 3.5" /> <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 }) { export function Icono({ nombre, className = '' }: { nombre: IconoNombre; className?: string }) {
return ( return (
<svg <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' 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} {String(n).padStart(2, '0')} · {name}
</p> </p>
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug"> <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" /> <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> </div>
</motion.div> </motion.div>
</li> </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 // 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 // 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 // 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 — // to it. The "hero" variant resolves the chaos into a skyline silhouette —
// FraseAncla pays it off further down the page. // structure emerging from disorder, what creeer builds for its clients.
// FraseAncla pays off the full ≡ resolution further down the page.
type Variant = 'completa' | 'hero'; type Variant = 'completa' | 'hero';
@@ -40,6 +41,55 @@ const DISPERSAS = [
// converge — many/disordered becomes few/aligned, per the manual. // converge — many/disordered becomes few/aligned, per the manual.
const SOBREVIVE = [0, 6, 12]; 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; const EASE = [0.22, 1, 0.36, 1] as const;
export function SecuenciaClaridad({ export function SecuenciaClaridad({
@@ -54,26 +104,23 @@ export function SecuenciaClaridad({
const completa = variant === 'completa'; const completa = variant === 'completa';
// Where each line ends up. In "completa" the survivors land exactly on the // Where each line ends up. In "completa" the survivors land exactly on the
// symbol's own coordinates; in "hero" they merely tighten toward their // symbol's own coordinates; in "hero" the lines form a skyline silhouette.
// group's axis without ever aligning.
const destino = (i: number) => { 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 { barra } = DISPERSAS[i];
const linea = LINEAS_SIMBOLO[barra]; const linea = LINEAS_SIMBOLO[barra];
const sobrevive = SOBREVIVE.includes(i); 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 { return {
x1: linea.x1, x1: linea.x1,
y1: linea.y1, y1: linea.y1,
@@ -87,8 +134,7 @@ export function SecuenciaClaridad({
completa && SOBREVIVE.includes(i) ? LINEAS_SIMBOLO[DISPERSAS[i].barra].grosor : 1.5; completa && SOBREVIVE.includes(i) ? LINEAS_SIMBOLO[DISPERSAS[i].barra].grosor : 1.5;
// Reduced motion renders plain <line> elements: motion.line would apply its // Reduced motion renders plain <line> elements: motion.line would apply its
// own dash scaffolding and leave the drawing invisible (see the same trap in // own dash scaffolding and leave the drawing invisible.
// IlustracionInflexion).
if (reduced) { if (reduced) {
return ( return (
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false"> <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"> <svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
{DISPERSAS.map(({ barra: _barra, ...inicio }, i) => { {DISPERSAS.map(({ barra: _barra, ...inicio }, i) => {
const d = destino(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 }; const reposo = { ...inicio, opacity: 0.55, strokeWidth: 1.5 };
return ( return (
<motion.line <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'); expect(screen.getByRole('link').getAttribute('href')).toBe('#contacto');
}); });
it('renders the ghost variant outlined, so it reads as secondary', () => { 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+/); const classes = screen.getByRole('link').className.split(/\s+/);
expect(classes).toContain('border-azul-profundo'); expect(classes).toContain('border-azul-profundo');
expect(classes).not.toContain('bg-azul-profundo'); // sólo se rellena en hover expect(classes).not.toContain('bg-azul-profundo'); // sólo se rellena en hover
expect(classes).toContain('hover:bg-azul-profundo'); 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', () => { it('keeps every variant the same height', () => {
const { container } = render( 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 { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Icono, type IconoNombre } from '../Icono'; import { Icono, NOMBRES_ICONO } from '../Icono';
const NOMBRES: IconoNombre[] = [
'inmersion',
'desafiar',
'estrategia',
'escribeme',
'diagnostico',
'plan',
'comunicacion',
'digital',
'experiencias',
];
describe('<Icono />', () => { describe('<Icono />', () => {
it('is decorative: hidden from assistive tech', () => { it('is decorative: hidden from assistive tech', () => {
@@ -20,7 +8,7 @@ describe('<Icono />', () => {
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); 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 { container } = render(<Icono nombre={nombre} />);
const svg = container.querySelector('svg'); const svg = container.querySelector('svg');
expect(svg?.children.length).toBeGreaterThan(0); 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, n: 1,
name: 'Crecer', name: 'Crecer',
situacion: 'El siguiente salto no tiene mapa.', 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]> = {}) => const montar = (extra: Partial<Parameters<typeof ParDialogo>[0]> = {}) =>
@@ -20,7 +20,7 @@ describe('<ParDialogo />', () => {
it('renders both voices of the exchange', () => { it('renders both voices of the exchange', () => {
montar(); montar();
expect(screen.getByText(/El siguiente salto no tiene mapa/)).toBeInTheDocument(); 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', () => { 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 // document. `hidden` or `display: none` would take it away from screen
// readers and from the static export the crawler sees — half the section's // readers and from the static export the crawler sees — half the section's
// message, gone, to buy an animation. // 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).toBeInTheDocument();
expect(respuesta.closest('[hidden]')).toBeNull(); expect(respuesta.closest('[hidden]')).toBeNull();
expect(screen.getByLabelText(/Respuesta de creeer a: Crecer/).className).toMatch(/opacity-0\b/); 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 { Container } from '@/components/primitives/Container';
import { CTA } from '@/components/primitives/CTA'; import { CTA } from '@/components/primitives/CTA';
import { Wordmark } from '@/components/primitives/Wordmark'; import { Wordmark } from '@/components/primitives/Wordmark';
import { IconoRed, NOMBRES_RED } from '@/components/primitives/IconoRed';
import { FondoPuntos } from '@/components/primitives/FondoPuntos'; import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { Resplandor } from '@/components/primitives/Resplandor';
import { cierre } from '@/lib/content'; import { cierre } from '@/lib/content';
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Cierre() { export function Cierre() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
const { whatsapp, email, linkedin } = cierre.contact; const { whatsapp } = cierre.contact;
return ( 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} /> <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"> <Container className="relative py-16 md:py-24 text-center">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex flex-col items-center"> className="flex flex-col items-center">
@@ -26,9 +30,16 @@ export function Cierre() {
</motion.div> </motion.div>
<motion.nav variants={fadeInUp} aria-label="Contacto" className="mt-14"> <motion.nav variants={fadeInUp} aria-label="Contacto" className="mt-14">
<ul className="flex flex-wrap justify-center gap-3 text-sm"> <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> {NOMBRES_RED.map((red) => (
<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 key={red}>
<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> <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> </ul>
</motion.nav> </motion.nav>
</motion.div> </motion.div>
+4 -3
View File
@@ -3,6 +3,7 @@
import { motion, useScroll, useTransform } from 'framer-motion'; import { motion, useScroll, useTransform } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { CTA } from '@/components/primitives/CTA'; import { CTA } from '@/components/primitives/CTA';
import { Icono, type IconoNombre } from '@/components/primitives/Icono'; import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { comoEmpezar, cierre } from '@/lib/content'; import { comoEmpezar, cierre } from '@/lib/content';
@@ -18,10 +19,10 @@ export function ComoEmpezar() {
const { scrollYProgress } = useScroll(); const { scrollYProgress } = useScroll();
const desplazamiento = useTransform(scrollYProgress, [0, 1], [20, -20]); const desplazamiento = useTransform(scrollYProgress, [0, 1], [20, -20]);
return ( 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"> <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.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.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"> <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) => ( {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" /> <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> <h3 className="font-sans font-bold text-xl text-azul-profundo">{step.name}</h3>
</div> </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> </div>
</motion.li> </motion.li>
))} ))}
+50 -13
View File
@@ -2,7 +2,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight'; import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Resplandor } from '@/components/primitives/Resplandor';
import { Icono, type IconoNombre } from '@/components/primitives/Icono'; import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { IlustracionConvergencia } from '@/components/primitives/IlustracionConvergencia'; import { IlustracionConvergencia } from '@/components/primitives/IlustracionConvergencia';
import { disciplinas } from '@/lib/content'; import { disciplinas } from '@/lib/content';
@@ -18,16 +20,45 @@ export function Disciplinas() {
// nobody loses anything by not having a mouse. // nobody loses anything by not having a mouse.
const [senalada, setSenalada] = useState<number | null>(null); const [senalada, setSenalada] = useState<number | null>(null);
return ( return (
<section className="bg-white"> // Azul profundo, no blanco: al mover esta sección delante de Proceso
<Container className="py-16 md:py-24"> // quedaba pegada a Dolores, que también es blanca, y las dos se fundían.
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} // Además rompe el tramo de seis secciones claras seguidas que había entre
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{disciplinas.eyebrow}</motion.p> // FraseAncla y Cierre, y le da a la página un latido oscuro a media altura.
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <section className="bg-azul-profundo bg-hondo-navy text-white relative overflow-hidden">
className="mt-4 font-sans font-black text-seccion text-azul-profundo">{disciplinas.title}</motion.h2> <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)} <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"> className="md:flex md:items-end md:justify-between md:gap-12">
<p className="text-gris-medio text-lg">{disciplinas.lead}</p> <div>
<IlustracionConvergencia className="mt-6 md:mt-0 w-56 md:w-64 h-auto shrink-0" activa={senalada} /> {/* 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.div>
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <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"> 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} <motion.li key={item.name} variants={fadeInUp}
onPointerEnter={() => setSenalada(i)} onPointerEnter={() => setSenalada(i)}
onPointerLeave={() => setSenalada(null)} onPointerLeave={() => setSenalada(null)}
className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none"> // No lift: the cards are not links. What answers the pointer is
<TarjetaSpotlight className="h-full rounded-lg bg-azul-profundo text-white p-7"> // the card's own strand lighting up in the drawing above, plus a
<div className="grid place-items-center w-14 h-14 rounded-full bg-white/10"> // 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" /> <Icono nombre={ICONOS[i]} className="w-7 h-7 text-dorado" />
</div> </div>
<h3 className="mt-5 font-sans font-black text-2xl">{item.name}</h3> <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> </TarjetaSpotlight>
</motion.li> </motion.li>
))} ))}
+5 -3
View File
@@ -2,6 +2,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo'; import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque'; import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { dolores } from '@/lib/content'; import { dolores } from '@/lib/content';
@@ -28,15 +29,16 @@ export function Dolores() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
return ( 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 {/* 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 whole title it read as a smudge over the headline rather than as a
knot. Stays centred. */} 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]" /> <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"> <Container className="relative py-16 md:py-24">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <Antetitulo as={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> {dolores.eyebrow}
</Antetitulo>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <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> 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'; 'use client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; 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 { ContadorNumero } from '@/components/primitives/ContadorNumero';
import { MarcoRetrato } from '@/components/primitives/MarcoRetrato';
import { estratega } from '@/lib/content'; import { estratega } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Estratega() { export function Estratega() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
return ( return (
<section className="bg-gris-claro"> <section className="bg-gris-claro bg-hondo-claro">
<Container className="py-16 md:py-24"> <Container className="py-16 md:py-24">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center"> className="max-w-2xl">
{/* TODO dueña: real portrait. The frame is built to take it as-is — <Antetitulo as={motion.p} variants={fadeInUp}>
drop an <Image fill /> in place of the placeholder below. */} {estratega.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span>
<motion.div variants={fadeInUp}> </Antetitulo>
<MarcoRetrato> <motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo">{estratega.title}</motion.h2>
{/* Reads as a brand plate, not as a broken image. The dot grid <motion.div variants={fadeInUp} className="mt-6 flex items-baseline gap-3">
that used to sit behind the wordmark looked like the <ContadorNumero hasta={25} sufijo="+" className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none" />
checkerboard a browser shows for a picture that failed to <p className="text-xs uppercase tracking-[0.14em] text-azul-profundo/70 font-semibold max-w-[12rem] leading-snug">años construyendo marcas</p>
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>
</motion.div> </motion.div>
<div> <motion.p variants={fadeInUp} className="mt-6 text-lg text-azul-profundo/70 leading-relaxed">{estratega.bio}</motion.p>
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado"> <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.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span> {estratega.quote}
</motion.p> </motion.blockquote>
<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.div> </motion.div>
</Container> </Container>
</section> </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 { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad'; import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
import { Resplandor } from '@/components/primitives/Resplandor';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque'; import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { fraseAncla } from '@/lib/content'; import { fraseAncla } from '@/lib/content';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -10,7 +11,10 @@ import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/
export function FraseAncla() { export function FraseAncla() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
return ( 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"> <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)} <motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex justify-center"> className="flex justify-center">
@@ -33,6 +37,19 @@ export function FraseAncla() {
]} ]}
/> />
</motion.p> </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> </Container>
</section> </section>
); );
+26 -38
View File
@@ -1,19 +1,14 @@
'use client';
import { motion } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark'; import { Wordmark } from '@/components/primitives/Wordmark';
import { CTA } from '@/components/primitives/CTA'; import { CTA } from '@/components/primitives/CTA';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoPuntos } from '@/components/primitives/FondoPuntos'; import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad'; import { ClaridadHero } from '@/components/primitives/ClaridadHero';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque'; import { hero, apertura } from '@/lib/content';
import { hero } from '@/lib/content';
import { fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion';
type Variant = 'hormozi' | 'editorial'; type Variant = 'hormozi' | 'editorial';
export function Hero({ variant = 'editorial' }: { variant?: Variant }) { export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
const reduced = usePrefersReducedMotion();
const isHormozi = variant === 'hormozi'; const isHormozi = variant === 'hormozi';
const headlineBase = isHormozi const headlineBase = isHormozi
? 'font-sans font-black uppercase tracking-tight' ? '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'; const clarityBorder = isHormozi ? 'border-b-[5px] border-dorado pb-0.5' : 'border-b-[3px] border-dorado pb-0.5';
return ( 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 {/* 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 reached back across the logotype and the tagline, so the pattern read
as collision rather than texture. */} 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} /> <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"> <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"> <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)} <div className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left"> <Wordmark size="xl" showTagline />
<motion.div variants={fadeInUp}><Wordmark size="xl" showTagline /></motion.div> <Antetitulo className="mt-6 md:text-sm">{hero.eyebrow}</Antetitulo>
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-black text-dorado"> <h1 className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
{hero.eyebrow} {hero.headline.line1}{' '}
</motion.p> <span className="text-dorado">{hero.headline.effort}</span>{' '}
<motion.h1 variants={fadeInUp} className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}> {hero.headline.line3}{' '}
<TextoDesenfoque <span className={clarityBorder}>{hero.headline.clarity}</span>
as="span" </h1>
segments={[ <p className="mt-6 text-lg md:text-xl text-azul-profundo/70 leading-relaxed max-w-xl">
{ text: hero.headline.line1 }, {apertura.dreamOutcome}
{ text: hero.headline.effort, className: 'text-dorado' }, </p>
{ text: hero.headline.line3 },
{ text: hero.headline.clarity, className: clarityBorder },
]}
/>
</motion.h1>
{/* Full width while stacked: at their natural widths the two CTAs {/* Full width while stacked: at their natural widths the two CTAs
came out ragged and unequal, which read as an accident rather came out ragged and unequal, which read as an accident rather
than as hierarchy. */} 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="#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" /> <CTA href="#proceso" label={hero.ctaSecundario} variant="ghost" className="text-center md:whitespace-nowrap" />
</motion.div> </div>
</motion.div> </div>
{/* Complexity → focus, left unresolved: the hero poses the tension {/* A visual representation of clarity, direction, and growth.
and FraseAncla pays it off by resolving into the ≡. Shown on every The box is sized here rather than by the scene, so the space is
breakpoint — hidden below lg it left the phone hero with no piece reserved before three.js arrives and nothing shifts when it does. */}
of the brand's own graphic language at all. */} <div className="min-w-0 h-64 lg:h-[28rem]">
<motion.div variants={fadeInUp} initial="hidden" animate="visible" {...motionProps(reduced)} <ClaridadHero className="w-full h-full" />
className="min-w-0"> </div>
<SecuenciaClaridad variant="hero" className="w-32 lg:w-full max-w-md mx-auto text-azul-profundo" />
</motion.div>
</div> </div>
</Container> </Container>
</section> </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 { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { IlustracionBifurcacion } from '@/components/primitives/IlustracionBifurcacion'; import { IlustracionBifurcacion } from '@/components/primitives/IlustracionBifurcacion';
import { paraQuien } from '@/lib/content'; import { paraQuien } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -9,7 +10,7 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
export function ParaQuien() { export function ParaQuien() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
return ( 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"> <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)} <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> 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)} <motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid md:grid-cols-2"> 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"> <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> <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"> <ul className="mt-8 space-y-4">
{paraQuien.criteria.map((c) => ( {paraQuien.criteria.map((c) => (
@@ -35,7 +36,7 @@ export function ParaQuien() {
))} ))}
</ul> </ul>
</motion.div> </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="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> <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"> <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 { useEffect, useRef, useState } from 'react';
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion'; import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoPuntos } from '@/components/primitives/FondoPuntos'; import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { FondoFoto } from '@/components/primitives/FondoFoto'; import { FondoFoto } from '@/components/primitives/FondoFoto';
import { ParDialogo } from '@/components/primitives/ParDialogo'; 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 // ever brings its own colour onto the page. Under the veils below they read as
// texture, not as stock photography. // texture, not as stock photography.
const FOTOS = [ const FOTOS = [
'/images/recorrido/crecer.jpg', // a financial district still going up, cranes and all '/images/recorrido/crecer.jpg', // Torre Mitikah, CDMX, still topped by its crane
'/images/recorrido/consolidarse.jpg', // an empty meeting room: order before more weight '/images/recorrido/consolidarse.jpg', // an office built in a hurry: boxes before order
'/images/recorrido/rentabilidad.jpg', // the harvest — effort actually turned into result '/images/recorrido/rentabilidad.jpg', // the agave harvest — effort actually turned into result
'/images/recorrido/simplificar.jpg', // a trading screen too dense to read '/images/recorrido/simplificar.jpg', // overpasses stacked over overpasses, CDMX
'/images/recorrido/expandirse.jpg', // a departures board: markets you have not flown to '/images/recorrido/expandirse.jpg', // Cancún's boarding gates: markets you have not flown to
'/images/recorrido/portafolio.jpg', // a skyline of towers, every one of them a front '/images/recorrido/portafolio.jpg', // Monterrey's skyline, every tower a front
]; ];
// Reduced motion, and the server's first paint, get the whole conversation laid // 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. // sixths of the copy behind a scroll position is not an option either way.
function Estatica() { function Estatica() {
return ( 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} /> <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"> <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"> <ul className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
{items.map((item, i) => ( {items.map((item, i) => (
<ParDialogo key={item.name} n={i + 1} {...item} activo reducido onActivar={() => {}} /> <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 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 at all is a hole in the document outline — for a screen reader
the six exchanges would hang off nothing. */} 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} {puntosInflexion.lead}
</h2> </Antetitulo>
{/* The rail. Where you are in the conversation, and how much is {/* The rail. Where you are in the conversation, and how much is
left — a pinned section without it feels like a page that 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(); 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 />', () => { describe('<ComoEmpezar />', () => {
it('renders the on-ramp steps', () => { it('renders the on-ramp steps', () => {
render(<ComoEmpezar />); render(<ComoEmpezar />);
expect(screen.getByText('Escríbeme')).toBeInTheDocument(); expect(screen.getByText('Escríbenos')).toBeInTheDocument();
expect(screen.getByText('Sesión de diagnóstico')).toBeInTheDocument(); expect(screen.getByText('Sesión de claridad')).toBeInTheDocument();
}); });
it('links the CTA to WhatsApp with a prefilled message', () => { it('links the CTA to WhatsApp with a prefilled message', () => {
render(<ComoEmpezar />); 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') ?? ''; const href = cta.getAttribute('href') ?? '';
expect(href).toMatch(/^https:\/\/wa\.me\//); expect(href).toMatch(/^https:\/\/wa\.me\//);
expect(href).toContain('text='); expect(href).toContain('text=');
@@ -5,11 +5,18 @@ import { Estratega } from '../Estratega';
describe('<Estratega />', () => { describe('<Estratega />', () => {
it('renders the bio', () => { it('renders the bio', () => {
render(<Estratega />); 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', () => { it('renders the institutional quote', () => {
render(<Estratega />); 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', () => { it('never lets CSS uppercase turn the brand name into CREEER', () => {
const { container } = render(<Estratega />); const { container } = render(<Estratega />);
@@ -9,6 +9,11 @@ describe('<FraseAncla />', () => {
expect(container.textContent).toMatch(/falta de claridad/i); 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', () => { it('resolves the brand metaphor into the mark', () => {
const { container } = render(<FraseAncla />); const { container } = render(<FraseAncla />);
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(3); expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(3);
+37 -10
View File
@@ -5,7 +5,7 @@ import { Hero } from '../Hero';
describe('<Hero />', () => { describe('<Hero />', () => {
it('renders the eyebrow', () => { it('renders the eyebrow', () => {
render(<Hero variant="editorial" />); 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', () => { it('renders the headline copy', () => {
const { container } = render(<Hero variant="hormozi" />); const { container } = render(<Hero variant="hormozi" />);
@@ -15,11 +15,11 @@ describe('<Hero />', () => {
}); });
it('renders the CTA', () => { it('renders the CTA', () => {
render(<Hero variant="editorial" />); 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', () => { it('offers a secondary route into the process for readers not ready to convert', () => {
render(<Hero variant="editorial" />); 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)', () => { it('does NOT render scarcity (cupos/cohorte)', () => {
const { container } = render(<Hero variant="editorial" />); 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); 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" />); const { container } = render(<Hero variant="editorial" />);
// SecuenciaClaridad is the only thing here drawn from <line>; the dot const ocultos = Array.from(container.querySelectorAll<HTMLElement>('*')).filter(
// fields are circles. variant="hero" plays dispersion and convergence (el) => el.style.opacity === '0',
// only — the ≡ resolution is FraseAncla's payoff, and a hero that );
// resolved would spend the beat before the copy earns it. expect(ocultos).toHaveLength(0);
expect(container.querySelectorAll('line').length).toBeGreaterThan(10); });
expect(container.querySelectorAll('[data-resolucion]')).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(/necesitas claridad/i)).toBeInTheDocument();
expect(screen.getByText(/sin perder tu esencia/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 />); render(<ParaQuien />);
expect(screen.getByText(/Esto no es para ti si/i)).toBeInTheDocument(); 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(/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(/un curso, no una transformación/i)).toBeInTheDocument();
expect(screen.getByText(/crisis de supervivencia/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', () => { 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", // 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 // "creciste muy rápido pero quieres expandirte". The deck's infinitives
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,320 @@
# «Las Tres Líneas» — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reemplazar la escena Three.js del hero ("Astrolabio") por «Las Tres Líneas»: tres barras doradas que pasan de caos → ≡ → escalera → crecimiento, con reposo vivo.
**Architecture:** Solo se reescribe el interior de `components/primitives/EscenaClaridad.tsx` (misma ruta, misma API `className`, mismo export). El wrapper (IntersectionObserver, `prefers-reduced-motion`, `SiHayWebGL`, props del `Canvas`) queda idéntico. `ClaridadHero.tsx`, `Hero.tsx` y las 4 pruebas existentes no se tocan.
**Tech Stack:** Next.js 14 (App Router, `output: export`), `three@0.185`, `@react-three/fiber@8`, TypeScript, Vitest.
**Spec:** `docs/superpowers/specs/2026-08-21-animacion-tres-lineas-design.md`
## Global Constraints
- Gestor de paquetes: **pnpm** (nunca npm). Comandos: `pnpm test`, `pnpm lint`, `npx tsc --noEmit`, `pnpm build`.
- **Comentarios de código en inglés**; copy/docs/commits en español.
- Paleta: dorado `#C4942E` (la escena no usa azul — ya no hay anillos ni sombra).
- **Staging del repo tiene cambios ajenos staged** (deletes de `FormaTrabajar`/`Mecanismo`, rename `CLAUDE.md`). Al commitear usa **rutas explícitas** (`git add <archivo>`), nunca `git add -A` / `git add .`.
- Commits de esta implementación **aprobados** por el usuario en la rama `animacion-tres-lineas`.
- `prefers-reduced-motion` debe montar la pose final directamente (patrón `estatico` existente).
## Fuente de verdad de la coreografía
Los parámetros aprobados son los del **preview del visual companion** (elección de Erik, concepto A). Nota: el geometry es **centrado** (`BoxGeometry(2.3, 0.3, 0.3)`) y crece simétricamente en X — así se ve en el preview aprobado; ignora la mención a "origen en extremo" heredada del código viejo en una frase del spec.
---
### Task 1: Reescribir `EscenaClaridad.tsx` con «Las Tres Líneas»
**Files:**
- Modify: `components/primitives/EscenaClaridad.tsx` (reescritura completa del interior)
- Test (sin edits): `components/primitives/__tests__/EscenaClaridad.test.tsx`
**Interfaces:**
- Consumes: `usePrefersReducedMotion` de `@/lib/motion` (ya usada), `Canvas`/`useFrame`/`useThree` de `@react-three/fiber`.
- Produces: `export function EscenaClaridad({ className = '' }: { className?: string })` — firma idéntica a la actual. `ClaridadHero` y los tests la consumen sin cambios.
- [ ] **Step 1: Baseline — las 4 pruebas pasan hoy**
Run: `pnpm test components/primitives/__tests__/EscenaClaridad.test.tsx`
Expected: 4 passed (aria-hidden, tamaño del caller, frameloop always, frameloop demand).
- [ ] **Step 2: Reemplazar el archivo completo por la nueva escena**
Escribir en `components/primitives/EscenaClaridad.tsx`:
```tsx
'use client';
import { Component, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { usePrefersReducedMotion } from '@/lib/motion';
// VISUAL CONCEPT: "Las Tres Líneas" — the brand symbol tells the story.
// 1. Caos: three golden bars drift tangled, each on its own frequencies.
// 2. Claridad: they settle into the "≡" of cre≡er — level, exact, still.
// 3. Dirección: together they tilt ~9° and stagger into a staircase.
// 4. Crecimiento: each line extends forward (the top one the most) with a
// rising golden glow. The scene then rests, barely alive: a slow camera
// drift and an almost imperceptible emissive pulse. No loops, no resets.
const DORADO = new THREE.Color('#C4942E');
const suave = (t: number) => 1 - Math.pow(1 - t, 3);
const tramo = (t: number, desde: number, dura: number) =>
Math.min(1, Math.max(0, (t - desde) / dura));
// Phase timings (seconds), verbatim from the approved preview.
const CAOS_DURA = 3.0;
const CLARIDAD = { desde: 3.0, dura: 2.2 };
const DIRECCION = { desde: 5.2, dura: 2.0 };
const CRECIMIENTO = { desde: 7.2, dura: 2.4 };
// ── Las Tres Líneas ─────────────────────────────────────────────────────────
function TresLineas({ estatico }: { estatico: boolean }) {
const time = useRef(estatico ? 10 : 0);
const m1 = useRef<THREE.Mesh>(null);
const m2 = useRef<THREE.Mesh>(null);
const m3 = useRef<THREE.Mesh>(null);
const meshes = [m1, m2, m3];
// Decoupled drift frequencies per bar, so the chaos never syncs.
const deriva = useMemo(
() => [
{ fx: 1.1, fy: 0.9, fz: 0.7, rx: 0.8, ry: 1.1 },
{ fx: 1.47, fy: 1.4, fz: 1.4, rx: 1.1, ry: 0.9 },
{ fx: 1.84, fy: 1.9, fz: 0.7, rx: 1.4, ry: 0.7 },
],
[],
);
const geometria = useMemo(() => new THREE.BoxGeometry(2.3, 0.3, 0.3), []);
const materiales = useMemo(
() =>
[0, 1, 2].map(
() =>
new THREE.MeshStandardMaterial({
color: DORADO,
metalness: 0.8,
roughness: 0.25,
emissive: DORADO,
emissiveIntensity: 0,
}),
),
[],
);
// Final poses: the "≡" (identity rotation) and the tilted staircase.
const qSimbolo = useMemo(() => new THREE.Quaternion().identity(), []);
const qEscalera = useMemo(
() => new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, -0.16)),
[],
);
useFrame((_, delta) => {
if (!estatico) time.current += delta;
const t = time.current;
const claridad = suave(tramo(t, CLARIDAD.desde, CLARIDAD.dura));
const direccion = suave(tramo(t, DIRECCION.desde, DIRECCION.dura));
const crecimiento = suave(tramo(t, CRECIMIENTO.desde, CRECIMIENTO.dura));
// "Reposo vivo": a slow emissive breathing once the story is told.
const pulso = Math.sin(t * 0.8) * 0.03 * crecimiento;
meshes.forEach((ref, i) => {
const mesh = ref.current;
if (!mesh) return;
const d = deriva[i];
// Caos: free drift around the frame.
const posCaos = new THREE.Vector3(
Math.sin(t * d.fx + i * 2.1) * 1.6,
Math.cos(t * d.fy + i) * 1.1 + 0.7,
Math.sin(t * d.fz + i * 3) * 0.9,
);
const rotCaos = new THREE.Quaternion().setFromEuler(
new THREE.Euler(t * d.rx, t * (d.ry - i * 0.2), t * 0.5),
);
// Claridad: the "≡" of the wordmark.
const posSimbolo = new THREE.Vector3(0, 0.7 - i * 0.7, 0);
// Dirección: the ascending staircase.
const posEscalera = new THREE.Vector3(i * 1.15 - 1.15, 0.35 + i * 0.8, 0);
mesh.position.copy(
posCaos.lerp(posSimbolo, claridad).lerp(posEscalera, direccion),
);
mesh.quaternion.slerpQuaternions(rotCaos, qSimbolo, claridad);
mesh.quaternion.slerp(qEscalera, direccion);
// Crecimiento: each line extends, the top one the most.
mesh.scale.set(
1 + direccion * 0.1 + crecimiento * (0.35 + i * 0.35),
1,
1,
);
materiales[i].emissiveIntensity = crecimiento * (0.1 + i * 0.06) + pulso;
});
});
return (
<group>
{meshes.map((ref, i) => (
<mesh key={i} ref={ref} geometry={geometria} material={materiales[i]} />
))}
</group>
);
}
// ── Cámara — frontal, with the preview's gentle drift ───────────────────────
const CAMARA: [number, number, number] = [0, 0.9, 8];
const MIRA: [number, number, number] = [0, 0.7, 0];
function Camara({ estatico }: { estatico: boolean }) {
const { camera, invalidate } = useThree();
const time = useRef(estatico ? 10 : 0);
useEffect(() => {
camera.position.set(...CAMARA);
camera.lookAt(...MIRA);
invalidate();
}, [camera, invalidate]);
useFrame((_, delta) => {
if (estatico) return;
time.current += delta;
camera.position.set(
CAMARA[0] + Math.sin(time.current * 0.25) * 0.5,
CAMARA[1],
CAMARA[2],
);
camera.lookAt(...MIRA);
});
return null;
}
// ── Iluminación — the three lights of the approved preview ──────────────────
function Luces() {
return (
<>
<ambientLight intensity={1.4} color="#ffffff" />
<directionalLight position={[4, 8, 5]} intensity={2.2} color="#ffffff" />
<pointLight position={[0, 2, 4]} intensity={10} distance={25} color={DORADO} />
</>
);
}
// ── WebGL Guard ─────────────────────────────────────────────────────────────
class SiHayWebGL extends Component<{ children: ReactNode }, { falla: boolean }> {
state = { falla: false };
static getDerivedStateFromError() { return { falla: true }; }
render() { return this.state.falla ? null : this.props.children; }
}
// ── El Componente ───────────────────────────────────────────────────────────
export function EscenaClaridad({ className = '' }: { className?: string }) {
const reducido = usePrefersReducedMotion();
const contenedor = useRef<HTMLDivElement>(null);
const [enVista, setEnVista] = useState(true);
useEffect(() => {
const el = contenedor.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const obs = new IntersectionObserver(([entrada]) => setEnVista(entrada.isIntersecting));
obs.observe(el);
return () => obs.disconnect();
}, []);
const animando = enVista && !reducido;
return (
<div ref={contenedor} className={className} aria-hidden="true">
<SiHayWebGL>
<Canvas
dpr={[1, 2]}
frameloop={animando ? 'always' : 'demand'}
camera={{ position: CAMARA, fov: 38, near: 0.1, far: 100 }}
gl={{ antialias: true, alpha: true, powerPreference: 'low-power' }}
style={{ background: 'transparent' }}
>
<Camara estatico={reducido} />
<Luces />
<TresLineas estatico={reducido} />
</Canvas>
</SiHayWebGL>
</div>
);
}
```
Diferencias clave vs. el archivo viejo: fuera `Anillos`, `Simbolo`, `Suelo`/`texturaSombra` y la `pointLight` azul; el `fov` pasa de 40 a 38 y `far` de 60 a 100 (los del preview); `Camara` recibe `estatico` para congelar la deriva en reduced-motion.
- [ ] **Step 3: Correr las pruebas del componente**
Run: `pnpm test components/primitives/__tests__/EscenaClaridad.test.tsx`
Expected: 4 passed — el wrapper no cambió de contrato.
- [ ] **Step 4: Commit**
```bash
git add components/primitives/EscenaClaridad.tsx
git commit -m "feat(hero): el astrolabio deja paso a «Las Tres Líneas» — caos, ≡, escalera, crecimiento"
```
---
### Task 2: Verificación completa y ajuste de luces
**Files:**
- Modify (solo si hace falta): `components/primitives/EscenaClaridad.tsx`
**Interfaces:**
- Consumes: Task 1 completo.
- Produces: nada nuevo — verificación.
- [ ] **Step 1: Typecheck y lint**
Run: `npx tsc --noEmit && pnpm lint`
Expected: sin errores. Si `three@0.185` tipa distinto algún prop de luces, ajusta el valor, no el tipo.
- [ ] **Step 2: Suite completa**
Run: `pnpm test`
Expected: todos pasan (incl. content/contraste/escala, intactos).
- [ ] **Step 3: Build de export estático**
Run: `pnpm build`
Expected: export exitoso a `out/` sin errores de prerender.
- [ ] **Step 4: Humo visual**
Run: `pnpm dev` → abrir `http://localhost:3000`, dejar correr el relato completo (~10 s) y verificar: (a) las tres barras forman el ≡, (b) se vuelven escalera ascendente, (c) crecen con brillo, (d) reposo con deriva sutil sin reiniciar, (e) con DevTools → rendering → emulate prefers-reduced-motion, la escena monta directo en la escalera, quieta.
Nota de luces: el preview corrió en three 0.160; en 0.185 la caída física de la `pointLight` es igual pero si el dorado se ve apagado o quemado, ajusta `intensity` (rango sano 6–16) en `Luces` — es calibración, no cambio de concepto.
- [ ] **Step 5: Commit (solo si hubo ajuste)**
```bash
git add components/primitives/EscenaClaridad.tsx
git commit -m "fix(hero): calibración de luces de «Las Tres Líneas» para three 0.185"
```
Si no hubo ajuste, este paso se omite.
---
## Self-Review
- **Cobertura del spec:** concepto/tabla de fases → Task 1 Step 2 (`CAOS_DURA`, `CLARIDAD`, `DIRECCION`, `CRECIMIENTO`, `pulso`, deriva de cámara); reposo vivo → `pulso` + drift de `Camara`; reduced-motion → `estatico` en `Camara`/`TresLineas` + `frameloop: demand`; fuera de vista → IntersectionObserver intacto; eliminaciones (Anillos/Suelo/pointLight azul) → código nuevo no los contiene; pruebas intactas → Steps 1/3; commit en rama → aprobado. ✔
- **Placeholders:** ninguno — todo paso tiene código o comando exacto. ✔
- **Consistencia de tipos:** `EscenaClaridad({ className }: { className?: string })` idéntico al consumido por `ClaridadHero.tsx:54` y por los tests. `Camara`/`TresLineas` comparten la prop `estatico: boolean`. ✔
@@ -0,0 +1,247 @@
# Rediseño completo · «El documento»
**Fecha:** 19/ago/2026
**Rama:** a crear desde `fix/contraste-y-hero-visible`
**Estado:** aprobado por el usuario (dirección y alcance), pendiente de la dueña
---
## 1. Encargo
Rediseñar la landing por completo. El usuario fijó qué sobrevive:
| Se conserva | Queda libre |
|---|---|
| La paleta entera (tokens de `tailwind.config.ts`) | Estructura y orden de secciones |
| El wordmark `cre≡er` (SVG trazado del manual) | Copy |
| Open Sans | Layout, retícula, escala tipográfica |
| | Sistema gráfico, ilustraciones, fotografía |
| | Movimiento |
| | Arquitectura de componentes |
**Conflicto declarado y aceptado:** `docs/dueña-directrices.md` registra que la dueña pidió el 30/jul "algo de gráfico y movimiento". Esta dirección reduce ambos. Y su doc dice de los dolores y los puntos de inflexión: "ya están escritos en su voz — usarlos, no reescribirlos"; este spec los conserva verbatim (§10), pero reorganiza todo lo demás. Ambas cosas necesitan el visto bueno de la dueña antes de publicar.
## 2. Concepto rector
creeer vende un documento. Su propio deck lo dice: cada etapa "deja algo por escrito", y la etapa 08 *es* un One Year Plan.
**La página se comporta como el entregable en vez de describirlo.** Un documento estratégico escrito sobre el negocio del lector: secciones numeradas, columna de margen, reglas finas, medida de texto disciplinada, cero decoración ambiental. La página demuestra claridad en lugar de prometerla.
De ahí sale todo lo demás. Cuando una decisión no esté en este spec, la pregunta es: *¿esto lo haría un documento estratégico bien compuesto?*
## 3. Estructura: 6 secciones
| # | Sección | Fondo | Origen |
|---|---|---|---|
| 01 | Apertura | blanco | `Hero` |
| 02 | Dónde estás | blanco | `PuntosInflexion` + `Dolores` |
| 03 | Qué cambia | **navy** | `FraseAncla` + lead de `Disciplinas` |
| 04 | El trabajo | blanco | `Proceso` |
| 05 | Para quién · Quién | blanco | `ParaQuien` + `Estratega` |
| 06 | Empezar | **navy** | `ComoEmpezar` + `Cierre` |
### 01 · Apertura
Wordmark (`size="xl"`, con tagline), antetítulo, titular en `display`, una línea de quién, dos CTAs. Sin ilustración, sin campos de puntos. Altura natural — se acabó el `min-h-[80vh]`, que en móvil pelea con la barra del navegador.
Su columna de margen lleva el número `01` y su `MarcaSeccion` en `paso={0}`: las líneas más dispersas de toda la página. El sistema queda establecido desde la primera pantalla.
### 02 · Dónde estás
Los seis puntos de inflexión como tabla de dos columnas: nombre (700) | situación (`cuerpo`), separados por filete `gris-claro`. Se lee entera en segundos.
Debajo, un aparte enmarcado con los cuatro dolores en primera persona, a `sumario` en cursiva, uno por línea con filete entre ellos. Cierra el remate de la dueña ("Esto no se resuelve con más esfuerzo…") a `titulo`, con la resolución en dorado.
**Desaparece el escenario fijado de 600vh**, con su `IntersectionObserver` manual, sus seis fotografías, sus velos, su raíl y su barra de progreso.
### 03 · Qué cambia — navy
El centro emocional y el primer navy. La frase ancla va a `titulo` con la segunda cláusula en dorado — no a `display`, que se gasta una sola vez en toda la página y es del titular de 01 (§4). Debajo, el insight que hoy vive en `Disciplinas`: "No vendemos marketing. Vendemos claridad estratégica". Las tres disciplinas quedan como una línea de texto, no como tres tarjetas con icono.
### 04 · El trabajo — blanco
Las ocho etapas como índice numerado, no como ocho tarjetas con foto. Cada etapa: número tabular, nombre a `sumario`/700, y los entregables como chips `azul-claro` con texto navy. Las tres fases aprobadas (Inmersión → Desafiar supuestos → Estrategia ejecutable) como tira encima del índice.
La etapa 08 se distingue por ser la última y por llevar su nombre a `titulo` en vez de a `sumario`, no por cambiar de color de fondo ni de tarjeta.
**Desaparecen las ocho fotografías** y los 27 iconos de entregable (a 18px con trazo efectivo de 1,2px no se distinguen entre sí).
### 05 · Para quién · Quién — blanco
Dos columnas separadas por filete vertical: "Sí es para ti" / "No es para ti", cuatro criterios cada una. Sin losa navy — el navy está reservado a 03 y 06.
Debajo, quién está detrás: hueco de retrato (`aspect-[4/5]`), el 30+ como cifra estática, bio y la cita institucional. **La cifra ya no cuenta hacia arriba.**
### 06 · Empezar — navy
Los tres pasos como lista numerada, el CTA principal a WhatsApp, y los tres enlaces de contacto. Cierra con el wordmark y el legal. Absorbe el footer.
## 4. Sistema tipográfico
Open Sans, cinco roles. Reemplazan a `ancla` y `seccion`, que se eliminan.
```ts
fontSize: {
display: ['clamp(3.5rem, 9vw, 7rem)', { lineHeight: '0.94', letterSpacing: '-0.03em' }],
titulo: ['clamp(2.25rem, 4.5vw, 3.5rem)', { lineHeight: '1', letterSpacing: '-0.02em' }],
sumario: ['clamp(1.375rem, 2vw, 1.75rem)', { lineHeight: '1.35', letterSpacing: '-0.01em' }],
cuerpo: ['1.0625rem', { lineHeight: '1.7' }],
rotulo: ['0.75rem', { lineHeight: '1.2', letterSpacing: '0.3em' }],
}
```
**Reglas duras:**
- `display` aparece **exactamente una vez** en toda la página: el titular de 01. Hoy hay cinco elementos empatados en 4,5rem, y por eso ninguno es el más grande.
- Medida máxima de texto corrido: **68 caracteres** (`max-w-[68ch]`).
- Pesos: 400 cuerpo · 600 énfasis y rótulos · 700 títulos · 800 display. **Prohibido `font-black`**: Open Sans llega a 800 y `layout.tsx` carga hasta 800, así que los 34 usos actuales caen a 800 de todos modos. Se elimina el peso 900 del vocabulario.
- Números de sección y de etapa con `font-variant-numeric: tabular-nums`.
- `text-balance` en titulares, `text-pretty` en párrafos.
## 5. Retícula
```
│ 02 │ DÓNDE ESTÁS │
│ ≡ │ │
│ │ Seis situaciones. Reconoce la tuya. │
│ │ │
│ │ Crecer El siguiente salto │
│ │ no tiene mapa. │
```
- Contenedor: `max-w-content` sube de 1152px a **1280px**, con `px-6 md:px-12`.
- `Seccion` monta `grid md:grid-cols-[7rem_minmax(0,1fr)] gap-x-10`. Columna izquierda: número tabular + `MarcaSeccion`. Columna derecha: todo el contenido.
- En móvil colapsa a una columna; el número y la marca van en línea sobre el antetítulo.
- La tensión entre reglas anchas y texto angosto (68ch dentro de 1280px) es deliberada: es lo que hace que la composición se lea editorial.
**Ritmo vertical**, tres niveles — hoy ocho secciones comparten `py-16 md:py-24` y la página va como un metrónomo:
| Nivel | Padding | Dónde |
|---|---|---|
| `compacta` | `py-20 md:py-28` | 04, 05 |
| `normal` | `py-28 md:py-40` | 02 |
| `momento` | `py-36 md:py-56` | 01, 03, 06 |
01 va en `momento` y sin `min-height`: la apertura respira por padding, no por alto de viewport.
## 6. Color
Los tokens no cambian. Cambia la disciplina de uso.
- **Un solo blanco.** Se eliminan `hondo-blanco` y `hondo-claro`: sus gradientes se solapan (`hondo-claro` empieza en `#FFFFFF`) y hacían indistinguibles cuatro secciones seguidas. Fondo claro = `#FFFFFF` plano.
- **`gris-claro`** deja de ser fondo de sección: solo filetes y rellenos de ficha.
- **Navy exactamente dos veces**, en 03 y 06. Se conserva `hondo-navy` para darles relieve.
- **Dorado, tres trabajos y ninguno más:** el `≡` del wordmark, `MarcaSeccion`, y una palabra de acento por declaración. Se elimina el dorado como relleno de icono y como campo ambiental. Se eliminan `velo-dorado`, `velo-frio` y `dorado-metal`.
- **`azul-claro #C8EEF5`** — hoy declarado y sin usar — pasa a ser el fondo de los chips de entregables en 04, con texto navy: **13,5:1**.
- Se conserva `dorado-texto #8A6520` para texto pequeño dorado sobre claro.
**Contraste mínimo exigido:** 4,5:1 texto normal, 3:1 texto grande y gráficos con significado. Esto cierra los pendientes que quedaron abiertos en la revisión: el acento del titular, el 30+ de Estratega, la flecha entre fases y los iconos dorados sobre blanco — todos desaparecen o se recolorean.
## 7. `MarcaSeccion`: el ≡ como sistema
La única idea gráfica de la página, y la respuesta a "le falta gráfico".
Cada sección lleva su marca en la columna de margen, y **la marca progresa al bajar**: muchas líneas desordenadas al principio, tres barras alineadas al final. La filosofía del manual (Complejidad → Enfoque → Claridad) deja de estar ilustrada en un rincón y pasa a ser el mobiliario del documento.
**Implementación:** reutiliza la geometría ya probada de `lib/simbolo.ts` (`VIEWBOX_SIMBOLO`, `LINEAS_SIMBOLO`, `BARRAS_SIMBOLO`) y las posiciones dispersas de `SecuenciaClaridad`. `MarcaSeccion({ paso })` con `paso` de 0 a 5 interpola **estáticamente** cada línea entre su posición dispersa y su barra destino con factor `paso/5`; las líneas no supervivientes bajan de opacidad con el mismo factor. En `paso === 5` quedan las tres barras del `≡` exactas.
Sin animación, sin estado, sin JS: es un SVG calculado en render. Server component.
**Sustituye a todo el aparato gráfico actual.** Se eliminan diecisiete primitivas: `FondoPuntos`, `PatronPuntos`, `Resplandor`, `IlustracionNudo`, `IlustracionBifurcacion`, `IlustracionInflexion`, `IlustracionConvergencia`, `SecuenciaClaridad`, `SimboloMarca`, `TarjetaSpotlight`, `MarcoRetrato`, `EscenaDialogo`, `ParDialogo`, `FondoFoto`, `TextoDesenfoque`, `ContadorNumero`, `ProgresoLectura`, y el sistema de iconos `Icono` con sus 41 glifos.
## 8. Movimiento
**Un solo gesto: `revelar`.** 12px de subida + fade, 400ms, `cubic-bezier(0.22, 1, 0.36, 1)`, una vez, al entrar en viewport, escalonado 60ms entre hermanos.
Se eliminan: parallax, scroll fijado, contadores, spotlight, hover-lift, blur palabra a palabra, barra de progreso de lectura, compactación del header, dibujado de ilustraciones y puntos reactivos al cursor.
**Montado al revés de como está hoy — este es el punto crítico.** El export actual manda 112 elementos en `opacity: 0`; si el JS no llega, la página no se lee.
1. `app/layout.tsx` incluye un script inline bloqueante en `<head>`: `document.documentElement.classList.add('js')`.
2. `globals.css`:
```css
@media (prefers-reduced-motion: no-preference) {
.js [data-revelar] { opacity: 0; transform: translateY(12px); }
.js [data-revelar][data-visible] { animation: revelar 400ms cubic-bezier(.22,1,.36,1) both;
animation-delay: calc(var(--i, 0) * 60ms); }
}
@keyframes revelar { to { opacity: 1; transform: none; } }
```
3. `<Revelar indice>` (client) pinta un envoltorio con `data-revelar` y `style={{ '--i': indice }}`. Un único `IntersectionObserver` a nivel de módulo, compartido por todas las instancias, marca `data-visible` al entrar y deja de observar. `indice` lo pasa la sección: es la posición del bloque entre sus hermanos, y sirve solo para escalonar el retardo. Cuando se omite, vale 0.
**Consecuencias:** sin JS, todo visible. Con `prefers-reduced-motion: reduce`, el estado oculto nunca llega a aplicarse — no hace falta el hook `usePrefersReducedMotion` ni el spread `motionProps` en ninguna sección.
## 9. Arquitectura
- **Fuera `framer-motion`** (53,6 kB del bundle compartido) y **fuera `embla-carousel`** + `embla-carousel-autoplay`, que ya no usa nadie.
- **Las secciones vuelven a ser server components.** Hoy las diez son `'use client'`. El único componente cliente será `Revelar`.
- **Siete primitivas**, frente a las ~20 actuales:
| Primitiva | Papel |
|---|---|
| `Container` | ancho y padding horizontal |
| `Seccion` | la retícula de margen + el ritmo vertical + fondo |
| `MarcaSeccion` | la progresión del ≡ |
| `Antetitulo` | el rótulo dorado (ya existe) |
| `CTA` | los botones (ya existe) |
| `Wordmark` | el logotipo (ya existe, intacto) |
| `Revelar` | el único gesto de movimiento |
- `lib/content.ts` sigue siendo el source of truth del copy.
- `lib/motion.ts` se elimina.
## 10. Copy
**Verbatim, sin tocar** (son palabras de la dueña, de su deck):
- Los seis puntos de inflexión (nombre + situación).
- Los cuatro dolores en primera persona.
- El remate: "Esto no se resuelve con más esfuerzo. Se resuelve con una estrategia clara."
- Las frases-ancla aprobadas.
- Los nombres de las ocho etapas y sus entregables.
- El insight: "No vendemos marketing. Vendemos claridad estratégica."
**Se escribe nuevo** solo el tejido conectivo que pide la estructura nueva: antetítulos y títulos de las seis secciones, y los sumarios de una línea bajo cada uno. Se recortan las respuestas largas del recorrido, que existían para llenar seis pantallas fijadas.
**Se mantiene la prohibición de escasez** en 01 (`cupos|cohorte|generación fundadora`), decisión explícita de la dueña.
Los placeholders `TODO dueña` (WhatsApp, email, LinkedIn, dominio, retrato) siguen siendo placeholders. No se inventan valores.
## 11. Accesibilidad
- Skip link al `<main>` — hoy no hay.
- Un solo `<h1>` (01); cada sección con su `<h2>`.
- `100svh`/`100dvh` en vez de `100vh` donde haga falta.
- Foco visible con contraste ≥ 3:1 **contra el fondo sobre el que aparece**: anillo navy sobre claro, dorado sobre navy. El dorado global de hoy da 2,76:1 sobre blanco.
- Nada de contenido oculto tras estado de scroll.
## 12. Tests
Se mantiene la convención del repo: **copy y contrato de marca, no estilos**, salvo cuando el estilo *es* el contrato (contraste, contenido oculto).
Se conservan intactos: `simbolo.test.ts`, `Wordmark.test.tsx`, `Antetitulo.test.tsx`, `CTA.test.tsx`, `Container.test.tsx`, `contraste.test.ts` y `content.test.ts`.
Se reescriben, no se conservan, los tests de sección: las seis secciones nuevas no se corresponden una a una con las diez viejas. Cada una estrena su test de copy, y el test negativo de escasez viaja de `Hero.test.tsx` al de la sección 01.
Se añaden:
1. **Guarda de contenido oculto**, generalizada a las seis secciones: ninguna renderiza elementos con `style.opacity === '0'` ni `style.filter` con blur. Es el fallo que ya nos mordió una vez.
2. **`MarcaSeccion`**: `paso={5}` produce exactamente las tres barras de `LINEAS_SIMBOLO`; `paso={0}` produce las nueve líneas dispersas.
3. **Guarda de escala**: la página monta exactamente un elemento con `text-display`.
Se eliminan los tests de los componentes eliminados.
## 13. Criterios de aceptación
1. `pnpm test`, `npx tsc --noEmit` y `pnpm lint` limpios.
2. `pnpm build` correcto, y `out/index.html` con **cero** ocurrencias de `opacity:0` y de `filter:blur`.
3. `framer-motion` y `embla-carousel` no aparecen en `package.json` ni en el bundle.
4. Exactamente **dos** secciones navy y **un** elemento a escala `display`.
5. Todo par texto/fondo cumple 4,5:1 (normal) o 3:1 (grande y gráficos).
6. Con JavaScript desactivado, la página se lee completa.
## 14. Fuera de alcance
- El retrato real de la estratega y los datos de contacto: siguen pendientes de la dueña.
- Los dos casos de proyectos reales que la dueña prometió el 29/jul.
- Cualquier decisión sobre si la escasez ("Generación Fundadora") puede vivir fuera del hero.
## 15. Riesgos
1. **Va contra el último encargo de la dueña.** Pidió "algo de gráfico y movimiento"; esto reduce ambos. El argumento a favor es que el ≡ progresivo es más gráfico que diez adornos ambientales, y que un solo gesto de movimiento se nota más que doce repartidos. Es su marca: necesita aprobarlo.
2. **Se retira trabajo real.** El recorrido fijado, cinco ilustraciones SVG, catorce fotografías acreditadas y el sistema de iconos son semanas de trabajo. Queda todo en git, pero sale de la página.
3. **Con una sola familia tipográfica, la distinción depende entera de la composición.** Si el resultado se siente genérico, la salida natural sería una segunda familia para display — y eso choca con la decisión de tipografía que el usuario marcó como intocable.
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import * as content from '../content';
import * as contenido from '../content';
import {
apertura, dondeEstas, queCambia, elTrabajo, paraQuienQuien, empezar,
puntosInflexion, dolores, fraseAncla,
} from '../content';
// The landing speaks in the first person plural (Erik's decision, 6/ago/2026,
// recorded in docs/superpowers/specs/2026-08-06-proceso-ocho-etapas-design.md).
// Before that it was singular, and the change touched copy in six different
// objects — easy to reintroduce by accident when editing one of them.
//
// The reader stays "tú": that half is the dueña's locked decision and is not
// what this file guards.
// Unambiguous first-person-singular markers: conjugated verbs and imperatives
// with the enclitic "-me". Deliberately excludes anything that doubles as a
// noun — "trabajo" is one ("el trabajo completo"), which is why the whole
// word list is verbs and never bare stems.
const SINGULAR = /\b(me sumerjo|sumerjo|cuestiono|separo|reviso|ubico|acompaño|escríbeme|cuéntame|conmigo)\b/i;
describe('voz de la marca en lib/content.ts', () => {
it('speaks in the plural everywhere creeer is the one speaking', () => {
// `dolores` is the one exception and it is not an oversight: those four
// quotes are in the first person singular because the CLIENT is talking,
// not creeer ("Trabajo más y gano casi lo mismo que hace dos años").
// They are verbatim from slide 3 of the dueña's deck and must not move.
const { dolores, ...resto } = content;
for (const [nombre, valor] of Object.entries(resto)) {
expect(JSON.stringify(valor), `«${nombre}» quedó en singular`).not.toMatch(SINGULAR);
}
// The eyebrow is where the old singular voice survived the plural
// migration — locked now that the dueña confirmed it (19/ago/2026).
expect(apertura.eyebrow).not.toMatch(/\btu estratega\b/i);
// Guard the guard: if the quotes are ever rewritten, this fails and forces
// a decision instead of silently weakening the check above.
expect(JSON.stringify(dolores)).toMatch(/Trabajo más y gano casi lo mismo/);
});
it('keeps the reader in the second person', () => {
expect(JSON.stringify(content.paraQuien)).toMatch(/\btienes\b/i);
expect(JSON.stringify(content.proceso)).toMatch(/\btu\b/i);
});
});
describe('la estructura de seis secciones', () => {
it('numera el documento del 01 al 06 sin saltos', () => {
expect(
[apertura, dondeEstas, queCambia, elTrabajo, paraQuienQuien, empezar].map((s) => s.numero),
).toEqual(['01', '02', '03', '04', '05', '06']);
});
it('conserva verbatim las palabras de la dueña', () => {
// Her doc is explicit: "they are already written in her voice — use them,
// don't rewrite them." These tests fail if someone rewrites them.
expect(dondeEstas.dolores).toContain('Trabajo más y gano casi lo mismo que hace dos años.');
expect(dondeEstas.remate.accent).toBe('Se resuelve con una estrategia clara.');
expect(queCambia.ancla.accent).toBe('dejan de crecer por falta de claridad.');
expect(dondeEstas.puntos.map((p) => p.name)).toEqual([
'Crecer', 'Consolidarse', 'Recuperar rentabilidad',
'Simplificar', 'Expandirse', 'Replantear el portafolio',
]);
});
it('los alias viejos siguen leyendo de la misma fuente, no de una copia', () => {
// While the two structures coexist, a silent divergence between them
// would mean different copy on the same page.
expect(puntosInflexion.items[0].situacion).toBe(dondeEstas.puntos[0].situacion);
expect(dolores.quotes).toEqual(dondeEstas.dolores);
expect(fraseAncla.quote).toBe(queCambia.ancla);
});
it('mantiene en pie todo lo que las secciones viejas importan', () => {
// The ten old sections stay mounted until Task 13, and a single missing
// name here breaks the whole build. This is the real list of names the
// code imports today from `@/lib/content`.
const modulo = contenido as Record<string, unknown>;
[
'brandPillars', 'cierre', 'comoEmpezar', 'creditosFotos', 'disciplinas',
'dolores', 'estratega', 'footer', 'fraseAncla', 'header', 'hero',
'paraQuien', 'proceso', 'puntosInflexion',
].forEach((nombre) => expect(modulo[nombre], nombre).toBeDefined());
});
it('no promete escasez en la apertura', () => {
expect(JSON.stringify(apertura)).not.toMatch(/cupos|cohorte|generación fundadora/i);
});
it('arma los tres contactos como enlaces que abren algo', () => {
// An email without `mailto:` is a relative URL: the browser navigates to
// /nombre@dominio and lands on a 404 instead of opening the mail client.
const { whatsapp, email, linkedin } = empezar.contacto;
expect(whatsapp.href).toMatch(/^https:\/\/wa\.me\/\d{10,15}$/);
expect(email.href).toMatch(/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/);
expect(linkedin.href).toMatch(/^https:\/\/(www\.)?linkedin\.com\/in\/[\w-]+\/?$/);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from 'node:fs';
import { describe, it, expect } from 'vitest';
// Guardas de contraste, leídas del código fuente porque son una regla de
// sistema, no el comportamiento de un componente suelto: lo que hay que impedir
// es que el token vuelva a aparecer donde no se lee.
//
// `gris-medio` (#AAAAAA) da 7,2:1 sobre azul profundo — ahí es el token
// correcto, y en Disciplinas y el footer se queda. Sobre blanco da 2,3:1 y
// sobre gris-claro 2,1:1: en fondo claro es texto que no se lee.
//
// `dorado` (#C4942E) da 6,07:1 sobre azul profundo y 2,76:1 / 2,46:1 sobre los
// dos fondos claros. Sigue siendo el color de rellenos, reglas, iconos y el ≡;
// lo que no puede ser en claro es texto pequeño, y para eso está `dorado-texto`.
const SOBRE_CLARO = [
'components/sections/Hero.tsx',
'components/sections/Dolores.tsx',
'components/sections/Proceso.tsx',
'components/sections/ParaQuien.tsx',
'components/sections/Estratega.tsx',
'components/sections/ComoEmpezar.tsx',
// La rama de movimiento reducido del recorrido va sobre gris-claro, y es
// justo la que sirve a quien más necesita que el texto se lea.
'components/primitives/ParDialogo.tsx',
];
describe('tokens de texto sobre fondo claro', () => {
it.each(SOBRE_CLARO)('%s no pinta texto en gris-medio', (archivo) => {
expect(readFileSync(archivo, 'utf8')).not.toMatch(/text-gris-medio/);
});
it('ParDialogo etiqueta al hablante en el dorado legible', () => {
// La regla vertical sigue siendo `bg-dorado` — el dorado del manual como
// relleno no tiene problema. Lo que no puede quedarse en #C4942E es la
// etiqueta, que es texto de 12px sobre gris-claro.
const fuente = readFileSync('components/primitives/ParDialogo.tsx', 'utf8');
expect(fuente).toMatch(/text-dorado-texto/);
expect(fuente).not.toMatch(/text-dorado(?!-)/);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { esExterno, propsEnlace } from '../enlaces';
describe('propsEnlace', () => {
it('opens outside links in a new tab without handing over window.opener', () => {
expect(propsEnlace('https://wa.me/525558185802')).toEqual({ target: '_blank', rel: 'noopener noreferrer' });
expect(propsEnlace('https://www.linkedin.com/in/x')).toEqual({ target: '_blank', rel: 'noopener noreferrer' });
});
it('leaves in-page anchors and mailto: in the same tab', () => {
expect(propsEnlace('#contacto')).toEqual({});
expect(propsEnlace('mailto:hola@creeer.com')).toEqual({});
expect(esExterno('mailto:hola@creeer.com')).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import config from '../../tailwind.config';
// The scale is a contract, not a preference: the problem this redesign fixes is
// that five elements were tied for largest on the page, and because of that,
// none of them read as largest. Five roles: only one reaches above 4rem.
const escala = () => (config.theme?.extend?.fontSize ?? {}) as Record<string, [string, object]>;
describe('escala tipográfica', () => {
it('define los cinco roles del documento', () => {
expect(Object.keys(escala())).toEqual(
expect.arrayContaining(['display', 'titulo', 'sumario', 'cuerpo', 'rotulo']),
);
});
it('display es el único que llega a 7rem', () => {
const conSieteRem = Object.entries(escala()).filter(([, v]) => v[0].includes('7rem'));
expect(conSieteRem.map(([k]) => k)).toEqual(['display']);
});
it('el cuerpo se compone holgado y el display apretado', () => {
const lh = (rol: string) => (escala()[rol][1] as { lineHeight: string }).lineHeight;
expect(Number(lh('display'))).toBeLessThan(1);
expect(Number(lh('cuerpo'))).toBeGreaterThan(1.6);
});
it('reserva una medida de 68 caracteres para el texto corrido', () => {
const anchos = (config.theme?.extend?.maxWidth ?? {}) as Record<string, string>;
expect(anchos.medida).toBe('68ch');
});
});
+216 -154
View File
@@ -11,160 +11,199 @@ export const brandPillars = {
pillar3: "Crecimiento", pillar3: "Crecimiento",
}; };
export const hero = { // ── 01 · Apertura ──────────────────────────────────────────────────────────
eyebrow: '— tu estratega de crecimiento —', export const apertura = {
// Headline broken into segments for emphasis styling numero: '01',
eyebrow: 'claridad estratégica para tu negocio',
headline: { headline: {
line1: 'Tu negocio no necesita', line1: 'Tu negocio no necesita',
effort: 'más esfuerzo.', // gold (HV-1) or gold italic (V-a) acento: 'más esfuerzo.',
line3: 'Necesita', line3: 'Necesita',
clarity: 'más claridad.', // underlined gold cierre: 'más claridad.',
}, },
cta: 'Define tu siguiente movimiento', quien: 'Claridad estratégica para empresarios con tracción que llegaron a su punto de inflexión.',
ctaSecundario: 'Ver cómo trabajo', // Hormozi: Dream Outcome — the hero names the problem; this line names the
// result the reader walks away with. Concrete, time-bound, personal.
dreamOutcome: 'Un plan a 12 meses para que tu negocio crezca sin depender de ti en cada decisión.',
cta: 'Agenda una sesión de claridad — 30 min, sin costo',
ctaSecundario: 'Ver cómo trabajamos',
}; };
export const fraseAncla = { // ── 02 · Dónde estás ───────────────────────────────────────────────────────
// Quote split into parts so the second clause can render in gold. // Los seis puntos y los cuatro dolores son de las diapositivas 2 y 3 del deck
quote: { // de la dueña. Verbatim. `respuesta` desaparece: existía para llenar seis
before: 'Las empresas no dejan de crecer por falta de talento;', // pantallas fijadas y el recorrido ya no existe.
accent: 'dejan de crecer por falta de claridad.', // gold const PUNTOS = [
}, { name: 'Crecer', situacion: 'Ya demostraste que sabes crecer. El siguiente salto no tiene mapa.' },
}; { name: 'Consolidarse', situacion: 'Construiste rápido. Escalar así multiplicaría el desorden.' },
{ name: 'Recuperar rentabilidad', situacion: 'Tu esfuerzo dejó de traducirse en resultado.' },
{ name: 'Simplificar', situacion: 'Tu operación se volvió más compleja que tu negocio.' },
{ name: 'Expandirse', situacion: 'Vas a mercados nuevos con el mismo mapa de decisión.' },
{ name: 'Replantear el portafolio', situacion: 'Tienes varios frentes abiertos y ninguno con su lugar claro.' },
];
// The six inflection points, from the dueña's deck "Rumbo Estratégico · export const dondeEstas = {
// Propuesta Socio" (slide 2). The deck states each one in the infinitive numero: '02',
// ("Dar el siguiente salto sin saber por dónde"); here it is stated in the eyebrow: 'dónde estás',
// second person, the way she reads them aloud herself — "estás atrapado en tu title: 'Tu negocio llegó a un punto de inflexión',
// compañía", "creciste muy rápido pero quieres expandirte" (call of 31/jul, sumario: 'Seis situaciones. Reconoce la tuya.',
// 6:01–7:15). `respuesta` is new copy and still needs her sign-off; 01 and 05 puntos: PUNTOS,
// are the two exchanges she improvised on that call, written out. etiquetaDolores: 'Y por dentro suena así',
// dolores: [
// `situacion` describes the state of the business. It must not drift into how
// the reader feels — that is `dolores`, one section down, in the client's own
// first-person words, and the two would collapse into each other.
export const puntosInflexion = {
eyebrow: 'el contexto',
title: 'Todos los negocios consolidados llegan a un punto de inflexión',
lead: '¿Te reconoces?',
items: [
{
name: 'Crecer',
situacion: 'Ya demostraste que sabes crecer. El siguiente salto no tiene mapa.',
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro. Sales sabiendo por dónde, no intentándolo por todos lados a la vez.',
},
{
name: 'Consolidarse',
situacion: 'Construiste rápido. Escalar así multiplicaría el desorden.',
respuesta: 'Ordenamos lo que ya existe antes de ponerle más peso encima. Escalas sobre algo que aguanta.',
},
{
name: 'Recuperar rentabilidad',
situacion: 'Tu esfuerzo dejó de traducirse en resultado.',
respuesta: 'Cuestiono lo que todos dan por hecho en tu operación. El margen no siempre está en vender más.',
},
{
name: 'Simplificar',
situacion: 'Tu operación se volvió más compleja que tu negocio.',
respuesta: 'Separo lo que solo tú puedes decidir de lo que la empresa debería resolver sin ti.',
},
{
name: 'Expandirse',
situacion: 'Vas a mercados nuevos con el mismo mapa de decisión.',
respuesta: 'Reviso tu proceso y ubico la oportunidad real antes de mover una ficha. Expandirte deja de ser una apuesta.',
},
{
name: 'Replantear el portafolio',
situacion: 'Tienes varios frentes abiertos y ninguno con su lugar claro.',
respuesta: 'Construimos, juntos, el criterio para decidir qué sigue y qué se deja ir. Por escrito, no en tu cabeza.',
},
],
};
// Slide 3 of the same deck. The four quotes are the client's own words —
// keep them verbatim, in first person.
export const dolores = {
eyebrow: 'lo que realmente sientes',
title: 'Los dolores detrás del punto de inflexión',
quotes: [
'Trabajo más y gano casi lo mismo que hace dos años.', 'Trabajo más y gano casi lo mismo que hace dos años.',
'Tengo varios proyectos y no sé en cuál enfocar mi tiempo y mi dinero.', 'Tengo varios proyectos y no sé en cuál enfocar mi tiempo y mi dinero.',
'Mi empresa no puede crecer si yo no estoy en cada decisión.', 'Mi empresa no puede crecer si yo no estoy en cada decisión.',
'Lo que me trajo hasta aquí ya no me sirve para el siguiente nivel.', 'Lo que me trajo hasta aquí ya no me sirve para el siguiente nivel.',
], ],
// Split so the resolution renders in gold.
remate: { remate: {
before: 'Esto no se resuelve con más esfuerzo.', before: 'Esto no se resuelve con más esfuerzo.',
accent: 'Se resuelve con una estrategia clara.', accent: 'Se resuelve con una estrategia clara.',
}, },
}; };
export const paraQuien = { // ── 03 · Qué cambia ────────────────────────────────────────────────────────
export const queCambia = {
numero: '03',
eyebrow: 'qué cambia',
ancla: {
before: 'Las empresas no dejan de crecer por falta de talento;',
accent: 'dejan de crecer por falta de claridad.',
},
// El "después": los dolores nombran el problema; esto pinta el resultado.
// Paga directo el tercer dolor — "Mi empresa no puede crecer si yo no estoy
// en cada decisión".
despues: 'Sabes exactamente dónde invertir, tu equipo ejecuta sin que estés encima, y cada decisión tiene un plan detrás.',
insight: 'No vendemos marketing. Vendemos claridad estratégica.',
// Las tres disciplinas, degradadas de tres tarjetas con icono a una línea de
// texto: el propio deck dice que son los medios, no la promesa.
medios: 'Comunicación, digital y experiencias son las herramientas que usamos. No son lo que vendemos.',
};
// ── 04 · El trabajo ────────────────────────────────────────────────────────
export const elTrabajo = {
numero: '04',
eyebrow: 'el trabajo',
// Hormozi: Name your mechanism. A named process is a system; an unnamed
// process is a list. Systems sell. Lists don't.
metodo: 'Ruta de Claridad',
title: 'La Ruta de Claridad: de tu operación de hoy a tu plan a un año',
sumario: 'Ocho etapas. Cada una deja algo por escrito.',
fases: ['Inmersión', 'Desafiar supuestos', 'Estrategia ejecutable'],
etiquetaEntregables: 'Entregables',
etapas: [
{ n: '01', name: 'Análisis de compañía y FODA', entregables: ['FODA', 'Diagnóstico ejecutivo', 'Prioridades', 'Quick wins'] },
{ n: '02', name: 'Análisis interno', entregables: ['Mapa de capacidades', 'Restricciones del negocio', 'Oportunidades de crecimiento'] },
{ n: '03', name: 'Conocimiento del consumidor', entregables: ['Segmentación', 'Buyer persona', 'Customer journey', 'Necesidades no cubiertas'] },
{ n: '04', name: 'Estrategia de marca', entregables: ['Posicionamiento', 'Arquitectura de marcas', 'Manual estratégico'] },
{ n: '05', name: 'Comunicación y branding', entregables: ['Plataforma de comunicación', 'Campaña anual', 'Estrategia de medios', 'Plan de contenido'] },
{ n: '06', name: 'Marketing y ventas', entregables: ['Dashboard CEO', 'Dashboard comercial', 'Comité de crecimiento'] },
{ n: '07', name: 'Innovación comercial', entregables: ['Roadmap de innovación', 'Go to market', 'Prioridades de innovación'] },
{ n: '08', name: 'One Year Plan', entregables: ['Roadmap a 12 meses', 'Iniciativas prioritarias', 'Gobernanza del crecimiento'], remate: true },
],
};
// ── 05 · Para quién · Quién ────────────────────────────────────────────────
export const paraQuienQuien = {
numero: '05',
eyebrow: 'para quién',
title: '¿Llegaste a tu punto de inflexión?', title: '¿Llegaste a tu punto de inflexión?',
lead: 'Esto es para ti si…', si: {
criteria: [ lead: 'Esto es para ti si…',
'tienes tracción y ya demostraste que sabes crecer', criterios: [
'trabajar más ya no rinde como antes', 'tienes tracción y ya demostraste que sabes crecer',
'necesitas claridad para decidir el siguiente movimiento', 'trabajar más ya no rinde como antes',
'quieres profesionalizar sin perder tu esencia', 'necesitas claridad para decidir el siguiente movimiento',
], 'quieres profesionalizar sin perder tu esencia',
// Deck slide 9, "NO ES PARA". Kept impersonal on purpose: this disqualifies ],
// a profile, it does not accuse the reader. },
leadNo: 'Esto no es para ti si…', no: {
criteriaNo: [ lead: 'Esto no es para ti si…',
'el negocio está en fase inicial o sin tracción', criterios: [
'buscas una solución rápida de venta', 'el negocio está en fase inicial o sin tracción',
'buscas un curso, no una transformación', 'buscas una solución rápida, no un cambio de fondo',
'la empresa está en crisis de supervivencia, no en transición', 'buscas un curso, no una transformación',
], 'la empresa está en crisis de supervivencia, no en transición',
],
},
quien: {
eyebrow: 'quiénes estamos detrás',
title: 'Quiénes somos',
cifra: '25+',
cifraPie: 'años construyendo marcas',
bio: '25 años de estrategia detrás de marcas que pasaron de locales a líderes de categoría. Hemos acompañado a más de 40 empresas en retail, servicios y manufactura a encontrar su siguiente movimiento.', // TODO dueña: confirmar cifra y lista de industrias
quote: 'Tu negocio ya demostró que funciona. Ahora necesita un plan que lo lleve más lejos.',
},
}; };
export const mecanismo = { // ── 06 · Empezar ───────────────────────────────────────────────────────────
eyebrow: 'el mecanismo único', export const empezar = {
title: 'Cómo trabajo', numero: '06',
steps: [ eyebrow: 'empezar',
{ n: '01', name: 'Inmersión', desc: 'Me sumerjo en tu empresa hasta comprenderla desde dentro.' }, title: 'Tu siguiente movimiento empieza con una conversación.',
{ n: '02', name: 'Desafiar supuestos', desc: 'Cuestiono lo que todos dan por hecho y frenan el crecimiento.' }, pasos: [
{ n: '03', name: 'Estrategia ejecutable', desc: 'Construimos, juntos, una estrategia que puedas ejecutar.' }, { n: '01', name: 'Escríbenos', desc: 'Cuéntanos dónde está tu negocio hoy.' },
{ n: '02', name: 'Sesión de claridad', desc: 'Una conversación para ubicar tu punto de inflexión.' },
{ n: '03', name: 'Plan en mano', desc: 'Sales con claridad y tu siguiente movimiento definido.' },
], ],
cta: 'Agenda tu sesión de claridad',
whatsappText: 'Hola, quiero agendar una sesión de claridad',
quote: 'Devuélvele claridad al rumbo de tu empresa.',
contacto: {
whatsapp: { label: 'WhatsApp', href: 'https://wa.me/525558185802' },
email: { label: 'Email', href: 'mailto:jazmin.rodgon@gmail.com' },
linkedin: { label: 'LinkedIn', href: 'https://www.linkedin.com/in/jazminrodriguez-jazinta' },
},
legal: '© 2026 creeer',
}; };
// Practical details of the engagement — what the reader can expect when // ── Alias de compatibilidad ────────────────────────────────────────────────
// working with creeer. Sits between Mecanismo (the high-level HOW) and // Los consumen las diez secciones viejas mientras conviven con las seis
// Disciplinas (the tools). All copy derived from approved sources: // nuevas. Derivan de los objetos de arriba, nunca duplican sus cadenas, y se
// mecanismo steps, puntosInflexion respuestas, comoEmpezar. // van enteros en la limpieza final. No añadir usos nuevos.
// ⚠️ «Duración» left deliberately open — the dueña hasn't specified a
// timeline. The card invites a conversation instead of committing a number. export const hero = {
export const formaTrabajar = { eyebrow: `— ${apertura.eyebrow} —`,
eyebrow: 'qué esperar', headline: {
title: 'Así es trabajar conmigo', line1: apertura.headline.line1,
items: [ effort: apertura.headline.acento,
{ line3: apertura.headline.line3,
name: 'Metodología', clarity: apertura.headline.cierre,
desc: 'Inmersión en tu empresa, desafío de lo que todos dan por hecho y construcción conjunta de tu estrategia.', },
}, cta: apertura.cta,
{ ctaSecundario: apertura.ctaSecundario,
name: 'Quién participa', };
desc: 'Tú y yo. Sin intermediarios. Trabajo directo con quien toma las decisiones.',
}, // La voz de creeer en el recorrido que sigue montado: cada situación del lector
{ // recibe su respuesta. El rediseño las retira junto con el recorrido (ver
name: 'De dónde partimos', // `dondeEstas`), pero mientras la sección vieja siga en la página, pregunta y
desc: 'Tu operación real: números, procesos, equipo. Me sumerjo hasta comprenderla desde dentro.', // respuesta no pueden decir lo mismo. Son las del 31/jul pasadas al plural;
}, // 01 y 05 las improvisó la dueña en esa llamada. Mismo orden que PUNTOS.
{ const RESPUESTAS = [
name: 'Qué te llevas', 'Nos sumergimos hasta entender tu empresa desde dentro. Sales sabiendo por dónde, no intentándolo por todos lados a la vez.',
desc: 'Un diagnóstico claro, tus prioridades ordenadas y una estrategia ejecutable. Por escrito, no en tu cabeza.', 'Ordenamos lo que ya existe antes de ponerle más peso encima. Escalas sobre algo que aguanta.',
}, 'Cuestionamos lo que todos dan por hecho en tu operación. El margen no siempre está en vender más.',
{ 'Separamos lo que solo tú puedes decidir de lo que la empresa debería resolver sin ti.',
name: 'Duración', 'Revisamos tu proceso y ubicamos la oportunidad real antes de mover una ficha. Expandirte deja de ser una apuesta.',
desc: 'Cada proceso tiene su ritmo. Lo definimos juntos en la sesión de diagnóstico.', 'Construimos, juntos, el criterio para decidir qué sigue y qué se deja ir. Por escrito, no en tu cabeza.',
}, ];
],
export const puntosInflexion = {
eyebrow: 'el contexto',
title: dondeEstas.title,
lead: '¿Te reconoces?',
items: PUNTOS.map((p, i) => ({ ...p, respuesta: RESPUESTAS[i] })),
};
export const dolores = {
eyebrow: 'lo que realmente sientes',
title: 'Los dolores detrás del punto de inflexión',
quotes: dondeEstas.dolores,
remate: dondeEstas.remate,
}; };
export const disciplinas = { export const disciplinas = {
eyebrow: 'las herramientas', eyebrow: 'las herramientas',
title: 'Las disciplinas que uso', title: 'Las disciplinas que usamos',
lead: 'No son la promesa — son los medios.', lead: 'No son la promesa — son los medios.',
items: [ items: [
{ name: 'Comunicación', desc: 'Mensaje que alinea a tu equipo y a tu mercado.' }, { name: 'Comunicación', desc: 'Mensaje que alinea a tu equipo y a tu mercado.' },
@@ -173,37 +212,48 @@ export const disciplinas = {
], ],
}; };
export const proceso = {
eyebrow: 'el trabajo completo',
title: elTrabajo.title,
lead: elTrabajo.sumario,
fases: elTrabajo.fases,
etiquetaEntregables: elTrabajo.etiquetaEntregables,
etapas: elTrabajo.etapas.map(({ n, name, entregables, remate }) =>
remate ? { n, name, entregables, destacada: true } : { n, name, entregables },
),
};
export const paraQuien = {
title: paraQuienQuien.title,
lead: paraQuienQuien.si.lead,
criteria: paraQuienQuien.si.criterios,
leadNo: paraQuienQuien.no.lead,
criteriaNo: paraQuienQuien.no.criterios,
};
export const estratega = { export const estratega = {
// Split so the brand name keeps its lowercase inside an uppercase eyebrow — eyebrow: { before: 'quiénes estamos detrás de', brand: 'creeer' },
// CSS text-transform would otherwise render it as CREEER. title: paraQuienQuien.quien.title,
eyebrow: { before: 'la mente detrás de', brand: 'creeer' }, bio: paraQuienQuien.quien.bio,
title: 'Sobre la estratega', quote: paraQuienQuien.quien.quote,
bio: 'Más de 30 años construyendo marcas. Hoy acompaño a empresarios que ya lograron tracción y necesitan claridad para su siguiente movimiento.',
quote: 'El tiempo no se compra, se invierte.',
}; };
export const comoEmpezar = { export const comoEmpezar = {
eyebrow: 'cómo empezar', eyebrow: 'cómo empezar',
title: 'Tu siguiente movimiento empieza con una conversación.', title: empezar.title,
steps: [ steps: empezar.pasos,
{ n: '01', name: 'Escríbeme', desc: 'Cuéntame dónde está tu negocio hoy.' }, cta: empezar.cta,
{ n: '02', name: 'Sesión de diagnóstico', desc: 'Una conversación para ubicar tu punto de inflexión.' }, whatsappText: empezar.whatsappText,
{ n: '03', name: 'Plan en mano', desc: 'Sales con claridad y tu siguiente movimiento definido.' },
],
cta: 'Agenda tu sesión de diagnóstico',
whatsappText: 'Hola, quiero agendar una sesión de diagnóstico',
}; };
export const cierre = { export const cierre = {
quote: 'Devuélvele claridad al rumbo de tu empresa.', quote: empezar.quote,
cta: 'Define tu siguiente movimiento', cta: apertura.cta,
contact: { contact: empezar.contacto,
whatsapp: { label: 'WhatsApp', href: 'https://wa.me/520000000000' }, // TODO dueña: real number
email: { label: 'Email', href: 'mailto:hola@creeer.com' }, // TODO dueña: real email
linkedin: { label: 'LinkedIn', href: 'https://linkedin.com/in/...' }, // TODO dueña: real URL
},
}; };
export const fraseAncla = { quote: queCambia.ancla, despues: queCambia.despues };
export const header = { export const header = {
cta: 'Hablemos', // short nav CTA → #contacto (the full CTA lives in Hero/Cierre) cta: 'Hablemos', // short nav CTA → #contacto (the full CTA lives in Hero/Cierre)
}; };
@@ -219,12 +269,24 @@ export const footer = {
// image was modified — all of them get the navy/gold duotone treatment, and the // image was modified — all of them get the navy/gold duotone treatment, and the
// six in `recorrido` are additionally cropped and resized. // six in `recorrido` are additionally cropped and resized.
export const creditosFotos = [ export const creditosFotos = [
{ desc: 'Foto: Golden Gate Bridge — Dietmar Rabich', href: 'https://commons.wikimedia.org/wiki/File:San_Francisco_(CA,_USA),_Golden_Gate_Bridge_--_2022_--_3023_(bw).jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: amanecer, Monte Nardi — Tony 1212', href: 'https://commons.wikimedia.org/wiki/File:Sunrise_over_the_summit_of_Mount_Nardi_from_the_south.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' }, { desc: 'Foto: amanecer, Monte Nardi — Tony 1212', href: 'https://commons.wikimedia.org/wiki/File:Sunrise_over_the_summit_of_Mount_Nardi_from_the_south.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: distrito financiero de Riad — B.alotaby', href: 'https://commons.wikimedia.org/wiki/File:Riyadh_Skyline_showing_the_King_Abdullah_Financial_District_(KAFD)_and_the_famous_Kingdom_Tower_.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' }, { desc: 'Foto: Torre Mitikah en construcción, Ciudad de México — ProtoplasmaKid', href: 'https://commons.wikimedia.org/wiki/File:Torre_Mitikah_en_construcci%C3%B3n.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: sala de reuniones — Breather', href: 'https://commons.wikimedia.org/wiki/File:Minimalist_meeting_room_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' }, { desc: 'Foto: oficina — Importaciones a México', href: 'https://commons.wikimedia.org/wiki/File:Importaciones_a_M%C3%A9xico_oficina.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: cosechadora en el trigo — Michael Gäbler', href: 'https://commons.wikimedia.org/wiki/File:The_combine_Claas_Lexion_584_in_the_wheat_harvest.jpg', licencia: 'CC BY-SA 3.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/3.0/' }, { desc: 'Foto: piñas de agave, Tequila, Jalisco — Gzzz', href: 'https://commons.wikimedia.org/wiki/File:Hearts_of_agave_plants_Tequila_Mexico.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: pantalla de la Bolsa de Fráncfort — Ank Kumar', href: 'https://commons.wikimedia.org/wiki/File:Frankfurt_Stock_Exchange_(Ank_Kumar)_04.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' }, { desc: 'Foto: Distribuidor Vial San Antonio, Ciudad de México — ProtoplasmaKid', href: 'https://commons.wikimedia.org/wiki/File:Distribuidor_Vial_San_Antonio_2025_32.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: panel de salidas, aeropuerto de Ginebra — Tiia Monto', href: 'https://commons.wikimedia.org/wiki/File:Departure_board_at_Geneva_Airport.jpg', licencia: 'CC BY-SA 3.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/3.0/' }, { desc: 'Foto: puertas de embarque, aeropuerto de Cancún — Vmzp85', href: 'https://commons.wikimedia.org/wiki/File:MMUN006.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: distrito financiero de Boston — Ingfbruno', href: 'https://commons.wikimedia.org/wiki/File:USA-Financial_District.JPG', licencia: 'CC BY-SA 3.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/3.0/' }, { desc: 'Foto: skyline de Monterrey — Daniel Escobedo', href: 'https://commons.wikimedia.org/wiki/File:Skyline_de_Monterrey.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
// Las ocho de `Proceso`, todas CC0 — ninguna obliga a atribuir, pero se
// acreditan igual por coherencia con el resto de la lista. `archivo` ata cada
// crédito a su imagen: hay un test en Proceso que falla si aparece una foto
// en la sección sin entrada aquí.
{ archivo: '/images/proceso/01.jpg', desc: 'Foto: sala de juntas — Benjamin Child', href: 'https://commons.wikimedia.org/wiki/File:Benjamin_Child_2015_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ archivo: '/images/proceso/02.jpg', desc: 'Foto: taller — Adi Goldstein', href: 'https://commons.wikimedia.org/wiki/File:The_Workshop_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ archivo: '/images/proceso/03.jpg', desc: 'Foto: escaleras concurridas — José Martín Ramírez C', href: 'https://commons.wikimedia.org/wiki/File:Busy_stairs_in_large_building_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ archivo: '/images/proceso/04.jpg', desc: 'Foto: tipografía sobre escritorio — rawpixel.com', href: 'https://commons.wikimedia.org/wiki/File:Typography,_coffee_and_roses_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ archivo: '/images/proceso/05.jpg', desc: 'Foto: estudio fotográfico — Alexander Dummer', href: 'https://commons.wikimedia.org/wiki/File:Photographic_studio_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ archivo: '/images/proceso/06.jpg', desc: 'Foto: tienda de discos — Samuel Dixon', href: 'https://commons.wikimedia.org/wiki/File:Vintage_record_store_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ archivo: '/images/proceso/07.jpg', desc: 'Foto: dispositivos sobre la mesa — Alejandro Escamilla', href: 'https://commons.wikimedia.org/wiki/File:Iphone,_macbook_and_smartphone_on_a_table_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ archivo: '/images/proceso/08.jpg', desc: 'Foto: planificador mensual — Eric Rothermel', href: 'https://commons.wikimedia.org/wiki/File:Monthly_schedule_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
]; ];
+19
View File
@@ -0,0 +1,19 @@
// Links that leave the site open in a new tab, so the reader never loses the
// page: WhatsApp, LinkedIn, the photo credits. Everything else stays put —
// in-page anchors (#contacto) obviously, and mailto: too, which hands off to
// the mail client and would only leave an empty tab behind.
//
// `rel="noopener noreferrer"`: without noopener the opened page gets a handle
// on this one through window.opener.
export function esExterno(href: string): boolean {
return /^https?:\/\//i.test(href);
}
export function propsEnlace(href: string) {
return esExterno(href) ? ({ target: '_blank', rel: 'noopener noreferrer' } as const) : {};
}
// Screen-reader users get told before the context switch happens (WCAG G201);
// sighted readers see the new tab open.
export const AVISO_PESTANA = 'se abre en otra pestaña';
+1 -1
View File
@@ -64,7 +64,7 @@ export const motionProps = (reduced: boolean) =>
? { initial: 'visible' as const, animate: 'visible' as const, whileInView: undefined, viewport: undefined } ? { initial: 'visible' as const, animate: 'visible' as const, whileInView: undefined, viewport: undefined }
: {}; : {};
// Cycles through `count` steps on its own (Mecanismo, ComoEmpezar): each // Cycles through `count` steps on its own (ComoEmpezar): each
// manual change restarts the dwell timer, hovering pauses it, and it never // manual change restarts the dwell timer, hovering pauses it, and it never
// advances at all for reduced-motion users. // advances at all for reduced-motion users.
export function useAutoStep(count: number, intervalMs = 3500) { export function useAutoStep(count: number, intervalMs = 3500) {
+4 -1
View File
@@ -11,12 +11,14 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@react-three/fiber": "^8.18.0",
"embla-carousel-autoplay": "^8.6.0", "embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
"next": "14.2.35", "next": "14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18",
"three": "^0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -25,6 +27,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-next": "^14.2.35", "eslint-config-next": "^14.2.35",
+203
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@react-three/fiber':
specifier: ^8.18.0
version: 8.18.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.185.1)
embla-carousel-autoplay: embla-carousel-autoplay:
specifier: ^8.6.0 specifier: ^8.6.0
version: 8.6.0(embla-carousel@8.6.0) version: 8.6.0(embla-carousel@8.6.0)
@@ -26,6 +29,9 @@ importers:
react-dom: react-dom:
specifier: ^18 specifier: ^18
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
three:
specifier: ^0.185.1
version: 0.185.1
devDependencies: devDependencies:
'@testing-library/jest-dom': '@testing-library/jest-dom':
specifier: ^6.9.1 specifier: ^6.9.1
@@ -45,6 +51,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^18 specifier: ^18
version: 18.3.7(@types/react@18.3.31) version: 18.3.7(@types/react@18.3.31)
'@types/three':
specifier: ^0.185.4
version: 0.185.4
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.7.0(vite@5.4.21(@types/node@20.19.43)) version: 4.7.0(vite@5.4.21(@types/node@20.19.43))
@@ -197,6 +206,9 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -489,6 +501,31 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@react-three/fiber@8.18.0':
resolution: {integrity: sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==}
peerDependencies:
expo: '>=43.0'
expo-asset: '>=8.4'
expo-file-system: '>=11.0'
expo-gl: '>=11.0'
react: '>=18 <19'
react-dom: '>=18 <19'
react-native: '>=0.64'
three: '>=0.133'
peerDependenciesMeta:
expo:
optional: true
expo-asset:
optional: true
expo-file-system:
optional: true
expo-gl:
optional: true
react-dom:
optional: true
react-native:
optional: true
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -673,6 +710,9 @@ packages:
peerDependencies: peerDependencies:
'@testing-library/dom': '>=7.21.4' '@testing-library/dom': '>=7.21.4'
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@tybys/wasm-util@0.10.3': '@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -708,9 +748,26 @@ packages:
peerDependencies: peerDependencies:
'@types/react': ^18.0.0 '@types/react': ^18.0.0
'@types/react-reconciler@0.26.7':
resolution: {integrity: sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==}
'@types/react-reconciler@0.28.9':
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
peerDependencies:
'@types/react': '*'
'@types/react@18.3.31': '@types/react@18.3.31':
resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.185.4':
resolution: {integrity: sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@typescript-eslint/eslint-plugin@8.65.0': '@typescript-eslint/eslint-plugin@8.65.0':
resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1039,6 +1096,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.44: baseline-browser-mapping@2.10.44:
resolution: {integrity: sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==} resolution: {integrity: sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -1067,6 +1127,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
busboy@1.6.0: busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'} engines: {node: '>=10.16.0'}
@@ -1476,6 +1539,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@@ -1652,6 +1718,9 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -1819,6 +1888,11 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
its-fine@1.2.5:
resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==}
peerDependencies:
react: '>=18.0'
jackspeak@2.3.6: jackspeak@2.3.6:
resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1936,6 +2010,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
meshoptimizer@1.1.1:
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
micromatch@4.0.8: micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
@@ -2254,10 +2331,25 @@ packages:
react-is@18.3.1: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-reconciler@0.27.0:
resolution: {integrity: sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==}
engines: {node: '>=0.10.0'}
peerDependencies:
react: ^18.0.0
react-refresh@0.17.0: react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
react-use-measure@2.1.7:
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
peerDependencies:
react: '>=16.13'
react-dom: '>=16.13'
peerDependenciesMeta:
react-dom:
optional: true
react@18.3.1: react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2340,6 +2432,9 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'} engines: {node: '>=v12.22.7'}
scheduler@0.21.0:
resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==}
scheduler@0.23.2: scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -2500,6 +2595,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
suspend-react@0.1.3:
resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
peerDependencies:
react: '>=17.0'
symbol-tree@3.2.4: symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -2518,6 +2618,9 @@ packages:
thenify@3.3.1: thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
three@0.185.1:
resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2778,6 +2881,15 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
zustand@3.7.2:
resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
engines: {node: '>=12.7.0'}
peerDependencies:
react: '>=16.8'
peerDependenciesMeta:
react:
optional: true
snapshots: snapshots:
'@adobe/css-tools@4.5.0': {} '@adobe/css-tools@4.5.0': {}
@@ -2926,6 +3038,8 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {} '@csstools/css-tokenizer@3.0.4': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@@ -3135,6 +3249,26 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@react-three/fiber@8.18.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.185.1)':
dependencies:
'@babel/runtime': 7.29.7
'@types/react-reconciler': 0.26.7
'@types/webxr': 0.5.24
base64-js: 1.5.1
buffer: 6.0.3
its-fine: 1.2.5(@types/react@18.3.31)(react@18.3.1)
react: 18.3.1
react-reconciler: 0.27.0(react@18.3.1)
react-use-measure: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
scheduler: 0.21.0
suspend-react: 0.1.3(react@18.3.1)
three: 0.185.1
zustand: 3.7.2(react@18.3.1)
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.62.2': '@rollup/rollup-android-arm-eabi@4.62.2':
@@ -3260,6 +3394,8 @@ snapshots:
dependencies: dependencies:
'@testing-library/dom': 10.4.1 '@testing-library/dom': 10.4.1
'@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.3': '@tybys/wasm-util@0.10.3':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -3302,11 +3438,32 @@ snapshots:
dependencies: dependencies:
'@types/react': 18.3.31 '@types/react': 18.3.31
'@types/react-reconciler@0.26.7':
dependencies:
'@types/react': 18.3.31
'@types/react-reconciler@0.28.9(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
'@types/react@18.3.31': '@types/react@18.3.31':
dependencies: dependencies:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
csstype: 3.2.3 csstype: 3.2.3
'@types/stats.js@0.17.4': {}
'@types/three@0.185.4':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
fflate: 0.8.3
meshoptimizer: 1.1.1
'@types/webxr@0.5.24': {}
'@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -3646,6 +3803,8 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.44: {} baseline-browser-mapping@2.10.44: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
@@ -3675,6 +3834,11 @@ snapshots:
node-releases: 2.0.51 node-releases: 2.0.51
update-browserslist-db: 1.2.3(browserslist@4.28.6) update-browserslist-db: 1.2.3(browserslist@4.28.6)
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
busboy@1.6.0: busboy@1.6.0:
dependencies: dependencies:
streamsearch: 1.1.0 streamsearch: 1.1.0
@@ -4256,6 +4420,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
fflate@0.8.3: {}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0
@@ -4446,6 +4612,8 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.6: {} ignore@7.0.6: {}
@@ -4613,6 +4781,13 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
set-function-name: 2.0.2 set-function-name: 2.0.2
its-fine@1.2.5(@types/react@18.3.31)(react@18.3.1):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@18.3.31)
react: 18.3.1
transitivePeerDependencies:
- '@types/react'
jackspeak@2.3.6: jackspeak@2.3.6:
dependencies: dependencies:
'@isaacs/cliui': 8.0.2 '@isaacs/cliui': 8.0.2
@@ -4734,6 +4909,8 @@ snapshots:
merge2@1.4.1: {} merge2@1.4.1: {}
meshoptimizer@1.1.1: {}
micromatch@4.0.8: micromatch@4.0.8:
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
@@ -5039,8 +5216,20 @@ snapshots:
react-is@18.3.1: {} react-is@18.3.1: {}
react-reconciler@0.27.0(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.21.0
react-refresh@0.17.0: {} react-refresh@0.17.0: {}
react-use-measure@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
react@18.3.1: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -5168,6 +5357,10 @@ snapshots:
dependencies: dependencies:
xmlchars: 2.2.0 xmlchars: 2.2.0
scheduler@0.21.0:
dependencies:
loose-envify: 1.4.0
scheduler@0.23.2: scheduler@0.23.2:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -5359,6 +5552,10 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
suspend-react@0.1.3(react@18.3.1):
dependencies:
react: 18.3.1
symbol-tree@3.2.4: {} symbol-tree@3.2.4: {}
tailwindcss@3.4.19: tailwindcss@3.4.19:
@@ -5399,6 +5596,8 @@ snapshots:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
three@0.185.1: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyglobby@0.2.17: tinyglobby@0.2.17:
@@ -5692,3 +5891,7 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
yocto-queue@1.2.2: {} yocto-queue@1.2.2: {}
zustand@3.7.2(react@18.3.1):
optionalDependencies:
react: 18.3.1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+48 -4
View File
@@ -10,6 +10,14 @@ const config: Config = {
'azul-profundo': '#0A1D3D', // Pantone 2965 C — texto, fondos oscuros, CTAs 'azul-profundo': '#0A1D3D', // Pantone 2965 C — texto, fondos oscuros, CTAs
'dorado': { 'dorado': {
DEFAULT: '#C4942E', // Pantone 872 C — acentos, títulos, símbolo ≡ DEFAULT: '#C4942E', // Pantone 872 C — acentos, títulos, símbolo ≡
// El dorado del manual sobre fondo claro es un problema de lectura,
// no de gusto: 2,76:1 sobre blanco y 2,46:1 sobre gris-claro, cuando
// AA pide 4,5:1 para texto pequeño. Sobre azul profundo da 6,07:1 y
// ahí no se toca. Éste es el mismo tono bajado hasta 5,3:1 sobre
// blanco y 4,7:1 sobre gris-claro, y existe para una sola cosa:
// TEXTO PEQUEÑO SOBRE FONDO CLARO. Rellenos, reglas, iconos, el ≡ y
// todo lo que vaya sobre navy siguen usando el DEFAULT.
texto: '#8A6520',
}, },
// Colores neutros // Colores neutros
'azul-claro': '#C8EEF5', // fondos alternativos, secciones suaves 'azul-claro': '#C8EEF5', // fondos alternativos, secciones suaves
@@ -24,13 +32,49 @@ const config: Config = {
// (mismo diseñador: Steve Matteson). Cero autoalojamiento, cero problemas de licencia. // (mismo diseñador: Steve Matteson). Cero autoalojamiento, cero problemas de licencia.
sans: ['var(--font-open-sans)', 'system-ui', 'sans-serif'], sans: ['var(--font-open-sans)', 'system-ui', 'sans-serif'],
}, },
// Profundidad. Un color plano de fondo se lee como bloque impreso: no
// tiene fuente de luz y todas las secciones pesan igual. Cada uno de
// estos degradados levanta un punto y hunde los bordes, así que la
// página gana relieve sin salirse de la paleta del manual.
//
// Van siempre acompañados de su color sólido (`bg-azul-profundo
// bg-hondo-navy`): el color pinta `background-color` y el degradado
// `background-image`, de modo que si el segundo no carga queda el fondo
// correcto y no un hueco blanco.
backgroundImage: {
'hondo-navy': 'radial-gradient(125% 95% at 12% 0%, #18345F 0%, #0A1D3D 52%, #061530 100%)',
'hondo-claro': 'radial-gradient(120% 100% at 85% 0%, #FFFFFF 0%, #F2F2F2 58%, #E7E9EC 100%)',
'hondo-blanco': 'radial-gradient(110% 90% at 18% 0%, #FFFFFF 0%, #FBFBFC 55%, #F0F2F5 100%)',
// Dorado como superficie, no como muestra de color: más claro arriba,
// como si le diera la luz.
'dorado-metal': 'linear-gradient(145deg, #E2B75C 0%, #C4942E 46%, #9C7222 100%)',
// Halo para soltar sobre las secciones oscuras.
'velo-dorado': 'radial-gradient(50% 50% at 50% 50%, rgba(196,148,46,0.20) 0%, rgba(196,148,46,0) 70%)',
'velo-frio': 'radial-gradient(50% 50% at 50% 50%, rgba(200,238,245,0.14) 0%, rgba(200,238,245,0) 70%)',
},
boxShadow: {
// Dos sombras en vez de una: un contacto corto y cerrado que apoya la
// tarjeta en la página, y una difusa y lejana que le da aire. Una sola
// sombra siempre parece calcomanía.
tarjeta: '0 1px 2px rgba(10,29,61,0.06), 0 12px 32px -12px rgba(10,29,61,0.14)',
'tarjeta-alta': '0 2px 4px rgba(10,29,61,0.08), 0 28px 60px -20px rgba(10,29,61,0.28)',
},
maxWidth: { maxWidth: {
content: '1152px', content: '1280px',
// Medida de texto corrido. La tensión entre reglas anchas (1280px) y
// texto angosto (68ch) es lo que hace que la composición se lea
// editorial en vez de como un bloque de landing.
medida: '68ch',
}, },
fontSize: { fontSize: {
// Two title roles, so a brand statement never looks like a section // Cinco roles. `display` se gasta UNA sola vez en toda la página.
// heading. Fluid so they hold from 360px to desktop without a stack of display: ['clamp(3.5rem, 9vw, 7rem)', { lineHeight: '0.94', letterSpacing: '-0.03em' }],
// breakpoint overrides. titulo: ['clamp(2.25rem, 4.5vw, 3.5rem)', { lineHeight: '1', letterSpacing: '-0.02em' }],
sumario: ['clamp(1.375rem, 2vw, 1.75rem)', { lineHeight: '1.35', letterSpacing: '-0.01em' }],
cuerpo: ['1.0625rem', { lineHeight: '1.7' }],
rotulo: ['0.75rem', { lineHeight: '1.2', letterSpacing: '0.3em' }],
// Se van con la limpieza de la Tarea 13, cuando ya no queden secciones
// viejas que los usen.
ancla: ['clamp(2.5rem, 6.5vw, 4.5rem)', { lineHeight: '0.98', letterSpacing: '-0.025em' }], ancla: ['clamp(2.5rem, 6.5vw, 4.5rem)', { lineHeight: '0.98', letterSpacing: '-0.025em' }],
seccion: ['clamp(2rem, 4vw, 3.25rem)', { lineHeight: '1.02', letterSpacing: '-0.02em' }], seccion: ['clamp(2rem, 4vw, 3.25rem)', { lineHeight: '1.02', letterSpacing: '-0.02em' }],
}, },