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>
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
- **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."
|
||||
- **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).
|
||||
|
||||
## Stack y constraints
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -23,7 +23,7 @@ pnpm lint # ESLint
|
||||
app/ # App Router (layout, page, globals.css)
|
||||
components/
|
||||
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
|
||||
lib/
|
||||
content.ts # todo el copy (source of truth)
|
||||
|
||||
@@ -18,9 +18,43 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a:focus-visible, button:focus-visible {
|
||||
outline: 3px solid #C4942E;
|
||||
/* The ring needs to contrast against the background it appears on, not
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'creeer · tu estratega de crecimiento',
|
||||
description: 'Claridad estratégica para empresarios con tracción que llegaron a su punto de inflexión. Inmersión profunda, estrategia ejecutable.',
|
||||
title: 'creeer · claridad estratégica para tu negocio',
|
||||
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
|
||||
openGraph: {
|
||||
title: 'creeer · tu estratega de crecimiento',
|
||||
description: 'Claridad estratégica para empresarios con tracción en punto de inflexión.',
|
||||
title: 'creeer · claridad estratégica para tu negocio',
|
||||
description: 'Tu negocio no necesita más esfuerzo — necesita más claridad. Estrategia ejecutable para empresarios con tracción.',
|
||||
type: 'website',
|
||||
locale: 'es',
|
||||
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' },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<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>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,7 @@ import { FraseAncla } from '@/components/sections/FraseAncla';
|
||||
import { PuntosInflexion } from '@/components/sections/PuntosInflexion';
|
||||
import { Dolores } from '@/components/sections/Dolores';
|
||||
import { ParaQuien } from '@/components/sections/ParaQuien';
|
||||
import { Mecanismo } from '@/components/sections/Mecanismo';
|
||||
import { FormaTrabajar } from '@/components/sections/FormaTrabajar';
|
||||
import { Proceso } from '@/components/sections/Proceso';
|
||||
import { Disciplinas } from '@/components/sections/Disciplinas';
|
||||
import { Estratega } from '@/components/sections/Estratega';
|
||||
import { ComoEmpezar } from '@/components/sections/ComoEmpezar';
|
||||
@@ -16,7 +15,7 @@ const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfessionalService',
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -27,16 +26,20 @@ export default function Home() {
|
||||
<Header />
|
||||
{/* 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
|
||||
seis situaciones y las contesta. FraseAncla y Dolores llegan después,
|
||||
y Mecanismo cierra explicando el método detrás de esas respuestas. */}
|
||||
seis situaciones y las contesta. FraseAncla y Dolores llegan después.
|
||||
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>
|
||||
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
|
||||
<PuntosInflexion />
|
||||
<FraseAncla />
|
||||
<Dolores />
|
||||
<Mecanismo />
|
||||
<FormaTrabajar />
|
||||
<Disciplinas />
|
||||
<Proceso />
|
||||
<ParaQuien />
|
||||
<Estratega />
|
||||
<ComoEmpezar />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { footer, creditosFotos } from '@/lib/content';
|
||||
import { IconoRed, NOMBRES_RED } from '@/components/primitives/IconoRed';
|
||||
import { footer, creditosFotos, empezar } from '@/lib/content';
|
||||
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
@@ -8,6 +10,19 @@ export function Footer() {
|
||||
<Container className="flex flex-col items-center gap-3 py-10 text-center md:flex-row md:justify-between md:text-left">
|
||||
<Wordmark size="sm" onDark />
|
||||
<p className="text-sm text-gris-medio">{footer.tagline}</p>
|
||||
<ul aria-label="Redes y contacto" className="flex items-center gap-2">
|
||||
{NOMBRES_RED.map((red) => (
|
||||
<li key={red}>
|
||||
<a href={empezar.contacto[red].href} {...propsEnlace(empezar.contacto[red].href)}
|
||||
aria-label={esExterno(empezar.contacto[red].href)
|
||||
? `${empezar.contacto[red].label} (${AVISO_PESTANA})`
|
||||
: empezar.contacto[red].label}
|
||||
className="grid place-items-center w-10 h-10 rounded-full text-gris-medio hover:text-dorado transition-colors">
|
||||
<IconoRed nombre={red} className="w-5 h-5" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-gris-medio">{footer.legal}</p>
|
||||
</Container>
|
||||
<Container className="pb-6 text-center md:text-left">
|
||||
@@ -15,8 +30,8 @@ export function Footer() {
|
||||
{creditosFotos.map((c, i) => (
|
||||
<span key={c.href}>
|
||||
{i > 0 && ' · '}
|
||||
<a href={c.href} className="hover:text-gris-medio">{c.desc}</a>, modificada,{' '}
|
||||
<a href={c.licenciaHref} className="hover:text-gris-medio">{c.licencia}</a>
|
||||
<a href={c.href} {...propsEnlace(c.href)} className="hover:text-gris-medio">{c.desc}<span className="sr-only"> ({AVISO_PESTANA})</span></a>, modificada,{' '}
|
||||
<a href={c.licenciaHref} {...propsEnlace(c.licenciaHref)} className="hover:text-gris-medio">{c.licencia}<span className="sr-only"> ({AVISO_PESTANA})</span></a>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
|
||||
@@ -29,3 +29,29 @@ describe('<Footer />', () => {
|
||||
expect(screen.getAllByText(/modificada/)).toHaveLength(creditosFotos.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Footer /> — redes', () => {
|
||||
it('links every contact channel by name, since the icons alone say nothing to a screen reader', () => {
|
||||
render(<Footer />);
|
||||
const lista = screen.getByRole('list', { name: /redes y contacto/i });
|
||||
expect(lista.querySelectorAll('a')).toHaveLength(3);
|
||||
expect(screen.getByRole('link', { name: /^WhatsApp/ })).toHaveAttribute('href', expect.stringMatching(/^https:\/\/wa\.me\//));
|
||||
expect(screen.getByRole('link', { name: 'Email' })).toHaveAttribute('href', expect.stringMatching(/^mailto:/));
|
||||
expect(screen.getByRole('link', { name: /^LinkedIn/ })).toHaveAttribute('href', expect.stringMatching(/linkedin\./));
|
||||
});
|
||||
|
||||
it('opens every outside link in a new tab, so the reader keeps the page', () => {
|
||||
render(<Footer />);
|
||||
for (const a of screen.getAllByRole('link')) {
|
||||
const href = a.getAttribute('href') ?? '';
|
||||
if (/^https?:/.test(href)) {
|
||||
expect(a).toHaveAttribute('target', '_blank');
|
||||
expect(a).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(a).toHaveAccessibleName(/se abre en otra pestaña/);
|
||||
} else {
|
||||
// mailto: in a new tab only leaves an empty tab behind.
|
||||
expect(a).not.toHaveAttribute('target');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
|
||||
|
||||
type Variant = 'light' | 'dark' | 'ghost';
|
||||
// Every variant carries border-2 so filled and outlined CTAs line up when
|
||||
// they sit side by side.
|
||||
const styles: Record<Variant, string> = {
|
||||
light: 'border-2 border-transparent bg-azul-profundo text-white hover:bg-azul-profundo/90',
|
||||
dark: 'border-2 border-transparent bg-dorado text-white hover:bg-dorado/90',
|
||||
// Azul profundo sobre dorado, no blanco: blanco sobre #C4942E es 2,76:1 y
|
||||
// esta es la variante del botón que cierra la página.
|
||||
dark: 'border-2 border-transparent bg-dorado text-azul-profundo hover:bg-dorado/90',
|
||||
ghost: 'border-2 border-azul-profundo text-azul-profundo hover:bg-azul-profundo hover:text-white',
|
||||
};
|
||||
|
||||
@@ -11,10 +15,12 @@ export function CTA({ href, label, variant = 'light', className = '' }: { href:
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...propsEnlace(href)}
|
||||
className={`group inline-block font-sans font-bold uppercase tracking-wider px-7 py-3.5 text-sm transition-colors ${styles[variant]} ${className}`}
|
||||
>
|
||||
{label}{' '}
|
||||
<span aria-hidden="true" className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transform-none motion-reduce:transition-none">→</span>
|
||||
{esExterno(href) && <span className="sr-only"> ({AVISO_PESTANA})</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Set of simple line icons — one per concept used across Mecanismo,
|
||||
// Set of simple line icons — one per concept used across Proceso,
|
||||
// ComoEmpezar and Disciplinas. Same stroke weight and geometric language as
|
||||
// the manual's own iconography guidance ("líneas simples, geométricas,
|
||||
// consistentes en grosor"), hand-drawn to match each step's specific idea
|
||||
@@ -16,10 +16,39 @@ export type IconoNombre =
|
||||
| 'comunicacion'
|
||||
| 'digital'
|
||||
| 'experiencias'
|
||||
| 'reloj';
|
||||
| 'consumidor'
|
||||
| 'ventas'
|
||||
| 'compania'
|
||||
// Entregables — one mark per deliverable, the way the lámina gives every
|
||||
// line its own.
|
||||
| 'matriz'
|
||||
| 'prioridades'
|
||||
| 'rayo'
|
||||
| 'mapa'
|
||||
| 'restriccion'
|
||||
| 'brote'
|
||||
| 'segmentos'
|
||||
| 'ficha'
|
||||
| 'recorrido'
|
||||
| 'hueco'
|
||||
| 'diana'
|
||||
| 'arquitectura'
|
||||
| 'libro'
|
||||
| 'megafono'
|
||||
| 'calendario'
|
||||
| 'ondas'
|
||||
| 'documento'
|
||||
| 'medidor'
|
||||
| 'tablero'
|
||||
| 'comite'
|
||||
| 'hitos'
|
||||
| 'cohete'
|
||||
| 'bombilla'
|
||||
| 'anio'
|
||||
| 'escudo';
|
||||
|
||||
const PATHS: Record<IconoNombre, ReactNode> = {
|
||||
// A line dives through two layers down to a core point — "me sumerjo
|
||||
// A line dives through two layers down to a core point — "nos sumergimos
|
||||
// hasta comprenderla desde dentro".
|
||||
inmersion: (
|
||||
<>
|
||||
@@ -30,7 +59,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
|
||||
<circle cx="12" cy="20.5" r="0.75" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
// A circle (the assumption) cracked by a bolt — "cuestiono lo que todos
|
||||
// A circle (the assumption) cracked by a bolt — "cuestionamos lo que todos
|
||||
// dan por hecho".
|
||||
desafiar: (
|
||||
<>
|
||||
@@ -46,7 +75,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
|
||||
<path d="M20 6h-5l2.5-3.5L20 6" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
// A speech bubble mid-thought — "cuéntame dónde está tu negocio hoy".
|
||||
// A speech bubble mid-thought — "cuéntanos dónde está tu negocio hoy".
|
||||
escribeme: (
|
||||
<>
|
||||
<path d="M4 5.5h16v10H10l-4 3.5v-3.5H4z" />
|
||||
@@ -102,15 +131,232 @@ const PATHS: Record<IconoNombre, ReactNode> = {
|
||||
<circle cx="12" cy="12" r="9" strokeOpacity={0.55} />
|
||||
</>
|
||||
),
|
||||
// Simple clock — "cada proceso tiene su ritmo".
|
||||
reloj: (
|
||||
// A person with the insight sitting inside the head, not beside it — the
|
||||
// point of Consumer Knowledge is what you find in there.
|
||||
consumidor: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3.5 3.5" />
|
||||
<circle cx="12" cy="7.5" r="3.75" />
|
||||
<circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none" />
|
||||
<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
|
||||
</>
|
||||
),
|
||||
// Three rising bars under an arrow — sell in, sell out, rotación.
|
||||
ventas: (
|
||||
<>
|
||||
<path d="M3.5 20.5h17" />
|
||||
<path d="M7 20.5v-4.5" />
|
||||
<path d="M12 20.5v-8" />
|
||||
<path d="M17 20.5v-9" />
|
||||
<path d="m17 4 2.4 4h-4.8z" fill="currentColor" stroke="none" />
|
||||
<path d="M17 8v3.5" />
|
||||
</>
|
||||
),
|
||||
|
||||
// Two towers — the company seen from outside. Stage 01 needs its own mark
|
||||
// because "Diagnóstico ejecutivo", one of its own deliverables, already has
|
||||
// the magnifier, and the two would sit repeated in the same card.
|
||||
compania: (
|
||||
<>
|
||||
<path d="M3 20.5h18" />
|
||||
<path d="M5.5 20.5V5.5l6.5-3v18" />
|
||||
<path d="m12 10.5 6.5 2.8v7.2" />
|
||||
<circle cx="8.3" cy="9" r=".75" fill="currentColor" stroke="none" />
|
||||
<circle cx="8.3" cy="13.5" r=".75" fill="currentColor" stroke="none" />
|
||||
<circle cx="15.3" cy="16" r=".75" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Entregables ────────────────────────────────────────────────────────
|
||||
// Deliberately plainer than the concept icons above: these render at 18px
|
||||
// inside the deliverables list, where anything busier turns to mush.
|
||||
|
||||
// Four quadrants — the SWOT grid itself.
|
||||
matriz: (
|
||||
<>
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="1.5" />
|
||||
<path d="M12 3.5v17M3.5 12h17" />
|
||||
</>
|
||||
),
|
||||
// Three rules, longest first — a ranked list.
|
||||
prioridades: (
|
||||
<>
|
||||
<path d="M4 6.5h16" />
|
||||
<path d="M4 12h10.5" />
|
||||
<path d="M4 17.5h5.5" />
|
||||
</>
|
||||
),
|
||||
// A bolt — the win you can take this quarter.
|
||||
rayo: <path d="M13.5 2.5 5 13.5h5.5l-1 8L19 10h-6z" />,
|
||||
// Folded map.
|
||||
mapa: (
|
||||
<>
|
||||
<path d="m3.5 6.5 5.5-2.5 6 2.5 5.5-2.5v13l-5.5 2.5-6-2.5-5.5 2.5z" />
|
||||
<path d="M9 4v15M15 6.5v13" />
|
||||
</>
|
||||
),
|
||||
// A funnel — where the business narrows.
|
||||
restriccion: <path d="M3.5 4.5h17l-6.3 7.6v7.4l-4.4 2.5V12.1z" />,
|
||||
// A sprout.
|
||||
brote: (
|
||||
<>
|
||||
<path d="M12 21v-8.5" />
|
||||
<path d="M12 12.5c0-3 2.2-5.2 5.2-5.2 0 3-2.2 5.2-5.2 5.2z" />
|
||||
<path d="M12 16c0-2.4-1.8-4.2-4.2-4.2 0 2.4 1.8 4.2 4.2 4.2z" />
|
||||
</>
|
||||
),
|
||||
// A disc cut into parts.
|
||||
segmentos: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 3.5v8.5l7.4 4.3" />
|
||||
</>
|
||||
),
|
||||
// An ID card — the persona, written down.
|
||||
ficha: (
|
||||
<>
|
||||
<rect x="2.5" y="5" width="19" height="14" rx="1.6" />
|
||||
<circle cx="8.5" cy="10.5" r="2" />
|
||||
<path d="M5.4 15.6a3.3 3.3 0 0 1 6.2 0" />
|
||||
<path d="M14.5 10.5h4.5M14.5 14h4.5" />
|
||||
</>
|
||||
),
|
||||
// Waypoints along a dotted route.
|
||||
recorrido: (
|
||||
<>
|
||||
<path d="M4.5 19.5c4.5 0 3-6 7-6s3.5-7 8-7" strokeDasharray="1.6 3" />
|
||||
<circle cx="4.5" cy="19.5" r="1.6" fill="currentColor" stroke="none" />
|
||||
<circle cx="19.5" cy="6.5" r="1.6" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
// The gap between two solid runs — the need nobody covers.
|
||||
hueco: (
|
||||
<>
|
||||
<path d="M2.5 12h5.5M16 12h5.5" />
|
||||
<circle cx="12" cy="12" r="3.4" strokeDasharray="2 2.6" />
|
||||
</>
|
||||
),
|
||||
// Crosshair on the target.
|
||||
diana: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none" />
|
||||
<path d="M12 1.5V5M12 19v3.5M1.5 12H5M19 12h3.5" />
|
||||
</>
|
||||
),
|
||||
// A parent and two children.
|
||||
arquitectura: (
|
||||
<>
|
||||
<rect x="9" y="2.5" width="6" height="4.5" rx="1" />
|
||||
<rect x="2" y="17" width="6" height="4.5" rx="1" />
|
||||
<rect x="16" y="17" width="6" height="4.5" rx="1" />
|
||||
<path d="M12 7v3.5M5 17v-3.5h14V17" />
|
||||
</>
|
||||
),
|
||||
// An open book.
|
||||
libro: (
|
||||
<>
|
||||
<path d="M12 6.5v13" />
|
||||
<path d="M12 6.5C10.5 5 8.5 4.5 4 4.5v13c4.5 0 6.5.5 8 2" />
|
||||
<path d="M12 6.5c1.5-1.5 3.5-2 8-2v13c-4.5 0-6.5.5-8 2" />
|
||||
</>
|
||||
),
|
||||
// A megaphone.
|
||||
megafono: (
|
||||
<>
|
||||
<path d="M3.5 10.5v3A1.5 1.5 0 0 0 5 15h2.5l7 4V5l-7 4H5a1.5 1.5 0 0 0-1.5 1.5z" />
|
||||
<path d="M18 9.5a4 4 0 0 1 0 5" />
|
||||
</>
|
||||
),
|
||||
// A month blocked out.
|
||||
calendario: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15.5" rx="1.5" />
|
||||
<path d="M3.5 9.5h17M8 2.5V6M16 2.5V6" />
|
||||
<path d="M7.5 14h6" />
|
||||
</>
|
||||
),
|
||||
// Broadcast.
|
||||
ondas: (
|
||||
<>
|
||||
<circle cx="12" cy="15" r="1.8" fill="currentColor" stroke="none" />
|
||||
<path d="M8.8 11.6a4.5 4.5 0 0 1 6.4 0" />
|
||||
<path d="M5.8 8.4a9 9 0 0 1 12.4 0" />
|
||||
<path d="M12 16.8V21" />
|
||||
</>
|
||||
),
|
||||
// A written page.
|
||||
documento: (
|
||||
<>
|
||||
<path d="M5.5 3.5h8l5 5v12h-13z" />
|
||||
<path d="M13.5 3.5V9h5" />
|
||||
<path d="M8.5 13h7M8.5 16.5h4.5" />
|
||||
</>
|
||||
),
|
||||
// A gauge — the number the CEO watches.
|
||||
medidor: (
|
||||
<>
|
||||
<path d="M3.5 17.5a8.5 8.5 0 0 1 17 0" />
|
||||
<path d="m12 17.5 4.6-5.2" />
|
||||
<circle cx="12" cy="17.5" r="1.4" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
// Bars inside a frame.
|
||||
tablero: (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="16" rx="1.5" />
|
||||
<path d="M7.5 16v-3.5M12 16v-6.5M16.5 16v-4.5" />
|
||||
</>
|
||||
),
|
||||
// Three round a table.
|
||||
comite: (
|
||||
<>
|
||||
<circle cx="12" cy="6" r="2.4" />
|
||||
<circle cx="5.2" cy="9.5" r="2" />
|
||||
<circle cx="18.8" cy="9.5" r="2" />
|
||||
<path d="M2.5 19.5h19" />
|
||||
<path d="M8 19.5a4 4 0 0 1 8 0" />
|
||||
</>
|
||||
),
|
||||
// Milestones on the line, the last one reached.
|
||||
hitos: (
|
||||
<>
|
||||
<path d="M2.5 12h19" />
|
||||
<circle cx="6.5" cy="12" r="2.1" />
|
||||
<circle cx="12" cy="12" r="2.1" />
|
||||
<circle cx="17.5" cy="12" r="2.1" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
// Launch.
|
||||
cohete: (
|
||||
<>
|
||||
<path d="M12 2.5c3 2.4 4.5 5.7 4.5 9.5L12 16l-4.5-4c0-3.8 1.5-7.1 4.5-9.5z" />
|
||||
<circle cx="12" cy="9.5" r="1.6" fill="currentColor" stroke="none" />
|
||||
<path d="M9.2 16.2 6.5 21l4-1.6M14.8 16.2 17.5 21l-4-1.6" />
|
||||
</>
|
||||
),
|
||||
// A lit idea.
|
||||
bombilla: (
|
||||
<>
|
||||
<path d="M8.5 14.5a5 5 0 1 1 7 0c-.8.8-1.2 1.5-1.2 2.5h-4.6c0-1-.4-1.7-1.2-2.5z" />
|
||||
<path d="M10 20h4" />
|
||||
</>
|
||||
),
|
||||
// A planner full of months.
|
||||
anio: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15.5" rx="1.5" />
|
||||
<path d="M3.5 9.5h17M8 2.5V6M16 2.5V6" />
|
||||
<path d="M7 13h1.6M11.2 13h1.6M15.4 13h1.6M7 16.8h1.6M11.2 16.8h1.6M15.4 16.8h1.6" />
|
||||
</>
|
||||
),
|
||||
// A shield — what keeps the growth from drifting.
|
||||
escudo: <path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8.6 7.5 10 4.4-1.4 7.5-5.4 7.5-10v-6z" />,
|
||||
};
|
||||
|
||||
// Every drawn icon, straight from the registry above — so a test iterating
|
||||
// these can never fall behind the set that actually exists.
|
||||
export const NOMBRES_ICONO = Object.keys(PATHS) as IconoNombre[];
|
||||
|
||||
export function Icono({ nombre, className = '' }: { nombre: IconoNombre; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function ParDialogo({
|
||||
resuelto ? 'opacity-100' : 'opacity-55'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado">
|
||||
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado-texto">
|
||||
{String(n).padStart(2, '0')} · {name}
|
||||
</p>
|
||||
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
|
||||
@@ -122,7 +122,7 @@ export function ParDialogo({
|
||||
}`}
|
||||
/>
|
||||
<Wordmark size="sm" />
|
||||
<p className="mt-3 text-gris-medio leading-relaxed">{respuesta}</p>
|
||||
<p className="mt-3 text-azul-profundo/70 leading-relaxed">{respuesta}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</li>
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo';
|
||||
// Eighteen crossing lines converge in three groups; in the "completa" variant
|
||||
// each group collapses onto one of the three bars of the ≡, so the mark is
|
||||
// literally produced by the resolution of the tangle rather than placed next
|
||||
// to it. The "hero" variant stops at convergence and leaves the tension open —
|
||||
// FraseAncla pays it off further down the page.
|
||||
// to it. The "hero" variant resolves the chaos into a skyline silhouette —
|
||||
// structure emerging from disorder, what creeer builds for its clients.
|
||||
// FraseAncla pays off the full ≡ resolution further down the page.
|
||||
|
||||
type Variant = 'completa' | 'hero';
|
||||
|
||||
@@ -40,6 +41,55 @@ const DISPERSAS = [
|
||||
// converge — many/disordered becomes few/aligned, per the manual.
|
||||
const SOBREVIVE = [0, 6, 12];
|
||||
|
||||
// Skyline destination for the hero variant. Eighteen lines form a city
|
||||
// silhouette: vertical segments are building walls, horizontal segments
|
||||
// are rooftops and the ground line. The skyline reads left to right as
|
||||
// buildings of increasing height — chaos becoming structure, the visual
|
||||
// promise of what creeer delivers.
|
||||
//
|
||||
// ViewBox: 64×64. Ground at y=58. Tallest building peaks at y=10.
|
||||
//
|
||||
// ┌──┐
|
||||
// ┌──┐ │ │ ┌─┐
|
||||
// │ │ │ │ │ │┌──┐
|
||||
// ┌─┤ │ │ │ │ ││ │
|
||||
// │ │ │ │ │ │ ││ │
|
||||
// └─┴──┴─┴──┴─┴─┴┴──┘
|
||||
//
|
||||
const SKYLINE = [
|
||||
// Building 1 (left, medium) — x: 4–14, top: 28
|
||||
{ x1: 4, y1: 58, x2: 4, y2: 28 }, // 0: left wall
|
||||
{ x1: 4, y1: 28, x2: 14, y2: 28 }, // 1: rooftop
|
||||
{ x1: 14, y1: 28, x2: 14, y2: 58 }, // 2: right wall
|
||||
|
||||
// Building 2 (tall) — x: 18–30, top: 12
|
||||
{ x1: 18, y1: 58, x2: 18, y2: 12 }, // 3: left wall
|
||||
{ x1: 18, y1: 12, x2: 30, y2: 12 }, // 4: rooftop
|
||||
{ x1: 30, y1: 12, x2: 30, y2: 58 }, // 5: right wall
|
||||
|
||||
// Building 3 (short) — x: 34–40, top: 32
|
||||
{ x1: 34, y1: 58, x2: 34, y2: 32 }, // 6: left wall
|
||||
{ x1: 34, y1: 32, x2: 40, y2: 32 }, // 7: rooftop
|
||||
{ x1: 40, y1: 32, x2: 40, y2: 58 }, // 8: right wall
|
||||
|
||||
// Building 4 (medium-tall) — x: 42–54, top: 20
|
||||
{ x1: 42, y1: 58, x2: 42, y2: 20 }, // 9: left wall
|
||||
{ x1: 42, y1: 20, x2: 54, y2: 20 }, // 10: rooftop
|
||||
{ x1: 54, y1: 20, x2: 54, y2: 58 }, // 11: right wall
|
||||
|
||||
// Small tower on building 1 — x: 7–11, top: 22
|
||||
{ x1: 7, y1: 28, x2: 7, y2: 22 }, // 12: left wall
|
||||
{ x1: 7, y1: 22, x2: 11, y2: 22 }, // 13: rooftop
|
||||
{ x1: 11, y1: 22, x2: 11, y2: 28 }, // 14: right wall
|
||||
|
||||
// Ground line segments
|
||||
{ x1: 2, y1: 58, x2: 30, y2: 58 }, // 15: ground left
|
||||
{ x1: 30, y1: 58, x2: 56, y2: 58 }, // 16: ground right
|
||||
|
||||
// Antenna on tallest building — a small vertical detail
|
||||
{ x1: 24, y1: 12, x2: 24, y2: 6 }, // 17: antenna
|
||||
];
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
export function SecuenciaClaridad({
|
||||
@@ -54,26 +104,23 @@ export function SecuenciaClaridad({
|
||||
const completa = variant === 'completa';
|
||||
|
||||
// Where each line ends up. In "completa" the survivors land exactly on the
|
||||
// symbol's own coordinates; in "hero" they merely tighten toward their
|
||||
// group's axis without ever aligning.
|
||||
// symbol's own coordinates; in "hero" the lines form a skyline silhouette.
|
||||
const destino = (i: number) => {
|
||||
if (!completa) {
|
||||
// Hero variant: lines converge into the skyline shape.
|
||||
const s = SKYLINE[i];
|
||||
return {
|
||||
x1: s.x1,
|
||||
y1: s.y1,
|
||||
x2: s.x2,
|
||||
y2: s.y2,
|
||||
opacity: i === 17 ? 0.7 : 0.85, // antenna slightly dimmer
|
||||
};
|
||||
}
|
||||
// Completa variant: survivors land on the ≡ bars.
|
||||
const { barra } = DISPERSAS[i];
|
||||
const linea = LINEAS_SIMBOLO[barra];
|
||||
const sobrevive = SOBREVIVE.includes(i);
|
||||
if (!completa) {
|
||||
const eje = linea.y1;
|
||||
const d = DISPERSAS[i];
|
||||
// Pulled a good way further in than it used to be: at a third of the way
|
||||
// the tangle still read as a scratched-out scribble instead of as lines
|
||||
// finding their axis. It must arrive close to aligned and stop short.
|
||||
return {
|
||||
x1: d.x1 + (26 - d.x1) * 0.5,
|
||||
y1: d.y1 + (eje - d.y1) * 0.7,
|
||||
x2: d.x2 + (38 - d.x2) * 0.5,
|
||||
y2: d.y2 + (eje - d.y2) * 0.7,
|
||||
opacity: sobrevive ? 0.95 : 0.28,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x1: linea.x1,
|
||||
y1: linea.y1,
|
||||
@@ -87,8 +134,7 @@ export function SecuenciaClaridad({
|
||||
completa && SOBREVIVE.includes(i) ? LINEAS_SIMBOLO[DISPERSAS[i].barra].grosor : 1.5;
|
||||
|
||||
// Reduced motion renders plain <line> elements: motion.line would apply its
|
||||
// own dash scaffolding and leave the drawing invisible (see the same trap in
|
||||
// IlustracionInflexion).
|
||||
// own dash scaffolding and leave the drawing invisible.
|
||||
if (reduced) {
|
||||
return (
|
||||
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
|
||||
@@ -118,8 +164,6 @@ export function SecuenciaClaridad({
|
||||
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
|
||||
{DISPERSAS.map(({ barra: _barra, ...inicio }, i) => {
|
||||
const d = destino(i);
|
||||
// `barra` is grouping metadata, not an SVG attribute — destructured out
|
||||
// so it never reaches the DOM.
|
||||
const reposo = { ...inicio, opacity: 0.55, strokeWidth: 1.5 };
|
||||
return (
|
||||
<motion.line
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,22 @@ describe('<CTA />', () => {
|
||||
expect(screen.getByRole('link').getAttribute('href')).toBe('#contacto');
|
||||
});
|
||||
it('renders the ghost variant outlined, so it reads as secondary', () => {
|
||||
render(<CTA href="#mecanismo" label="Ver cómo trabajo" variant="ghost" />);
|
||||
render(<CTA href="#proceso" label="Ver cómo trabajamos" variant="ghost" />);
|
||||
const classes = screen.getByRole('link').className.split(/\s+/);
|
||||
expect(classes).toContain('border-azul-profundo');
|
||||
expect(classes).not.toContain('bg-azul-profundo'); // sólo se rellena en hover
|
||||
expect(classes).toContain('hover:bg-azul-profundo');
|
||||
});
|
||||
it('rellena en dorado con la etiqueta en azul profundo', () => {
|
||||
// Blanco sobre el dorado del manual (#C4942E) es 2,76:1 — no pasa AA para
|
||||
// texto pequeño, y esta es la variante del botón que cierra la página.
|
||||
// Azul profundo sobre ese mismo dorado da 6,07:1.
|
||||
render(<CTA href="#contacto" label="Define tu siguiente movimiento" variant="dark" />);
|
||||
const classes = screen.getByRole('link').className.split(/\s+/);
|
||||
expect(classes).toContain('bg-dorado');
|
||||
expect(classes).toContain('text-azul-profundo');
|
||||
expect(classes).not.toContain('text-white');
|
||||
});
|
||||
it('keeps every variant the same height', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ClaridadHero } from '../ClaridadHero';
|
||||
|
||||
describe('<ClaridadHero />', () => {
|
||||
it('is decorative: hidden from assistive tech', () => {
|
||||
const { container } = render(<ClaridadHero className="w-full h-full" />);
|
||||
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('holds the box open so nothing shifts when the scene arrives', () => {
|
||||
const { container } = render(<ClaridadHero className="w-full h-full" />);
|
||||
expect(container.firstElementChild).toHaveClass('w-full', 'h-full');
|
||||
});
|
||||
|
||||
// The stub observer in the test setup never reports an intersection, which is
|
||||
// exactly the case worth pinning: until the box comes near the viewport,
|
||||
// nothing mounts and three.js is never fetched.
|
||||
it('does not fetch three.js before the box comes near the viewport', () => {
|
||||
const { container } = render(<ClaridadHero className="w-full h-full" />);
|
||||
expect(container.firstElementChild).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
// jsdom has no WebGL, so the real <Canvas> cannot mount. The mock keeps the
|
||||
// wrapper — the part that is plain DOM and that carries the decisions worth
|
||||
// protecting — testable, and reports back the frameloop it was handed.
|
||||
vi.mock('@react-three/fiber', () => ({
|
||||
Canvas: ({ frameloop }: { frameloop?: string }) => (
|
||||
<div data-testid="lienzo" data-frameloop={frameloop} />
|
||||
),
|
||||
useFrame: () => {},
|
||||
useThree: () => ({ camera: { position: { set: () => {} }, lookAt: () => {} }, invalidate: () => {} }),
|
||||
}));
|
||||
|
||||
import { EscenaClaridad } from '../EscenaClaridad';
|
||||
|
||||
function conMovimientoReducido(reducido: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: reducido && query.includes('reduce'),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
afterEach(() => conMovimientoReducido(false));
|
||||
|
||||
describe('<EscenaClaridad />', () => {
|
||||
it('is decorative: hidden from assistive tech', () => {
|
||||
const { container } = render(<EscenaClaridad />);
|
||||
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('takes its size from the caller, so the hero reserves the box', () => {
|
||||
const { container } = render(<EscenaClaridad className="w-full h-full" />);
|
||||
expect(container.firstElementChild).toHaveClass('w-full', 'h-full');
|
||||
});
|
||||
|
||||
it('runs the loop while the hero is on screen', () => {
|
||||
conMovimientoReducido(false);
|
||||
const { getByTestId } = render(<EscenaClaridad />);
|
||||
expect(getByTestId('lienzo')).toHaveAttribute('data-frameloop', 'always');
|
||||
});
|
||||
|
||||
it('stops the loop for readers who asked for reduced motion', () => {
|
||||
conMovimientoReducido(true);
|
||||
const { getByTestId } = render(<EscenaClaridad />);
|
||||
expect(getByTestId('lienzo')).toHaveAttribute('data-frameloop', 'demand');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,6 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Icono, type IconoNombre } from '../Icono';
|
||||
|
||||
const NOMBRES: IconoNombre[] = [
|
||||
'inmersion',
|
||||
'desafiar',
|
||||
'estrategia',
|
||||
'escribeme',
|
||||
'diagnostico',
|
||||
'plan',
|
||||
'comunicacion',
|
||||
'digital',
|
||||
'experiencias',
|
||||
];
|
||||
import { Icono, NOMBRES_ICONO } from '../Icono';
|
||||
|
||||
describe('<Icono />', () => {
|
||||
it('is decorative: hidden from assistive tech', () => {
|
||||
@@ -20,7 +8,7 @@ describe('<Icono />', () => {
|
||||
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it.each(NOMBRES)('renders a non-empty svg for "%s"', (nombre) => {
|
||||
it.each(NOMBRES_ICONO)('renders a non-empty svg for "%s"', (nombre) => {
|
||||
const { container } = render(<Icono nombre={nombre} />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg?.children.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { IconoRed, NOMBRES_RED } from '../IconoRed';
|
||||
|
||||
describe('<IconoRed />', () => {
|
||||
it('is decorative: the link around it carries the name', () => {
|
||||
const { container } = render(<IconoRed nombre="whatsapp" />);
|
||||
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it.each(NOMBRES_RED)('renders a non-empty svg for "%s"', (nombre) => {
|
||||
const { container } = render(<IconoRed nombre={nombre} />);
|
||||
expect(container.querySelector('svg')?.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never brings its own brand colour onto the page', () => {
|
||||
for (const nombre of NOMBRES_RED) {
|
||||
const { container } = render(<IconoRed nombre={nombre} />);
|
||||
const pintados = container.querySelectorAll('[fill]:not([fill="none"]), [stroke]');
|
||||
pintados.forEach((el) => {
|
||||
const color = el.getAttribute('fill') ?? el.getAttribute('stroke');
|
||||
if (color !== 'none') expect(color).toBe('currentColor');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MarcaSeccion, PASOS } from '../MarcaSeccion';
|
||||
import { LINEAS_SIMBOLO } from '@/lib/simbolo';
|
||||
|
||||
const lineas = (paso: number) => {
|
||||
const { container } = render(<MarcaSeccion paso={paso} />);
|
||||
return Array.from(container.querySelectorAll('line'));
|
||||
};
|
||||
|
||||
const visibles = (paso: number) => lineas(paso).filter((l) => l.getAttribute('opacity') !== '0');
|
||||
|
||||
const coords = (paso: number) =>
|
||||
lineas(paso).map((l) =>
|
||||
(['x1', 'y1', 'x2', 'y2'] as const).map((a) => Number(l.getAttribute(a))),
|
||||
);
|
||||
|
||||
describe('<MarcaSeccion />', () => {
|
||||
it('opens the document with all nine lines tangled', () => {
|
||||
expect(visibles(0)).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("resolves into the manual's ≡ exactly, not into a lookalike", () => {
|
||||
// Not a lookalike: the exact three bars that the wordmark draws.
|
||||
const finales = visibles(PASOS - 1);
|
||||
expect(finales).toHaveLength(3);
|
||||
finales.forEach((l, i) => {
|
||||
(['x1', 'y1', 'x2', 'y2'] as const).forEach((attr) => {
|
||||
expect(Number(l.getAttribute(attr)), attr).toBeCloseTo(LINEAS_SIMBOLO[i][attr], 5);
|
||||
});
|
||||
expect(Number(l.getAttribute('stroke-width'))).toBeCloseTo(LINEAS_SIMBOLO[i].grosor, 5);
|
||||
});
|
||||
});
|
||||
|
||||
it('converges step by step: each one leaves the lines closer to where they end up', () => {
|
||||
// Counting visible lines proves nothing here: the six that fade do it
|
||||
// continuously as `1 - t` and only reach a literal "0" at the last step,
|
||||
// so the count is [9,9,9,9,9,3] and the assertion cannot fail. What does
|
||||
// move at every step is how far each line still is from its final place.
|
||||
const destino = coords(PASOS - 1);
|
||||
const desvio = (paso: number) =>
|
||||
coords(paso).reduce(
|
||||
(total, linea, i) => total + Math.hypot(...linea.map((v, j) => v - destino[i][j])),
|
||||
0,
|
||||
);
|
||||
const serie = Array.from({ length: PASOS }, (_, p) => desvio(p));
|
||||
serie.slice(1).forEach((d, i) => expect(d, `paso ${i + 1}`).toBeLessThan(serie[i]));
|
||||
expect(serie[PASOS - 1]).toBe(0);
|
||||
});
|
||||
|
||||
it('is decorative for screen readers', () => {
|
||||
const { container } = render(<MarcaSeccion paso={2} />);
|
||||
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const par = {
|
||||
n: 1,
|
||||
name: 'Crecer',
|
||||
situacion: 'El siguiente salto no tiene mapa.',
|
||||
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro.',
|
||||
respuesta: 'Nos sumergimos hasta entender tu empresa desde dentro.',
|
||||
};
|
||||
|
||||
const montar = (extra: Partial<Parameters<typeof ParDialogo>[0]> = {}) =>
|
||||
@@ -20,7 +20,7 @@ describe('<ParDialogo />', () => {
|
||||
it('renders both voices of the exchange', () => {
|
||||
montar();
|
||||
expect(screen.getByText(/El siguiente salto no tiene mapa/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Nos sumergimos hasta entender tu empresa desde dentro/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('attributes the answer to creeer, not to the reader', () => {
|
||||
@@ -56,7 +56,7 @@ describe('<ParDialogo />', () => {
|
||||
// document. `hidden` or `display: none` would take it away from screen
|
||||
// readers and from the static export the crawler sees — half the section's
|
||||
// message, gone, to buy an animation.
|
||||
const respuesta = screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/);
|
||||
const respuesta = screen.getByText(/Nos sumergimos hasta entender tu empresa desde dentro/);
|
||||
expect(respuesta).toBeInTheDocument();
|
||||
expect(respuesta.closest('[hidden]')).toBeNull();
|
||||
expect(screen.getByLabelText(/Respuesta de creeer a: Crecer/).className).toMatch(/opacity-0\b/);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Revelar } from '../Revelar';
|
||||
|
||||
// The stub in vitest.setup.ts is inert: it observes but never fires. This one
|
||||
// captures the callback so we can simulate the element entering the viewport.
|
||||
let disparar: (entradas: Partial<IntersectionObserverEntry>[]) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class {
|
||||
constructor(cb: IntersectionObserverCallback) {
|
||||
disparar = (entradas) => cb(entradas as IntersectionObserverEntry[], this as never);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('<Revelar />', () => {
|
||||
it('no oculta nada por sí mismo', () => {
|
||||
// The hidden state is CSS-only, gated on `html.js`. If the component wrote
|
||||
// opacity 0 into the inline style we'd be back to the bug this redesign
|
||||
// fixes: 112 invisible elements in the export and a blank page whenever
|
||||
// the JS doesn't arrive.
|
||||
const { container } = render(<Revelar>Tu negocio no necesita más esfuerzo.</Revelar>);
|
||||
const el = container.firstElementChild as HTMLElement;
|
||||
expect(el.style.opacity).toBe('');
|
||||
expect(screen.getByText('Tu negocio no necesita más esfuerzo.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marca el bloque para que el CSS lo pueda gobernar', () => {
|
||||
const { container } = render(<Revelar>x</Revelar>);
|
||||
expect(container.firstElementChild).toHaveAttribute('data-revelar');
|
||||
});
|
||||
|
||||
it('escalona el retardo con el índice', () => {
|
||||
const { container } = render(<Revelar indice={3}>x</Revelar>);
|
||||
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--i')).toBe('3');
|
||||
});
|
||||
|
||||
it('se marca visible al entrar en viewport', () => {
|
||||
const { container } = render(<Revelar>x</Revelar>);
|
||||
const el = container.firstElementChild as HTMLElement;
|
||||
expect(el).not.toHaveAttribute('data-visible');
|
||||
disparar([{ isIntersecting: true, target: el }]);
|
||||
expect(el).toHaveAttribute('data-visible');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Seccion } from '../Seccion';
|
||||
|
||||
describe('<Seccion />', () => {
|
||||
it('renders its content and its number', () => {
|
||||
render(<Seccion numero="02" paso={1}>Dónde estás</Seccion>);
|
||||
expect(screen.getByText('Dónde estás')).toBeInTheDocument();
|
||||
expect(screen.getByText('02')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('carries its mark at the step it is given', () => {
|
||||
const { container } = render(<Seccion numero="06" paso={5}>x</Seccion>);
|
||||
// Step 5 resolves the ≡: three of the nine lines stay visible.
|
||||
const visibles = Array.from(container.querySelectorAll('line')).filter(
|
||||
(l) => l.getAttribute('opacity') !== '0',
|
||||
);
|
||||
expect(visibles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('uses the manual gold on dark backgrounds, the readable gold on light ones', () => {
|
||||
const { container: oscuro } = render(<Seccion numero="03" paso={2} fondo="oscuro">x</Seccion>);
|
||||
expect(oscuro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado');
|
||||
expect(oscuro.querySelector('svg')?.getAttribute('class')).not.toContain('text-dorado-texto');
|
||||
|
||||
const { container: claro } = render(<Seccion numero="04" paso={3}>x</Seccion>);
|
||||
expect(claro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado-texto');
|
||||
});
|
||||
|
||||
it('is not announced to screen readers', () => {
|
||||
// It is document furniture, not content: the section's own heading
|
||||
// already says what it is about.
|
||||
const { container } = render(<Seccion numero="05" paso={4}>x</Seccion>);
|
||||
expect(container.querySelector('[aria-hidden="true"]')?.textContent).toContain('05');
|
||||
});
|
||||
|
||||
it('only becomes an anchor target when it has an id', () => {
|
||||
const { container } = render(<Seccion numero="06" paso={5} id="contacto">x</Seccion>);
|
||||
const seccion = container.querySelector('section');
|
||||
expect(seccion).toHaveAttribute('id', 'contacto');
|
||||
expect(seccion?.className).toContain('scroll-mt-24');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SimboloMarca } from '../SimboloMarca';
|
||||
import { BARRAS_SIMBOLO } from '@/lib/simbolo';
|
||||
|
||||
describe('<SimboloMarca />', () => {
|
||||
it('is decorative: hidden from assistive tech', () => {
|
||||
const { container } = render(<SimboloMarca />);
|
||||
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
// The dueña's 28/jul instruction was that the ≡ has to read as an E. That
|
||||
// depends entirely on the proportions in lib/simbolo.ts — squat bars and
|
||||
// tight gaps turn it into a hamburger menu icon. Drawing from the canonical
|
||||
// geometry rather than hand-typed numbers is what keeps that guarantee, so
|
||||
// the test checks the bars really came from there.
|
||||
it('draws the three canonical bars, straight from lib/simbolo', () => {
|
||||
const { container } = render(<SimboloMarca />);
|
||||
const barras = container.querySelectorAll('rect');
|
||||
expect(barras).toHaveLength(3);
|
||||
barras.forEach((barra, i) => {
|
||||
expect(barra.getAttribute('x')).toBe(String(BARRAS_SIMBOLO[i].x));
|
||||
expect(barra.getAttribute('y')).toBe(String(BARRAS_SIMBOLO[i].y));
|
||||
expect(barra.getAttribute('width')).toBe(String(BARRAS_SIMBOLO[i].width));
|
||||
expect(barra.getAttribute('height')).toBe(String(BARRAS_SIMBOLO[i].height));
|
||||
});
|
||||
});
|
||||
|
||||
it('takes its colour from currentColor via className, not a hardcoded fill', () => {
|
||||
const { container } = render(<SimboloMarca className="text-dorado" />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveAttribute('fill', 'currentColor');
|
||||
expect(svg).toHaveClass('text-dorado');
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,20 @@ import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { CTA } from '@/components/primitives/CTA';
|
||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||
import { IconoRed, NOMBRES_RED } from '@/components/primitives/IconoRed';
|
||||
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
|
||||
import { Resplandor } from '@/components/primitives/Resplandor';
|
||||
import { cierre } from '@/lib/content';
|
||||
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function Cierre() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const { whatsapp, email, linkedin } = cierre.contact;
|
||||
const { whatsapp } = cierre.contact;
|
||||
return (
|
||||
<section id="contacto" className="bg-azul-profundo text-white scroll-mt-24 relative overflow-hidden">
|
||||
<section id="contacto" className="bg-azul-profundo bg-hondo-navy text-white scroll-mt-24 relative overflow-hidden">
|
||||
<FondoPuntos className="top-12 -left-16 w-72 md:w-[30rem] text-dorado/25" distance={36} />
|
||||
<Resplandor className="-top-32 left-1/2 -translate-x-1/2 w-[46rem] h-[46rem]" />
|
||||
<Container className="relative py-16 md:py-24 text-center">
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="flex flex-col items-center">
|
||||
@@ -26,9 +30,16 @@ export function Cierre() {
|
||||
</motion.div>
|
||||
<motion.nav variants={fadeInUp} aria-label="Contacto" className="mt-14">
|
||||
<ul className="flex flex-wrap justify-center gap-3 text-sm">
|
||||
<li><a href={whatsapp.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{whatsapp.label}</a></li>
|
||||
<li><a href={email.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{email.label}</a></li>
|
||||
<li><a href={linkedin.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{linkedin.label}</a></li>
|
||||
{NOMBRES_RED.map((red) => (
|
||||
<li key={red}>
|
||||
<a href={cierre.contact[red].href} {...propsEnlace(cierre.contact[red].href)}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">
|
||||
<IconoRed nombre={red} className="w-4 h-4 shrink-0" />
|
||||
{cierre.contact[red].label}
|
||||
{esExterno(cierre.contact[red].href) && <span className="sr-only"> ({AVISO_PESTANA})</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.nav>
|
||||
</motion.div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { Antetitulo } from '@/components/primitives/Antetitulo';
|
||||
import { CTA } from '@/components/primitives/CTA';
|
||||
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
|
||||
import { comoEmpezar, cierre } from '@/lib/content';
|
||||
@@ -18,10 +19,10 @@ export function ComoEmpezar() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const desplazamiento = useTransform(scrollYProgress, [0, 1], [20, -20]);
|
||||
return (
|
||||
<section className="bg-white">
|
||||
<section className="bg-white bg-hondo-blanco">
|
||||
<Container className="py-16 md:py-24 grid gap-12 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
|
||||
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{comoEmpezar.eyebrow}</motion.p>
|
||||
<Antetitulo as={motion.p} variants={fadeInUp}>{comoEmpezar.eyebrow}</Antetitulo>
|
||||
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-xl">{comoEmpezar.title}</motion.h2>
|
||||
<motion.ol variants={staggerContainer} className="mt-12 grid gap-10 sm:grid-cols-3 md:grid-cols-1 md:gap-8">
|
||||
{comoEmpezar.steps.map((step, i) => (
|
||||
@@ -38,7 +39,7 @@ export function ComoEmpezar() {
|
||||
<Icono nombre={ICONOS[i]} className="w-6 h-6 text-dorado shrink-0" />
|
||||
<h3 className="font-sans font-bold text-xl text-azul-profundo">{step.name}</h3>
|
||||
</div>
|
||||
<p className="mt-3 md:mt-1 text-gris-medio leading-relaxed">{step.desc}</p>
|
||||
<p className="mt-3 md:mt-1 text-azul-profundo/70 leading-relaxed">{step.desc}</p>
|
||||
</div>
|
||||
</motion.li>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { Antetitulo } from '@/components/primitives/Antetitulo';
|
||||
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
|
||||
import { Resplandor } from '@/components/primitives/Resplandor';
|
||||
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
|
||||
import { IlustracionConvergencia } from '@/components/primitives/IlustracionConvergencia';
|
||||
import { disciplinas } from '@/lib/content';
|
||||
@@ -18,16 +20,45 @@ export function Disciplinas() {
|
||||
// nobody loses anything by not having a mouse.
|
||||
const [senalada, setSenalada] = useState<number | null>(null);
|
||||
return (
|
||||
<section className="bg-white">
|
||||
<Container className="py-16 md:py-24">
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{disciplinas.eyebrow}</motion.p>
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 font-sans font-black text-seccion text-azul-profundo">{disciplinas.title}</motion.h2>
|
||||
// Azul profundo, no blanco: al mover esta sección delante de Proceso
|
||||
// quedaba pegada a Dolores, que también es blanca, y las dos se fundían.
|
||||
// Además rompe el tramo de seis secciones claras seguidas que había entre
|
||||
// FraseAncla y Cierre, y le da a la página un latido oscuro a media altura.
|
||||
<section className="bg-azul-profundo bg-hondo-navy text-white relative overflow-hidden">
|
||||
<Resplandor className="-top-40 -right-32 w-[38rem] h-[38rem]" />
|
||||
<Resplandor className="bottom-[-14rem] -left-40 w-[32rem] h-[32rem]" tono="frio" />
|
||||
<Container className="relative py-16 md:py-24">
|
||||
{/* Title and lead on the left, the drawing on the right at the same
|
||||
height. The lead is the sentence this section exists for — Proceso
|
||||
sits after it so it lands first — and it used to be small grey
|
||||
text lost in the gap under the title, with the drawing floating
|
||||
loose below. */}
|
||||
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 md:flex md:items-center md:justify-between md:gap-10">
|
||||
<p className="text-gris-medio text-lg">{disciplinas.lead}</p>
|
||||
<IlustracionConvergencia className="mt-6 md:mt-0 w-56 md:w-64 h-auto shrink-0" activa={senalada} />
|
||||
className="md:flex md:items-end md:justify-between md:gap-12">
|
||||
<div>
|
||||
{/* sobre="oscuro": la sección es azul profundo, así que aquí el dorado
|
||||
del manual se queda tal cual — 6,07:1. */}
|
||||
<Antetitulo sobre="oscuro" as="p">
|
||||
{disciplinas.eyebrow}
|
||||
</Antetitulo>
|
||||
<h2 className="mt-4 font-sans font-black text-seccion text-balance">{disciplinas.title}</h2>
|
||||
<p className="mt-4 text-xl md:text-2xl font-semibold text-white/85">
|
||||
{/* Two unbreakable halves, the dash closing the first. On a
|
||||
phone the line used to break wherever it fell — before the
|
||||
dash, or before «medios» — and left a stray fragment alone. */}
|
||||
{(() => {
|
||||
const [antes, despues] = disciplinas.lead.split(' — ');
|
||||
if (!despues) return disciplinas.lead; // copy without a dash
|
||||
return (
|
||||
<>
|
||||
<span className="whitespace-nowrap">{antes} —</span>{' '}
|
||||
<span className="whitespace-nowrap">{despues}</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<IlustracionConvergencia className="mt-8 md:mt-0 w-56 md:w-72 h-auto shrink-0" activa={senalada} />
|
||||
</motion.div>
|
||||
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-12 grid gap-6 md:grid-cols-3 list-none p-0">
|
||||
@@ -35,13 +66,19 @@ export function Disciplinas() {
|
||||
<motion.li key={item.name} variants={fadeInUp}
|
||||
onPointerEnter={() => setSenalada(i)}
|
||||
onPointerLeave={() => setSenalada(null)}
|
||||
className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none">
|
||||
<TarjetaSpotlight className="h-full rounded-lg bg-azul-profundo text-white p-7">
|
||||
<div className="grid place-items-center w-14 h-14 rounded-full bg-white/10">
|
||||
// No lift: the cards are not links. What answers the pointer is
|
||||
// the card's own strand lighting up in the drawing above, plus a
|
||||
// gold edge so the reader sees which card that strand belongs to.
|
||||
className="group">
|
||||
{/* Tarjetas invertidas al pasar la sección a azul: `text-gris-medio`
|
||||
se leía bien sobre navy, pero sobre blanco cae a ~2,3:1 y no
|
||||
pasa contraste. La descripción va en azul rebajado. */}
|
||||
<TarjetaSpotlight className="h-full rounded-xl bg-white text-azul-profundo p-7 shadow-tarjeta border-t-4 border-transparent transition-colors duration-200 group-hover:border-dorado motion-reduce:transition-none">
|
||||
<div className="grid place-items-center w-14 h-14 rounded-full bg-dorado/15">
|
||||
<Icono nombre={ICONOS[i]} className="w-7 h-7 text-dorado" />
|
||||
</div>
|
||||
<h3 className="mt-5 font-sans font-black text-2xl">{item.name}</h3>
|
||||
<p className="mt-3 text-gris-medio leading-relaxed">{item.desc}</p>
|
||||
<p className="mt-3 text-azul-profundo/75 leading-relaxed text-pretty">{item.desc}</p>
|
||||
</TarjetaSpotlight>
|
||||
</motion.li>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { Antetitulo } from '@/components/primitives/Antetitulo';
|
||||
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
|
||||
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
|
||||
import { dolores } from '@/lib/content';
|
||||
@@ -28,15 +29,16 @@ export function Dolores() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
return (
|
||||
<section className="bg-white relative overflow-hidden">
|
||||
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
|
||||
{/* Smaller and a touch stronger than it was: sprawling at 8% across the
|
||||
whole title it read as a smudge over the headline rather than as a
|
||||
knot. Stays centred. */}
|
||||
<IlustracionNudo className="pointer-events-none select-none absolute top-0 left-1/2 -translate-x-1/2 w-56 md:w-80 text-azul-profundo/[0.11]" />
|
||||
|
||||
<Container className="relative py-16 md:py-24">
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{dolores.eyebrow}</motion.p>
|
||||
<Antetitulo as={motion.p} variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
|
||||
{dolores.eyebrow}
|
||||
</Antetitulo>
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-3xl">{dolores.title}</motion.h2>
|
||||
|
||||
|
||||
@@ -1,49 +1,30 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||
import { Antetitulo } from '@/components/primitives/Antetitulo';
|
||||
import { ContadorNumero } from '@/components/primitives/ContadorNumero';
|
||||
import { MarcoRetrato } from '@/components/primitives/MarcoRetrato';
|
||||
import { estratega } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function Estratega() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-gris-claro">
|
||||
<section className="bg-gris-claro bg-hondo-claro">
|
||||
<Container className="py-16 md:py-24">
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center">
|
||||
{/* TODO dueña: real portrait. The frame is built to take it as-is —
|
||||
drop an <Image fill /> in place of the placeholder below. */}
|
||||
<motion.div variants={fadeInUp}>
|
||||
<MarcoRetrato>
|
||||
{/* Reads as a brand plate, not as a broken image. The dot grid
|
||||
that used to sit behind the wordmark looked like the
|
||||
checkerboard a browser shows for a picture that failed to
|
||||
load; the ≡ alone, meanwhile, reads as a hamburger menu once
|
||||
it stands by itself in a circle. The wordmark is neither. */}
|
||||
<div className="w-full h-full bg-azul-profundo grid place-items-center" role="img" aria-label="Retrato de la estratega — pendiente">
|
||||
<Wordmark size="md" onDark />
|
||||
</div>
|
||||
</MarcoRetrato>
|
||||
className="max-w-2xl">
|
||||
<Antetitulo as={motion.p} variants={fadeInUp}>
|
||||
{estratega.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span>
|
||||
</Antetitulo>
|
||||
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo">{estratega.title}</motion.h2>
|
||||
<motion.div variants={fadeInUp} className="mt-6 flex items-baseline gap-3">
|
||||
<ContadorNumero hasta={25} sufijo="+" className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none" />
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-azul-profundo/70 font-semibold max-w-[12rem] leading-snug">años construyendo marcas</p>
|
||||
</motion.div>
|
||||
<div>
|
||||
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">
|
||||
{estratega.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span>
|
||||
</motion.p>
|
||||
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo">{estratega.title}</motion.h2>
|
||||
<motion.div variants={fadeInUp} className="mt-6 flex items-baseline gap-3">
|
||||
<ContadorNumero hasta={30} sufijo="+" className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none" />
|
||||
{/* 8rem was narrower than the word "construyendo" itself, which
|
||||
broke the label into three ragged lines beside the number. */}
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-gris-medio font-semibold max-w-[12rem] leading-snug">años construyendo marcas</p>
|
||||
</motion.div>
|
||||
<motion.p variants={fadeInUp} className="mt-6 text-lg text-gris-medio leading-relaxed max-w-2xl">{estratega.bio}</motion.p>
|
||||
<motion.blockquote variants={fadeInUp} className="mt-6 font-sans italic text-xl md:text-2xl text-azul-profundo border-l-4 border-dorado pl-4">
|
||||
{estratega.quote}
|
||||
</motion.blockquote>
|
||||
</div>
|
||||
<motion.p variants={fadeInUp} className="mt-6 text-lg text-azul-profundo/70 leading-relaxed">{estratega.bio}</motion.p>
|
||||
<motion.blockquote variants={fadeInUp} className="mt-6 font-sans italic text-xl md:text-2xl text-azul-profundo border-l-4 border-dorado pl-4">
|
||||
{estratega.quote}
|
||||
</motion.blockquote>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
|
||||
import { Resplandor } from '@/components/primitives/Resplandor';
|
||||
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
|
||||
import { fraseAncla } from '@/lib/content';
|
||||
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
@@ -10,7 +11,10 @@ import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/
|
||||
export function FraseAncla() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-azul-profundo relative overflow-hidden">
|
||||
<section className="bg-azul-profundo bg-hondo-navy relative overflow-hidden">
|
||||
{/* Centrado: la frase es el momento más quieto de la página, así que la
|
||||
luz viene de detrás de ella y no de un lado. */}
|
||||
<Resplandor className="top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[52rem] h-[52rem]" />
|
||||
<Container className="relative py-20 md:py-28 text-center max-w-5xl">
|
||||
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="flex justify-center">
|
||||
@@ -33,6 +37,19 @@ export function FraseAncla() {
|
||||
]}
|
||||
/>
|
||||
</motion.p>
|
||||
{/* El "después": la ancla nombra el diagnóstico; esta línea pinta el
|
||||
resultado en la vida del lector. Sin ella, el único futuro que
|
||||
existe en la página es el negativo (los dolores). */}
|
||||
<motion.p
|
||||
variants={fadeInUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
{...motionProps(reduced)}
|
||||
className="mt-8 max-w-[42ch] mx-auto text-white/70 leading-relaxed"
|
||||
>
|
||||
{fraseAncla.despues}
|
||||
</motion.p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||
import { CTA } from '@/components/primitives/CTA';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { Antetitulo } from '@/components/primitives/Antetitulo';
|
||||
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
|
||||
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
|
||||
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
|
||||
import { hero } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
import { ClaridadHero } from '@/components/primitives/ClaridadHero';
|
||||
import { hero, apertura } from '@/lib/content';
|
||||
|
||||
type Variant = 'hormozi' | 'editorial';
|
||||
|
||||
export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const isHormozi = variant === 'hormozi';
|
||||
const headlineBase = isHormozi
|
||||
? 'font-sans font-black uppercase tracking-tight'
|
||||
@@ -22,7 +17,7 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
||||
const clarityBorder = isHormozi ? 'border-b-[5px] border-dorado pb-0.5' : 'border-b-[3px] border-dorado pb-0.5';
|
||||
|
||||
return (
|
||||
<section className="bg-white relative overflow-hidden">
|
||||
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
|
||||
{/* Kept clear of the wordmark on narrow screens: at 390px the md field
|
||||
reached back across the logotype and the tagline, so the pattern read
|
||||
as collision rather than texture. */}
|
||||
@@ -30,39 +25,32 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
||||
<FondoPuntos className="bottom-16 -left-20 w-64 md:w-96 text-dorado/20" distance={28} rows={2} />
|
||||
<Container className="relative min-h-[80vh] lg:min-h-[88vh] flex items-center py-16 md:py-24">
|
||||
<div className="grid gap-8 lg:gap-14 lg:grid-cols-[1.1fr_0.9fr] lg:items-center w-full">
|
||||
<motion.div variants={staggerContainer} initial="hidden" animate="visible" {...motionProps(reduced)}
|
||||
className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
|
||||
<motion.div variants={fadeInUp}><Wordmark size="xl" showTagline /></motion.div>
|
||||
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-black text-dorado">
|
||||
{hero.eyebrow}
|
||||
</motion.p>
|
||||
<motion.h1 variants={fadeInUp} className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
|
||||
<TextoDesenfoque
|
||||
as="span"
|
||||
segments={[
|
||||
{ text: hero.headline.line1 },
|
||||
{ text: hero.headline.effort, className: 'text-dorado' },
|
||||
{ text: hero.headline.line3 },
|
||||
{ text: hero.headline.clarity, className: clarityBorder },
|
||||
]}
|
||||
/>
|
||||
</motion.h1>
|
||||
<div className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
|
||||
<Wordmark size="xl" showTagline />
|
||||
<Antetitulo className="mt-6 md:text-sm">{hero.eyebrow}</Antetitulo>
|
||||
<h1 className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
|
||||
{hero.headline.line1}{' '}
|
||||
<span className="text-dorado">{hero.headline.effort}</span>{' '}
|
||||
{hero.headline.line3}{' '}
|
||||
<span className={clarityBorder}>{hero.headline.clarity}</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-lg md:text-xl text-azul-profundo/70 leading-relaxed max-w-xl">
|
||||
{apertura.dreamOutcome}
|
||||
</p>
|
||||
{/* Full width while stacked: at their natural widths the two CTAs
|
||||
came out ragged and unequal, which read as an accident rather
|
||||
than as hierarchy. */}
|
||||
<motion.div variants={fadeInUp} className="mt-10 w-full flex flex-col md:flex-row items-stretch md:items-center md:w-auto gap-4">
|
||||
<div className="mt-10 w-full flex flex-col md:flex-row items-stretch md:items-center md:w-auto gap-4">
|
||||
<CTA href="#contacto" label={hero.cta} variant="light" className="text-center md:whitespace-nowrap" />
|
||||
<CTA href="#mecanismo" label={hero.ctaSecundario} variant="ghost" className="text-center md:whitespace-nowrap" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
{/* Complexity → focus, left unresolved: the hero poses the tension
|
||||
and FraseAncla pays it off by resolving into the ≡. Shown on every
|
||||
breakpoint — hidden below lg it left the phone hero with no piece
|
||||
of the brand's own graphic language at all. */}
|
||||
<motion.div variants={fadeInUp} initial="hidden" animate="visible" {...motionProps(reduced)}
|
||||
className="min-w-0">
|
||||
<SecuenciaClaridad variant="hero" className="w-32 lg:w-full max-w-md mx-auto text-azul-profundo" />
|
||||
</motion.div>
|
||||
<CTA href="#proceso" label={hero.ctaSecundario} variant="ghost" className="text-center md:whitespace-nowrap" />
|
||||
</div>
|
||||
</div>
|
||||
{/* A visual representation of clarity, direction, and growth.
|
||||
The box is sized here rather than by the scene, so the space is
|
||||
reserved before three.js arrives and nothing shifts when it does. */}
|
||||
<div className="min-w-0 h-64 lg:h-[28rem]">
|
||||
<ClaridadHero className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { Antetitulo } from '@/components/primitives/Antetitulo';
|
||||
import { IlustracionBifurcacion } from '@/components/primitives/IlustracionBifurcacion';
|
||||
import { paraQuien } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
@@ -9,7 +10,7 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
|
||||
export function ParaQuien() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-white relative overflow-hidden">
|
||||
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
|
||||
<Container className="relative pt-16 md:pt-24 pb-8 md:pb-12 md:flex md:items-center md:justify-between md:gap-12">
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="font-sans font-black text-seccion text-azul-profundo max-w-3xl">{paraQuien.title}</motion.h2>
|
||||
@@ -24,7 +25,7 @@ export function ParaQuien() {
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="grid md:grid-cols-2">
|
||||
<motion.div variants={fadeInUp} className="bg-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-dorado">
|
||||
<p className="text-xs tracking-[0.3em] uppercase font-black text-dorado">Sí</p>
|
||||
<Antetitulo>Sí</Antetitulo>
|
||||
<p className="mt-3 text-2xl font-sans font-bold text-azul-profundo max-w-sm">{paraQuien.lead}</p>
|
||||
<ul className="mt-8 space-y-4">
|
||||
{paraQuien.criteria.map((c) => (
|
||||
@@ -35,7 +36,7 @@ export function ParaQuien() {
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
<motion.div variants={fadeInUp} className="bg-azul-profundo text-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-white/10">
|
||||
<motion.div variants={fadeInUp} className="bg-azul-profundo bg-hondo-navy text-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-white/10">
|
||||
<p className="text-xs tracking-[0.3em] uppercase font-black text-white/50">No</p>
|
||||
<p className="mt-3 text-2xl font-sans font-bold text-white/70 max-w-sm">{paraQuien.leadNo}</p>
|
||||
<ul className="mt-8 space-y-4">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { Antetitulo } from '@/components/primitives/Antetitulo';
|
||||
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
|
||||
import { FondoFoto } from '@/components/primitives/FondoFoto';
|
||||
import { ParDialogo } from '@/components/primitives/ParDialogo';
|
||||
@@ -23,12 +24,12 @@ const TOTAL = items.length;
|
||||
// ever brings its own colour onto the page. Under the veils below they read as
|
||||
// texture, not as stock photography.
|
||||
const FOTOS = [
|
||||
'/images/recorrido/crecer.jpg', // a financial district still going up, cranes and all
|
||||
'/images/recorrido/consolidarse.jpg', // an empty meeting room: order before more weight
|
||||
'/images/recorrido/rentabilidad.jpg', // the harvest — effort actually turned into result
|
||||
'/images/recorrido/simplificar.jpg', // a trading screen too dense to read
|
||||
'/images/recorrido/expandirse.jpg', // a departures board: markets you have not flown to
|
||||
'/images/recorrido/portafolio.jpg', // a skyline of towers, every one of them a front
|
||||
'/images/recorrido/crecer.jpg', // Torre Mitikah, CDMX, still topped by its crane
|
||||
'/images/recorrido/consolidarse.jpg', // an office built in a hurry: boxes before order
|
||||
'/images/recorrido/rentabilidad.jpg', // the agave harvest — effort actually turned into result
|
||||
'/images/recorrido/simplificar.jpg', // overpasses stacked over overpasses, CDMX
|
||||
'/images/recorrido/expandirse.jpg', // Cancún's boarding gates: markets you have not flown to
|
||||
'/images/recorrido/portafolio.jpg', // Monterrey's skyline, every tower a front
|
||||
];
|
||||
|
||||
// Reduced motion, and the server's first paint, get the whole conversation laid
|
||||
@@ -37,10 +38,10 @@ const FOTOS = [
|
||||
// sixths of the copy behind a scroll position is not an option either way.
|
||||
function Estatica() {
|
||||
return (
|
||||
<section className="bg-gris-claro relative overflow-hidden">
|
||||
<section className="bg-gris-claro bg-hondo-claro relative overflow-hidden">
|
||||
<FondoPuntos className="-bottom-6 -right-12 w-80 md:w-[32rem] text-azul-profundo/10" distance={32} />
|
||||
<Container className="relative py-16 md:py-24">
|
||||
<h2 className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</h2>
|
||||
<Antetitulo as="h2">{puntosInflexion.lead}</Antetitulo>
|
||||
<ul className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
|
||||
{items.map((item, i) => (
|
||||
<ParDialogo key={item.name} n={i + 1} {...item} activo reducido onActivar={() => {}} />
|
||||
@@ -185,9 +186,9 @@ function Escenificado() {
|
||||
the recorrido its <h2>, and a section this size with no heading
|
||||
at all is a hole in the document outline — for a screen reader
|
||||
the six exchanges would hang off nothing. */}
|
||||
<h2 className="absolute top-24 left-6 md:left-12 text-xs tracking-[0.3em] uppercase font-black text-dorado md:top-28">
|
||||
<Antetitulo as="h2" sobre="oscuro" className="absolute top-24 left-6 md:left-12 md:top-28">
|
||||
{puntosInflexion.lead}
|
||||
</h2>
|
||||
</Antetitulo>
|
||||
|
||||
{/* The rail. Where you are in the conversation, and how much is
|
||||
left — a pinned section without it feels like a page that
|
||||
|
||||
@@ -21,3 +21,16 @@ describe('<Cierre />', () => {
|
||||
expect(screen.getByRole('navigation', { name: /contacto/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Cierre /> — pestañas', () => {
|
||||
it('opens WhatsApp and LinkedIn in a new tab and keeps email in place', () => {
|
||||
render(<Cierre />);
|
||||
for (const nombre of [/whatsapp/i, /linkedin/i]) {
|
||||
for (const a of screen.getAllByRole('link', { name: nombre })) {
|
||||
expect(a).toHaveAttribute('target', '_blank');
|
||||
expect(a).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
expect(screen.getByRole('link', { name: /email/i })).not.toHaveAttribute('target');
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,12 @@ import { ComoEmpezar } from '../ComoEmpezar';
|
||||
describe('<ComoEmpezar />', () => {
|
||||
it('renders the on-ramp steps', () => {
|
||||
render(<ComoEmpezar />);
|
||||
expect(screen.getByText('Escríbeme')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sesión de diagnóstico')).toBeInTheDocument();
|
||||
expect(screen.getByText('Escríbenos')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sesión de claridad')).toBeInTheDocument();
|
||||
});
|
||||
it('links the CTA to WhatsApp with a prefilled message', () => {
|
||||
render(<ComoEmpezar />);
|
||||
const cta = screen.getByRole('link', { name: /Agenda tu sesión de diagnóstico/i });
|
||||
const cta = screen.getByRole('link', { name: /Agenda tu sesión de claridad/i });
|
||||
const href = cta.getAttribute('href') ?? '';
|
||||
expect(href).toMatch(/^https:\/\/wa\.me\//);
|
||||
expect(href).toContain('text=');
|
||||
|
||||
@@ -5,11 +5,18 @@ import { Estratega } from '../Estratega';
|
||||
describe('<Estratega />', () => {
|
||||
it('renders the bio', () => {
|
||||
render(<Estratega />);
|
||||
expect(screen.getByText(/30 años/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/años construyendo marcas/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Hemos acompañado a más de 40 empresas/i)).toBeInTheDocument();
|
||||
});
|
||||
it('shows the confirmed figure — 25+, never the stale 30', () => {
|
||||
// La dueña confirmó 25+ (19/ago/2026); el 30 fue un borrador que
|
||||
// sobrevivió como hardcode después de que el copy pasó a 25.
|
||||
const { container } = render(<Estratega />);
|
||||
expect(container.textContent).not.toMatch(/30/);
|
||||
});
|
||||
it('renders the institutional quote', () => {
|
||||
render(<Estratega />);
|
||||
expect(screen.getByText(/El tiempo no se compra/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Tu negocio ya demostró que funciona/i)).toBeInTheDocument();
|
||||
});
|
||||
it('never lets CSS uppercase turn the brand name into CREEER', () => {
|
||||
const { container } = render(<Estratega />);
|
||||
|
||||
@@ -9,6 +9,11 @@ describe('<FraseAncla />', () => {
|
||||
expect(container.textContent).toMatch(/falta de claridad/i);
|
||||
});
|
||||
|
||||
it('paints the "after": the anchor names the problem, this line names the outcome', () => {
|
||||
const { container } = render(<FraseAncla />);
|
||||
expect(container.textContent).toMatch(/Sabes exactamente dónde invertir/i);
|
||||
});
|
||||
|
||||
it('resolves the brand metaphor into the mark', () => {
|
||||
const { container } = render(<FraseAncla />);
|
||||
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(3);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Hero } from '../Hero';
|
||||
describe('<Hero />', () => {
|
||||
it('renders the eyebrow', () => {
|
||||
render(<Hero variant="editorial" />);
|
||||
expect(screen.getByText(/tu estratega de crecimiento/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/claridad estratégica para tu negocio/i)).toBeInTheDocument();
|
||||
});
|
||||
it('renders the headline copy', () => {
|
||||
const { container } = render(<Hero variant="hormozi" />);
|
||||
@@ -15,11 +15,11 @@ describe('<Hero />', () => {
|
||||
});
|
||||
it('renders the CTA', () => {
|
||||
render(<Hero variant="editorial" />);
|
||||
expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toHaveAttribute('href', '#contacto');
|
||||
expect(screen.getByRole('link', { name: /agenda una sesión de claridad/i })).toHaveAttribute('href', '#contacto');
|
||||
});
|
||||
it('offers a secondary route into the process for readers not ready to convert', () => {
|
||||
render(<Hero variant="editorial" />);
|
||||
expect(screen.getByRole('link', { name: /ver cómo trabajo/i })).toHaveAttribute('href', '#mecanismo');
|
||||
expect(screen.getByRole('link', { name: /ver cómo trabajamos/i })).toHaveAttribute('href', '#proceso');
|
||||
});
|
||||
it('does NOT render scarcity (cupos/cohorte)', () => {
|
||||
const { container } = render(<Hero variant="editorial" />);
|
||||
@@ -33,13 +33,40 @@ describe('<Hero />', () => {
|
||||
expect(container.textContent).not.toMatch(/Trabajo más y gano casi lo mismo/i);
|
||||
});
|
||||
|
||||
it('poses the brand metaphor without resolving it', () => {
|
||||
// El hero es el LCP de la página, y la página se abre desde un enlace de
|
||||
// WhatsApp con datos móviles. Mientras el titular naciera en opacity 0
|
||||
// esperando a que hidratara React, montara el IntersectionObserver y corriera
|
||||
// el stagger, lo primero que veía el lector era una pantalla en blanco — y si
|
||||
// el JS fallaba, se quedaba así. El export estático llegó a salir con 126
|
||||
// elementos en opacity 0, el <h1> entre ellos.
|
||||
it('entra pintado: nada del hero nace oculto', () => {
|
||||
const { container } = render(<Hero variant="editorial" />);
|
||||
// SecuenciaClaridad is the only thing here drawn from <line>; the dot
|
||||
// fields are circles. variant="hero" plays dispersion and convergence
|
||||
// only — the ≡ resolution is FraseAncla's payoff, and a hero that
|
||||
// resolved would spend the beat before the copy earns it.
|
||||
expect(container.querySelectorAll('line').length).toBeGreaterThan(10);
|
||||
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(0);
|
||||
const ocultos = Array.from(container.querySelectorAll<HTMLElement>('*')).filter(
|
||||
(el) => el.style.opacity === '0',
|
||||
);
|
||||
expect(ocultos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('el titular no se difumina palabra a palabra', () => {
|
||||
const { container } = render(<Hero variant="editorial" />);
|
||||
const difuminados = Array.from(container.querySelectorAll<HTMLElement>('*')).filter((el) =>
|
||||
el.style.filter.includes('blur'),
|
||||
);
|
||||
expect(difuminados).toHaveLength(0);
|
||||
});
|
||||
|
||||
// El edificio es three.js: ~185 kB que el titular no puede esperar. Vive tras
|
||||
// next/dynamic con ssr:false, así que el hero se pinta entero sin él y el
|
||||
// export estático no lleva ni un <canvas>. Cuando el dynamic() vivía en el
|
||||
// propio Hero — un server component — Next 14 se negaba a construir.
|
||||
it('el hero se pinta sin esperar al edificio', () => {
|
||||
const { container } = render(<Hero variant="editorial" />);
|
||||
expect(container.querySelector('h1')).toBeInTheDocument();
|
||||
expect(container.querySelector('canvas')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Dream Outcome below the headline', () => {
|
||||
const { container } = render(<Hero variant="editorial" />);
|
||||
expect(container.textContent).toMatch(/plan a 12 meses/i);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Mecanismo } from '../Mecanismo';
|
||||
|
||||
describe('<Mecanismo />', () => {
|
||||
it('renders the 3 steps in order', () => {
|
||||
render(<Mecanismo />);
|
||||
expect(screen.getByText('Inmersión')).toBeInTheDocument();
|
||||
expect(screen.getByText('Desafiar supuestos')).toBeInTheDocument();
|
||||
expect(screen.getByText('Estrategia ejecutable')).toBeInTheDocument();
|
||||
});
|
||||
it('renders step numbers 01/02/03', () => {
|
||||
const { container } = render(<Mecanismo />);
|
||||
expect(container.textContent).toMatch(/01.+02.+03/);
|
||||
});
|
||||
it('keeps the background photo subtle enough not to fight the text', () => {
|
||||
const { container } = render(<Mecanismo />);
|
||||
const bg = container.querySelector('img');
|
||||
expect(bg).toHaveAttribute('src', '/images/mecanismo-bg.jpg');
|
||||
expect(bg?.closest('[class*="opacity-"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
describe('auto-advancing step indicator', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('highlights the first step by default, then advances on its own', () => {
|
||||
render(<Mecanismo />);
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
|
||||
act(() => { vi.advanceTimersByTime(3300); });
|
||||
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('stops advancing while the pointer is over the steps', () => {
|
||||
render(<Mecanismo />);
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
fireEvent.mouseEnter(screen.getByRole('tablist'));
|
||||
act(() => { vi.advanceTimersByTime(6000); });
|
||||
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,11 @@ describe('<ParaQuien />', () => {
|
||||
expect(screen.getByText(/necesitas claridad/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/sin perder tu esencia/i)).toBeInTheDocument();
|
||||
});
|
||||
it('renders the disqualifying criteria from the dueña deck', () => {
|
||||
it('renders the disqualifying criteria', () => {
|
||||
render(<ParaQuien />);
|
||||
expect(screen.getByText(/Esto no es para ti si/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/fase inicial o sin tracción/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/solución rápida de venta/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/solución rápida, no un cambio de fondo/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/un curso, no una transformación/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/crisis de supervivencia/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Proceso } from '../Proceso';
|
||||
import { proceso, creditosFotos } from '@/lib/content';
|
||||
|
||||
describe('<Proceso />', () => {
|
||||
it('renders the three approved phases as a strip', () => {
|
||||
render(<Proceso />);
|
||||
expect(proceso.fases).toHaveLength(3);
|
||||
for (const fase of proceso.fases) {
|
||||
expect(screen.getByText(fase)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders all eight stages, numbered 01 through 08', () => {
|
||||
render(<Proceso />);
|
||||
expect(proceso.etapas).toHaveLength(8);
|
||||
for (const etapa of proceso.etapas) {
|
||||
expect(screen.getByText(etapa.name)).toBeInTheDocument();
|
||||
expect(screen.getByText(etapa.n)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
// The argument of the section: "claridad estratégica" is abstract until it
|
||||
// is a document you can hold. The lámina labels this block on all eight of
|
||||
// its cards and marks every line — losing either turns the deliverables back
|
||||
// into a list of service names.
|
||||
it('labels the deliverables block on every card and marks every line', () => {
|
||||
const { container } = render(<Proceso />);
|
||||
expect(screen.getAllByText(proceso.etiquetaEntregables)).toHaveLength(8);
|
||||
|
||||
for (const entregable of proceso.etapas.flatMap((e) => e.entregables)) {
|
||||
expect(screen.getByText(entregable)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// One <ul> of deliverables per stage, every line of it carrying an icon.
|
||||
const listas = container.querySelectorAll('ul');
|
||||
expect(listas).toHaveLength(proceso.etapas.length);
|
||||
listas.forEach((lista, i) => {
|
||||
const esperados = proceso.etapas[i].entregables.length;
|
||||
expect(lista.querySelectorAll(':scope > li')).toHaveLength(esperados);
|
||||
expect(lista.querySelectorAll(':scope > li > svg')).toHaveLength(esperados);
|
||||
});
|
||||
});
|
||||
|
||||
// The icon map is keyed by the copy itself, so rewording a deliverable in
|
||||
// lib/content.ts without updating the map would render a blank space where
|
||||
// its mark should be. Nothing else would fail.
|
||||
it('gives every deliverable its own icon, none of them empty', () => {
|
||||
const { container } = render(<Proceso />);
|
||||
const items = Array.from(container.querySelectorAll('ul > li'));
|
||||
expect(items).toHaveLength(proceso.etapas.reduce((n, e) => n + e.entregables.length, 0));
|
||||
for (const item of items) {
|
||||
const svg = item.querySelector(':scope > svg');
|
||||
expect(svg, `«${item.textContent}» se quedó sin ícono`).not.toBeNull();
|
||||
expect(svg!.children.length, `el ícono de «${item.textContent}» está vacío`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Stage 01's medallion was the magnifier, and so was its own "Diagnóstico
|
||||
// ejecutivo" line — the same glyph twice in one card, which reads as a
|
||||
// mistake. Nothing but the eye catches this, so the eye gets help.
|
||||
it('never repeats a glyph inside the same card', () => {
|
||||
const { container } = render(<Proceso />);
|
||||
const tarjetas = Array.from(container.querySelectorAll('ol'))[1].querySelectorAll(':scope > li');
|
||||
tarjetas.forEach((tarjeta, i) => {
|
||||
const glifos = Array.from(tarjeta.querySelectorAll('svg')).map((s) => s.innerHTML);
|
||||
expect(new Set(glifos).size, `la etapa ${proceso.etapas[i].n} repite un ícono`).toBe(glifos.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on the One Year Plan, the payoff the other seven point at', () => {
|
||||
render(<Proceso />);
|
||||
expect(screen.getByText('One Year Plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roadmap a 12 meses')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gobernanza del crecimiento')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The stages are a sequence, so the order has to reach assistive tech
|
||||
// through the markup — otherwise it lives only in the drawn number, which is
|
||||
// decorative and never announced.
|
||||
it('carries the order in the markup, not just in the drawn number', () => {
|
||||
const { container } = render(<Proceso />);
|
||||
const listas = container.querySelectorAll('ol');
|
||||
expect(listas).toHaveLength(2); // the phase strip and the eight stages
|
||||
expect(listas[1].querySelectorAll(':scope > li')).toHaveLength(8);
|
||||
});
|
||||
|
||||
// The footer is the only place in the project where the photo licences are
|
||||
// honoured, and it renders `creditosFotos` verbatim. An image added here
|
||||
// without an entry there would go uncredited and nothing else would notice.
|
||||
it('credits every photograph it shows', () => {
|
||||
const { container } = render(<Proceso />);
|
||||
const fotos = Array.from(container.querySelectorAll('img')).map((i) => i.getAttribute('src'));
|
||||
expect(fotos).toHaveLength(proceso.etapas.length);
|
||||
for (const src of fotos) {
|
||||
expect(
|
||||
creditosFotos.some((c) => 'archivo' in c && c.archivo === src),
|
||||
`«${src}» no tiene crédito en creditosFotos`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('loads the photographs lazily, since none of them is above the fold', () => {
|
||||
const { container } = render(<Proceso />);
|
||||
for (const img of Array.from(container.querySelectorAll('img'))) {
|
||||
expect(img).toHaveAttribute('loading', 'lazy');
|
||||
}
|
||||
});
|
||||
|
||||
// Everything here is adapted from the dueña's working deck for a real
|
||||
// client. None of that client's detail may reach a public page.
|
||||
it('leaks nothing that identifies the client the deck was built for', () => {
|
||||
const { container } = render(<Proceso />);
|
||||
expect(container.textContent).not.toMatch(
|
||||
/cápsulas|gomitas|tabletas|planta|sell in|sell out|mercado mexicano|líderes de la categoría/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,15 @@ describe('<PuntosInflexion />', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('never lets creeer answer by repeating the situation back', () => {
|
||||
// The restructure of lib/content.ts once aliased `respuesta` to
|
||||
// `situacion`: problem and answer read the same, and the test above still
|
||||
// passed because the situation renders inside «».
|
||||
for (const { situacion, respuesta } of puntosInflexion.items) {
|
||||
expect(respuesta).not.toBe(situacion);
|
||||
}
|
||||
});
|
||||
|
||||
it('states each situation in the second person, as the dueña reads them', () => {
|
||||
// Her own phrasing in the 31/jul call: "estás atrapado en tu compañía",
|
||||
// "creciste muy rápido pero quieres expandirte". The deck's infinitives
|
||||
|
||||
@@ -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.
|
||||
@@ -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-]+\/?$/);
|
||||
});
|
||||
});
|
||||
@@ -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(?!-)/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -11,160 +11,199 @@ export const brandPillars = {
|
||||
pillar3: "Crecimiento",
|
||||
};
|
||||
|
||||
export const hero = {
|
||||
eyebrow: '— tu estratega de crecimiento —',
|
||||
// Headline broken into segments for emphasis styling
|
||||
// ── 01 · Apertura ──────────────────────────────────────────────────────────
|
||||
export const apertura = {
|
||||
numero: '01',
|
||||
eyebrow: 'claridad estratégica para tu negocio',
|
||||
headline: {
|
||||
line1: 'Tu negocio no necesita',
|
||||
effort: 'más esfuerzo.', // gold (HV-1) or gold italic (V-a)
|
||||
acento: 'más esfuerzo.',
|
||||
line3: 'Necesita',
|
||||
clarity: 'más claridad.', // underlined gold
|
||||
cierre: 'más claridad.',
|
||||
},
|
||||
cta: 'Define tu siguiente movimiento',
|
||||
ctaSecundario: 'Ver cómo trabajo',
|
||||
quien: 'Claridad estratégica para empresarios con tracción que llegaron a su punto de inflexión.',
|
||||
// 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 = {
|
||||
// Quote split into parts so the second clause can render in gold.
|
||||
quote: {
|
||||
before: 'Las empresas no dejan de crecer por falta de talento;',
|
||||
accent: 'dejan de crecer por falta de claridad.', // gold
|
||||
},
|
||||
};
|
||||
// ── 02 · Dónde estás ───────────────────────────────────────────────────────
|
||||
// Los seis puntos y los cuatro dolores son de las diapositivas 2 y 3 del deck
|
||||
// de la dueña. Verbatim. `respuesta` desaparece: existía para llenar seis
|
||||
// pantallas fijadas y el recorrido ya no existe.
|
||||
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 ·
|
||||
// Propuesta Socio" (slide 2). The deck states each one in the infinitive
|
||||
// ("Dar el siguiente salto sin saber por dónde"); here it is stated in the
|
||||
// second person, the way she reads them aloud herself — "estás atrapado en tu
|
||||
// compañía", "creciste muy rápido pero quieres expandirte" (call of 31/jul,
|
||||
// 6:01–7:15). `respuesta` is new copy and still needs her sign-off; 01 and 05
|
||||
// are the two exchanges she improvised on that call, written out.
|
||||
//
|
||||
// `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: [
|
||||
export const dondeEstas = {
|
||||
numero: '02',
|
||||
eyebrow: 'dónde estás',
|
||||
title: 'Tu negocio llegó a un punto de inflexión',
|
||||
sumario: 'Seis situaciones. Reconoce la tuya.',
|
||||
puntos: PUNTOS,
|
||||
etiquetaDolores: 'Y por dentro suena así',
|
||||
dolores: [
|
||||
'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.',
|
||||
'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.',
|
||||
],
|
||||
// Split so the resolution renders in gold.
|
||||
remate: {
|
||||
before: 'Esto no se resuelve con más esfuerzo.',
|
||||
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?',
|
||||
lead: 'Esto es para ti si…',
|
||||
criteria: [
|
||||
'tienes tracción y ya demostraste que sabes crecer',
|
||||
'trabajar más ya no rinde como antes',
|
||||
'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…',
|
||||
criteriaNo: [
|
||||
'el negocio está en fase inicial o sin tracción',
|
||||
'buscas una solución rápida de venta',
|
||||
'buscas un curso, no una transformación',
|
||||
'la empresa está en crisis de supervivencia, no en transición',
|
||||
],
|
||||
si: {
|
||||
lead: 'Esto es para ti si…',
|
||||
criterios: [
|
||||
'tienes tracción y ya demostraste que sabes crecer',
|
||||
'trabajar más ya no rinde como antes',
|
||||
'necesitas claridad para decidir el siguiente movimiento',
|
||||
'quieres profesionalizar sin perder tu esencia',
|
||||
],
|
||||
},
|
||||
no: {
|
||||
lead: 'Esto no es para ti si…',
|
||||
criterios: [
|
||||
'el negocio está en fase inicial o sin tracción',
|
||||
'buscas una solución rápida, no un cambio de fondo',
|
||||
'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 = {
|
||||
eyebrow: 'el mecanismo único',
|
||||
title: 'Cómo trabajo',
|
||||
steps: [
|
||||
{ n: '01', name: 'Inmersión', desc: 'Me sumerjo en tu empresa hasta comprenderla desde dentro.' },
|
||||
{ n: '02', name: 'Desafiar supuestos', desc: 'Cuestiono lo que todos dan por hecho y frenan el crecimiento.' },
|
||||
{ n: '03', name: 'Estrategia ejecutable', desc: 'Construimos, juntos, una estrategia que puedas ejecutar.' },
|
||||
// ── 06 · Empezar ───────────────────────────────────────────────────────────
|
||||
export const empezar = {
|
||||
numero: '06',
|
||||
eyebrow: 'empezar',
|
||||
title: 'Tu siguiente movimiento empieza con una conversación.',
|
||||
pasos: [
|
||||
{ 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
|
||||
// working with creeer. Sits between Mecanismo (the high-level HOW) and
|
||||
// Disciplinas (the tools). All copy derived from approved sources:
|
||||
// mecanismo steps, puntosInflexion respuestas, comoEmpezar.
|
||||
// ⚠️ «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 formaTrabajar = {
|
||||
eyebrow: 'qué esperar',
|
||||
title: 'Así es trabajar conmigo',
|
||||
items: [
|
||||
{
|
||||
name: 'Metodología',
|
||||
desc: 'Inmersión en tu empresa, desafío de lo que todos dan por hecho y construcción conjunta de tu estrategia.',
|
||||
},
|
||||
{
|
||||
name: 'Quién participa',
|
||||
desc: 'Tú y yo. Sin intermediarios. Trabajo directo con quien toma las decisiones.',
|
||||
},
|
||||
{
|
||||
name: 'De dónde partimos',
|
||||
desc: 'Tu operación real: números, procesos, equipo. Me sumerjo hasta comprenderla desde dentro.',
|
||||
},
|
||||
{
|
||||
name: 'Qué te llevas',
|
||||
desc: 'Un diagnóstico claro, tus prioridades ordenadas y una estrategia ejecutable. Por escrito, no en tu cabeza.',
|
||||
},
|
||||
{
|
||||
name: 'Duración',
|
||||
desc: 'Cada proceso tiene su ritmo. Lo definimos juntos en la sesión de diagnóstico.',
|
||||
},
|
||||
],
|
||||
// ── Alias de compatibilidad ────────────────────────────────────────────────
|
||||
// Los consumen las diez secciones viejas mientras conviven con las seis
|
||||
// nuevas. Derivan de los objetos de arriba, nunca duplican sus cadenas, y se
|
||||
// van enteros en la limpieza final. No añadir usos nuevos.
|
||||
|
||||
export const hero = {
|
||||
eyebrow: `— ${apertura.eyebrow} —`,
|
||||
headline: {
|
||||
line1: apertura.headline.line1,
|
||||
effort: apertura.headline.acento,
|
||||
line3: apertura.headline.line3,
|
||||
clarity: apertura.headline.cierre,
|
||||
},
|
||||
cta: apertura.cta,
|
||||
ctaSecundario: apertura.ctaSecundario,
|
||||
};
|
||||
|
||||
// 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
|
||||
// `dondeEstas`), pero mientras la sección vieja siga en la página, pregunta y
|
||||
// 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 = [
|
||||
'Nos sumergimos hasta entender tu empresa desde dentro. Sales sabiendo por dónde, no intentándolo por todos lados a la vez.',
|
||||
'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.',
|
||||
'Revisamos tu proceso y ubicamos la oportunidad real antes de mover una ficha. Expandirte deja de ser una apuesta.',
|
||||
'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 = {
|
||||
eyebrow: 'las herramientas',
|
||||
title: 'Las disciplinas que uso',
|
||||
title: 'Las disciplinas que usamos',
|
||||
lead: 'No son la promesa — son los medios.',
|
||||
items: [
|
||||
{ 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 = {
|
||||
// Split so the brand name keeps its lowercase inside an uppercase eyebrow —
|
||||
// CSS text-transform would otherwise render it as CREEER.
|
||||
eyebrow: { before: 'la mente detrás de', brand: 'creeer' },
|
||||
title: 'Sobre la estratega',
|
||||
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.',
|
||||
eyebrow: { before: 'quiénes estamos detrás de', brand: 'creeer' },
|
||||
title: paraQuienQuien.quien.title,
|
||||
bio: paraQuienQuien.quien.bio,
|
||||
quote: paraQuienQuien.quien.quote,
|
||||
};
|
||||
|
||||
export const comoEmpezar = {
|
||||
eyebrow: 'cómo empezar',
|
||||
title: 'Tu siguiente movimiento empieza con una conversación.',
|
||||
steps: [
|
||||
{ n: '01', name: 'Escríbeme', desc: 'Cuéntame dónde está tu negocio hoy.' },
|
||||
{ n: '02', name: 'Sesión de diagnóstico', 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 diagnóstico',
|
||||
whatsappText: 'Hola, quiero agendar una sesión de diagnóstico',
|
||||
title: empezar.title,
|
||||
steps: empezar.pasos,
|
||||
cta: empezar.cta,
|
||||
whatsappText: empezar.whatsappText,
|
||||
};
|
||||
|
||||
export const cierre = {
|
||||
quote: 'Devuélvele claridad al rumbo de tu empresa.',
|
||||
cta: 'Define tu siguiente movimiento',
|
||||
contact: {
|
||||
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
|
||||
},
|
||||
quote: empezar.quote,
|
||||
cta: apertura.cta,
|
||||
contact: empezar.contacto,
|
||||
};
|
||||
|
||||
export const fraseAncla = { quote: queCambia.ancla, despues: queCambia.despues };
|
||||
|
||||
export const header = {
|
||||
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
|
||||
// six in `recorrido` are additionally cropped and resized.
|
||||
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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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/' },
|
||||
];
|
||||
@@ -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';
|
||||
@@ -64,7 +64,7 @@ export const motionProps = (reduced: boolean) =>
|
||||
? { 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
|
||||
// advances at all for reduced-motion users.
|
||||
export function useAutoStep(count: number, intervalMs = 3500) {
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react-dom": "^18",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -25,6 +27,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/three": "^0.185.4",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.35",
|
||||
|
||||
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
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:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(embla-carousel@8.6.0)
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18
|
||||
version: 18.3.1(react@18.3.1)
|
||||
three:
|
||||
specifier: ^0.185.1
|
||||
version: 0.185.1
|
||||
devDependencies:
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
@@ -45,6 +51,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^18
|
||||
version: 18.3.7(@types/react@18.3.31)
|
||||
'@types/three':
|
||||
specifier: ^0.185.4
|
||||
version: 0.185.4
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(vite@5.4.21(@types/node@20.19.43))
|
||||
@@ -197,6 +206,9 @@ packages:
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0':
|
||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -489,6 +501,31 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
@@ -673,6 +710,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@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':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
|
||||
@@ -708,9 +748,26 @@ packages:
|
||||
peerDependencies:
|
||||
'@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':
|
||||
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':
|
||||
resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1039,6 +1096,9 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.10.44:
|
||||
resolution: {integrity: sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -1067,6 +1127,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
@@ -1476,6 +1539,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-entry-cache@6.0.1:
|
||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
@@ -1652,6 +1718,9 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -1819,6 +1888,11 @@ packages:
|
||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
its-fine@1.2.5:
|
||||
resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==}
|
||||
peerDependencies:
|
||||
react: '>=18.0'
|
||||
|
||||
jackspeak@2.3.6:
|
||||
resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1936,6 +2010,9 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
meshoptimizer@1.1.1:
|
||||
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
@@ -2254,10 +2331,25 @@ packages:
|
||||
react-is@18.3.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2340,6 +2432,9 @@ packages:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.21.0:
|
||||
resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
@@ -2500,6 +2595,11 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
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:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
@@ -2518,6 +2618,9 @@ packages:
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
three@0.185.1:
|
||||
resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -2778,6 +2881,15 @@ packages:
|
||||
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
||||
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:
|
||||
|
||||
'@adobe/css-tools@4.5.0': {}
|
||||
@@ -2926,6 +3038,8 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -3135,6 +3249,26 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
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': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||
@@ -3260,6 +3394,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3302,11 +3438,32 @@ snapshots:
|
||||
dependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.15
|
||||
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)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -3646,6 +3803,8 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.44: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
@@ -3675,6 +3834,11 @@ snapshots:
|
||||
node-releases: 2.0.51
|
||||
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:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
@@ -4256,6 +4420,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-entry-cache@6.0.1:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
@@ -4446,6 +4612,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.6: {}
|
||||
@@ -4613,6 +4781,13 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
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:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
@@ -4734,6 +4909,8 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
meshoptimizer@1.1.1: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
@@ -5039,8 +5216,20 @@ snapshots:
|
||||
|
||||
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-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:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -5168,6 +5357,10 @@ snapshots:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.21.0:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -5359,6 +5552,10 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
tailwindcss@3.4.19:
|
||||
@@ -5399,6 +5596,8 @@ snapshots:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
three@0.185.1: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
@@ -5692,3 +5891,7 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-queue@1.2.2: {}
|
||||
|
||||
zustand@3.7.2(react@18.3.1):
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
Before Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -10,6 +10,14 @@ const config: Config = {
|
||||
'azul-profundo': '#0A1D3D', // Pantone 2965 C — texto, fondos oscuros, CTAs
|
||||
'dorado': {
|
||||
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
|
||||
'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.
|
||||
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: {
|
||||
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: {
|
||||
// Two title roles, so a brand statement never looks like a section
|
||||
// heading. Fluid so they hold from 360px to desktop without a stack of
|
||||
// breakpoint overrides.
|
||||
// Cinco roles. `display` se gasta UNA sola vez en toda la página.
|
||||
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' }],
|
||||
// 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' }],
|
||||
seccion: ['clamp(2rem, 4vw, 3.25rem)', { lineHeight: '1.02', letterSpacing: '-0.02em' }],
|
||||
},
|
||||
|
||||