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

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

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

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

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import type { ElementType, ReactNode } from 'react';
// The short gold line in letter-spaced caps that announces each section.
//
// It used to be hand-written in eight places, and the colour travelled with it:
// `text-dorado` reads at 6,07:1 on azul-profundo and at 2,76:1 on white, so
// half of those copies were illegible and every fix had to be made eight times.
// The rule lives here now, and the default is the safe one — forgetting the
// prop cannot produce an unreadable antetítulo.
//
// `as` takes `motion.p` where the section wants it to animate in, and `h2`
// where the antetítulo is the only heading the section has.
type Fondo = 'claro' | 'oscuro';
const tono: Record<Fondo, string> = {
claro: 'text-dorado-texto',
oscuro: 'text-dorado',
};
export function Antetitulo({
children,
sobre = 'claro',
as: Tag = 'p',
className = '',
...rest
}: {
children: ReactNode;
sobre?: Fondo;
as?: ElementType;
className?: string;
// Whatever the caller's `as` needs — in practice Framer's variants/viewport
// props when a section wants its antetítulo to animate in.
[prop: string]: unknown;
}) {
return (
<Tag className={['text-rotulo uppercase font-semibold', tono[sobre], className].filter(Boolean).join(' ')} {...rest}>
{children}
</Tag>
);
}
+7 -1
View File
@@ -1,9 +1,13 @@
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
type Variant = 'light' | 'dark' | 'ghost';
// Every variant carries border-2 so filled and outlined CTAs line up when
// they sit side by side.
const styles: Record<Variant, string> = {
light: 'border-2 border-transparent bg-azul-profundo text-white hover:bg-azul-profundo/90',
dark: 'border-2 border-transparent bg-dorado text-white hover:bg-dorado/90',
// Azul profundo sobre dorado, no blanco: blanco sobre #C4942E es 2,76:1 y
// esta es la variante del botón que cierra la página.
dark: 'border-2 border-transparent bg-dorado text-azul-profundo hover:bg-dorado/90',
ghost: 'border-2 border-azul-profundo text-azul-profundo hover:bg-azul-profundo hover:text-white',
};
@@ -11,10 +15,12 @@ export function CTA({ href, label, variant = 'light', className = '' }: { href:
return (
<a
href={href}
{...propsEnlace(href)}
className={`group inline-block font-sans font-bold uppercase tracking-wider px-7 py-3.5 text-sm transition-colors ${styles[variant]} ${className}`}
>
{label}{' '}
<span aria-hidden="true" className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transform-none motion-reduce:transition-none">→</span>
{esExterno(href) && <span className="sr-only"> ({AVISO_PESTANA})</span>}
</a>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
// The building's public door, and the only file a section should import.
//
// three.js is around 185 kB gzipped and the hero is the LCP — the page opens
// from a WhatsApp link on mobile data, so the headline, the CTAs and the Dream
// Outcome have to be painted and clickable long before any of this arrives.
// `dynamic` holds the scene back until after hydration, and `ssr: false` is not
// optional: three.js needs a real canvas, which the static export does not have
// at build time.
//
// The `dynamic` call lives here, behind 'use client', because Next 14 refuses
// `ssr: false` inside a Server Component — and `Hero` is one, deliberately: it
// is almost all text and has no reason to ship as JavaScript.
const EscenaClaridad = dynamic(
() => import('@/components/primitives/EscenaClaridad').then((m) => m.EscenaClaridad),
{ ssr: false, loading: () => null },
);
export function ClaridadHero({ className = '' }: { className?: string }) {
const contenedor = useRef<HTMLDivElement>(null);
const [cerca, setCerca] = useState(false);
// On a phone the drawing sits under the two CTAs, a screen and a half down,
// and plenty of readers tap through without ever reaching it. Deferring to
// hydration is not enough: the chunk waits until the box is nearly in view,
// the same deal the six photos of the recorrido got. On a laptop the box is
// beside the headline, so this resolves on the first observation and costs
// nothing.
useEffect(() => {
const el = contenedor.current;
if (!el) return;
if (typeof IntersectionObserver === 'undefined') {
setCerca(true);
return;
}
const obs = new IntersectionObserver(
([entrada]) => {
if (!entrada.isIntersecting) return;
setCerca(true);
obs.disconnect();
},
{ rootMargin: '300px' },
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div ref={contenedor} className={className} aria-hidden="true">
{cerca ? <EscenaClaridad className="w-full h-full" /> : null}
</div>
);
}
+255 -9
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
// Set of simple line icons — one per concept used across Mecanismo,
// Set of simple line icons — one per concept used across Proceso,
// ComoEmpezar and Disciplinas. Same stroke weight and geometric language as
// the manual's own iconography guidance ("líneas simples, geométricas,
// consistentes en grosor"), hand-drawn to match each step's specific idea
@@ -16,10 +16,39 @@ export type IconoNombre =
| 'comunicacion'
| 'digital'
| 'experiencias'
| 'reloj';
| 'consumidor'
| 'ventas'
| 'compania'
// Entregables — one mark per deliverable, the way the lámina gives every
// line its own.
| 'matriz'
| 'prioridades'
| 'rayo'
| 'mapa'
| 'restriccion'
| 'brote'
| 'segmentos'
| 'ficha'
| 'recorrido'
| 'hueco'
| 'diana'
| 'arquitectura'
| 'libro'
| 'megafono'
| 'calendario'
| 'ondas'
| 'documento'
| 'medidor'
| 'tablero'
| 'comite'
| 'hitos'
| 'cohete'
| 'bombilla'
| 'anio'
| 'escudo';
const PATHS: Record<IconoNombre, ReactNode> = {
// A line dives through two layers down to a core point — "me sumerjo
// A line dives through two layers down to a core point — "nos sumergimos
// hasta comprenderla desde dentro".
inmersion: (
<>
@@ -30,7 +59,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<circle cx="12" cy="20.5" r="0.75" fill="currentColor" stroke="none" />
</>
),
// A circle (the assumption) cracked by a bolt — "cuestiono lo que todos
// A circle (the assumption) cracked by a bolt — "cuestionamos lo que todos
// dan por hecho".
desafiar: (
<>
@@ -46,7 +75,7 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<path d="M20 6h-5l2.5-3.5L20 6" fill="currentColor" stroke="none" />
</>
),
// A speech bubble mid-thought — "cuéntame dónde está tu negocio hoy".
// A speech bubble mid-thought — "cuéntanos dónde está tu negocio hoy".
escribeme: (
<>
<path d="M4 5.5h16v10H10l-4 3.5v-3.5H4z" />
@@ -102,15 +131,232 @@ const PATHS: Record<IconoNombre, ReactNode> = {
<circle cx="12" cy="12" r="9" strokeOpacity={0.55} />
</>
),
// Simple clock — "cada proceso tiene su ritmo".
reloj: (
// A person with the insight sitting inside the head, not beside it — the
// point of Consumer Knowledge is what you find in there.
consumidor: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3.5 3.5" />
<circle cx="12" cy="7.5" r="3.75" />
<circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none" />
<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
</>
),
// Three rising bars under an arrow — sell in, sell out, rotación.
ventas: (
<>
<path d="M3.5 20.5h17" />
<path d="M7 20.5v-4.5" />
<path d="M12 20.5v-8" />
<path d="M17 20.5v-9" />
<path d="m17 4 2.4 4h-4.8z" fill="currentColor" stroke="none" />
<path d="M17 8v3.5" />
</>
),
// Two towers — the company seen from outside. Stage 01 needs its own mark
// because "Diagnóstico ejecutivo", one of its own deliverables, already has
// the magnifier, and the two would sit repeated in the same card.
compania: (
<>
<path d="M3 20.5h18" />
<path d="M5.5 20.5V5.5l6.5-3v18" />
<path d="m12 10.5 6.5 2.8v7.2" />
<circle cx="8.3" cy="9" r=".75" fill="currentColor" stroke="none" />
<circle cx="8.3" cy="13.5" r=".75" fill="currentColor" stroke="none" />
<circle cx="15.3" cy="16" r=".75" fill="currentColor" stroke="none" />
</>
),
// ── Entregables ────────────────────────────────────────────────────────
// Deliberately plainer than the concept icons above: these render at 18px
// inside the deliverables list, where anything busier turns to mush.
// Four quadrants — the SWOT grid itself.
matriz: (
<>
<rect x="3.5" y="3.5" width="17" height="17" rx="1.5" />
<path d="M12 3.5v17M3.5 12h17" />
</>
),
// Three rules, longest first — a ranked list.
prioridades: (
<>
<path d="M4 6.5h16" />
<path d="M4 12h10.5" />
<path d="M4 17.5h5.5" />
</>
),
// A bolt — the win you can take this quarter.
rayo: <path d="M13.5 2.5 5 13.5h5.5l-1 8L19 10h-6z" />,
// Folded map.
mapa: (
<>
<path d="m3.5 6.5 5.5-2.5 6 2.5 5.5-2.5v13l-5.5 2.5-6-2.5-5.5 2.5z" />
<path d="M9 4v15M15 6.5v13" />
</>
),
// A funnel — where the business narrows.
restriccion: <path d="M3.5 4.5h17l-6.3 7.6v7.4l-4.4 2.5V12.1z" />,
// A sprout.
brote: (
<>
<path d="M12 21v-8.5" />
<path d="M12 12.5c0-3 2.2-5.2 5.2-5.2 0 3-2.2 5.2-5.2 5.2z" />
<path d="M12 16c0-2.4-1.8-4.2-4.2-4.2 0 2.4 1.8 4.2 4.2 4.2z" />
</>
),
// A disc cut into parts.
segmentos: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 3.5v8.5l7.4 4.3" />
</>
),
// An ID card — the persona, written down.
ficha: (
<>
<rect x="2.5" y="5" width="19" height="14" rx="1.6" />
<circle cx="8.5" cy="10.5" r="2" />
<path d="M5.4 15.6a3.3 3.3 0 0 1 6.2 0" />
<path d="M14.5 10.5h4.5M14.5 14h4.5" />
</>
),
// Waypoints along a dotted route.
recorrido: (
<>
<path d="M4.5 19.5c4.5 0 3-6 7-6s3.5-7 8-7" strokeDasharray="1.6 3" />
<circle cx="4.5" cy="19.5" r="1.6" fill="currentColor" stroke="none" />
<circle cx="19.5" cy="6.5" r="1.6" fill="currentColor" stroke="none" />
</>
),
// The gap between two solid runs — the need nobody covers.
hueco: (
<>
<path d="M2.5 12h5.5M16 12h5.5" />
<circle cx="12" cy="12" r="3.4" strokeDasharray="2 2.6" />
</>
),
// Crosshair on the target.
diana: (
<>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none" />
<path d="M12 1.5V5M12 19v3.5M1.5 12H5M19 12h3.5" />
</>
),
// A parent and two children.
arquitectura: (
<>
<rect x="9" y="2.5" width="6" height="4.5" rx="1" />
<rect x="2" y="17" width="6" height="4.5" rx="1" />
<rect x="16" y="17" width="6" height="4.5" rx="1" />
<path d="M12 7v3.5M5 17v-3.5h14V17" />
</>
),
// An open book.
libro: (
<>
<path d="M12 6.5v13" />
<path d="M12 6.5C10.5 5 8.5 4.5 4 4.5v13c4.5 0 6.5.5 8 2" />
<path d="M12 6.5c1.5-1.5 3.5-2 8-2v13c-4.5 0-6.5.5-8 2" />
</>
),
// A megaphone.
megafono: (
<>
<path d="M3.5 10.5v3A1.5 1.5 0 0 0 5 15h2.5l7 4V5l-7 4H5a1.5 1.5 0 0 0-1.5 1.5z" />
<path d="M18 9.5a4 4 0 0 1 0 5" />
</>
),
// A month blocked out.
calendario: (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="1.5" />
<path d="M3.5 9.5h17M8 2.5V6M16 2.5V6" />
<path d="M7.5 14h6" />
</>
),
// Broadcast.
ondas: (
<>
<circle cx="12" cy="15" r="1.8" fill="currentColor" stroke="none" />
<path d="M8.8 11.6a4.5 4.5 0 0 1 6.4 0" />
<path d="M5.8 8.4a9 9 0 0 1 12.4 0" />
<path d="M12 16.8V21" />
</>
),
// A written page.
documento: (
<>
<path d="M5.5 3.5h8l5 5v12h-13z" />
<path d="M13.5 3.5V9h5" />
<path d="M8.5 13h7M8.5 16.5h4.5" />
</>
),
// A gauge — the number the CEO watches.
medidor: (
<>
<path d="M3.5 17.5a8.5 8.5 0 0 1 17 0" />
<path d="m12 17.5 4.6-5.2" />
<circle cx="12" cy="17.5" r="1.4" fill="currentColor" stroke="none" />
</>
),
// Bars inside a frame.
tablero: (
<>
<rect x="3" y="4" width="18" height="16" rx="1.5" />
<path d="M7.5 16v-3.5M12 16v-6.5M16.5 16v-4.5" />
</>
),
// Three round a table.
comite: (
<>
<circle cx="12" cy="6" r="2.4" />
<circle cx="5.2" cy="9.5" r="2" />
<circle cx="18.8" cy="9.5" r="2" />
<path d="M2.5 19.5h19" />
<path d="M8 19.5a4 4 0 0 1 8 0" />
</>
),
// Milestones on the line, the last one reached.
hitos: (
<>
<path d="M2.5 12h19" />
<circle cx="6.5" cy="12" r="2.1" />
<circle cx="12" cy="12" r="2.1" />
<circle cx="17.5" cy="12" r="2.1" fill="currentColor" stroke="none" />
</>
),
// Launch.
cohete: (
<>
<path d="M12 2.5c3 2.4 4.5 5.7 4.5 9.5L12 16l-4.5-4c0-3.8 1.5-7.1 4.5-9.5z" />
<circle cx="12" cy="9.5" r="1.6" fill="currentColor" stroke="none" />
<path d="M9.2 16.2 6.5 21l4-1.6M14.8 16.2 17.5 21l-4-1.6" />
</>
),
// A lit idea.
bombilla: (
<>
<path d="M8.5 14.5a5 5 0 1 1 7 0c-.8.8-1.2 1.5-1.2 2.5h-4.6c0-1-.4-1.7-1.2-2.5z" />
<path d="M10 20h4" />
</>
),
// A planner full of months.
anio: (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="1.5" />
<path d="M3.5 9.5h17M8 2.5V6M16 2.5V6" />
<path d="M7 13h1.6M11.2 13h1.6M15.4 13h1.6M7 16.8h1.6M11.2 16.8h1.6M15.4 16.8h1.6" />
</>
),
// A shield — what keeps the growth from drifting.
escudo: <path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8.6 7.5 10 4.4-1.4 7.5-5.4 7.5-10v-6z" />,
};
// Every drawn icon, straight from the registry above — so a test iterating
// these can never fall behind the set that actually exists.
export const NOMBRES_ICONO = Object.keys(PATHS) as IconoNombre[];
export function Icono({ nombre, className = '' }: { nombre: IconoNombre; className?: string }) {
return (
<svg
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
// Contact marks. Unlike Icono these are not hand-drawn: a brand logo has to
// be the brand's own shape or it stops being recognisable, so WhatsApp and
// LinkedIn are the Simple Icons paths (CC0), filled with currentColor so they
// take the page's colour instead of their brand green and blue. Email is not a
// brand — it is an envelope in Icono's own line language.
export type RedNombre = 'whatsapp' | 'email' | 'linkedin';
const MARCAS: Record<RedNombre, ReactNode> = {
whatsapp: (
<path
fill="currentColor"
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"
/>
),
email: (
<g fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<rect x="2.5" y="4.5" width="19" height="15" rx="2" />
<path d="m3 6 9 7 9-7" />
</g>
),
linkedin: (
<path
fill="currentColor"
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
),
};
// Key order is display order: WhatsApp first, it is the primary channel.
export const NOMBRES_RED = Object.keys(MARCAS) as RedNombre[];
export function IconoRed({ nombre, className = '' }: { nombre: RedNombre; className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
{MARCAS[nombre]}
</svg>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo';
// The manual's philosophy — Complejidad → Enfoque → Claridad — as the
// document's own furniture instead of as an illustration parked in a corner.
// Each section carries one step of it in the margin column, so the reader walks
// the tangle resolving into the ≡ by scrolling.
//
// Static: the interpolation happens at render, there is no animation and no
// client JavaScript. The geometry it resolves into comes from lib/simbolo.ts,
// the same source the wordmark traces, so the mark it produces is provably the
// brand's and not a lookalike.
export const PASOS = 6;
// Hand-placed rather than random, so the mark is identical on every render and
// between server and client. Three lines per bar of the ≡.
const DISPERSAS = [
{ x1: 4, y1: 10, x2: 44, y2: 4, barra: 0 },
{ x1: 18, y1: 3, x2: 58, y2: 22, barra: 0 },
{ x1: 6, y1: 24, x2: 50, y2: 12, barra: 0 },
{ x1: 3, y1: 30, x2: 56, y2: 26, barra: 1 },
{ x1: 14, y1: 40, x2: 52, y2: 20, barra: 1 },
{ x1: 8, y1: 34, x2: 60, y2: 38, barra: 1 },
{ x1: 5, y1: 46, x2: 47, y2: 60, barra: 2 },
{ x1: 22, y1: 62, x2: 58, y2: 44, barra: 2 },
{ x1: 10, y1: 56, x2: 54, y2: 52, barra: 2 },
];
// One line per group survives to become a bar; the other six fade out as the
// page converges — many/disordered becomes few/aligned, per the manual.
const SOBREVIVE = [0, 3, 6];
const GROSOR_DISPERSO = 2;
export function MarcaSeccion({ paso, className = '' }: { paso: number; className?: string }) {
const t = Math.min(1, Math.max(0, paso / (PASOS - 1)));
const mezcla = (a: number, b: number) => a + (b - a) * t;
return (
<svg viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
{DISPERSAS.map((d, i) => {
const destino = LINEAS_SIMBOLO[d.barra];
const sobrevive = SOBREVIVE.includes(i);
return (
<line
key={i}
x1={mezcla(d.x1, destino.x1)}
y1={mezcla(d.y1, destino.y1)}
x2={mezcla(d.x2, destino.x2)}
y2={mezcla(d.y2, destino.y2)}
stroke="currentColor"
strokeWidth={sobrevive ? mezcla(GROSOR_DISPERSO, destino.grosor) : GROSOR_DISPERSO}
strokeLinecap="round"
opacity={sobrevive ? 1 : 1 - t}
/>
);
})}
</svg>
);
}
+2 -2
View File
@@ -94,7 +94,7 @@ export function ParDialogo({
resuelto ? 'opacity-100' : 'opacity-55'
}`}
>
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado">
<p className="text-xs font-black uppercase tracking-[0.2em] text-dorado-texto">
{String(n).padStart(2, '0')} · {name}
</p>
<p className="mt-3 font-sans font-bold text-xl md:text-2xl text-azul-profundo leading-snug">
@@ -122,7 +122,7 @@ export function ParDialogo({
}`}
/>
<Wordmark size="sm" />
<p className="mt-3 text-gris-medio leading-relaxed">{respuesta}</p>
<p className="mt-3 text-azul-profundo/70 leading-relaxed">{respuesta}</p>
</div>
</motion.div>
</li>
+25
View File
@@ -0,0 +1,25 @@
// A soft light source dropped behind a section's content.
//
// A flat fill reads as a printed block: nothing tells the eye where the light
// comes from, so every section weighs the same. One of these, placed off to a
// side, gives the surface a direction and lets the gradient underneath do its
// work. Purely decorative — it carries no meaning and is hidden from assistive
// tech.
//
// The caller positions and sizes it; only the colour and the falloff live here.
export function Resplandor({
className = '',
tono = 'dorado',
}: {
className?: string;
tono?: 'dorado' | 'frio';
}) {
return (
<div
aria-hidden="true"
className={`pointer-events-none absolute rounded-full blur-[1px] ${
tono === 'dorado' ? 'bg-velo-dorado' : 'bg-velo-frio'
} ${className}`}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
// The page's only motion, and it is built the opposite way round from the
// system it replaces.
//
// The hidden state lives in CSS and applies only under `html.js`, a class an
// inline script in <head> adds before first paint. Nothing here writes
// `opacity: 0` into the markup, so the static export ships a page that reads in
// full — the previous architecture shipped 112 elements at opacity zero and
// went blank whenever the JavaScript failed to arrive.
//
// One module-level observer for the whole page rather than one per block.
let observador: IntersectionObserver | null = null;
function observadorCompartido() {
if (observador) return observador;
observador = new IntersectionObserver(
(entradas) => {
for (const entrada of entradas) {
if (!entrada.isIntersecting) continue;
entrada.target.setAttribute('data-visible', '');
observador?.unobserve(entrada.target);
}
},
// Fires a touch before the block is fully in, so the gesture has finished
// by the time the reader's eye lands on it.
{ rootMargin: '0px 0px -10% 0px' },
);
return observador;
}
export function Revelar({
children,
indice = 0,
className = '',
}: {
children: ReactNode;
indice?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const obs = observadorCompartido();
obs.observe(el);
return () => obs.unobserve(el);
}, []);
return (
<div ref={ref} data-revelar className={className} style={{ '--i': indice } as CSSProperties}>
{children}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
import { Container } from '@/components/primitives/Container';
import { MarcaSeccion } from '@/components/primitives/MarcaSeccion';
// The document's grid: a narrow margin column carrying the section number and
// its mark, and a wide column carrying everything else. That asymmetry is what
// makes the page read as a document rather than as a stack of landing blocks.
//
// Three vertical rhythms rather than one. Every section used to share
// `py-16 md:py-24`, which made ten sections go past like a metronome; the page
// needs somewhere to breathe and somewhere to hurry.
type Ritmo = 'compacta' | 'normal' | 'momento';
type Fondo = 'claro' | 'oscuro';
const RITMO: Record<Ritmo, string> = {
compacta: 'py-20 md:py-28',
normal: 'py-28 md:py-40',
momento: 'py-36 md:py-56',
};
const FONDO: Record<Fondo, string> = {
claro: 'bg-white text-azul-profundo',
oscuro: 'bg-azul-profundo bg-hondo-navy text-white',
};
// Gold on navy is 6,07:1; on white it is 2,76:1 and the mark is a graphic that
// carries meaning, so it needs 3:1.
const MARCA: Record<Fondo, string> = {
claro: 'text-dorado-texto',
oscuro: 'text-dorado',
};
export function Seccion({
numero,
paso,
ritmo = 'compacta',
fondo = 'claro',
id,
children,
}: {
numero: string;
paso: number;
ritmo?: Ritmo;
fondo?: Fondo;
id?: string;
children: ReactNode;
}) {
return (
<section id={id} className={`${FONDO[fondo]}${id ? ' scroll-mt-24' : ''}`}>
<Container className={RITMO[ritmo]}>
<div className="grid gap-y-8 md:grid-cols-[7rem_minmax(0,1fr)] md:gap-x-10">
<div
aria-hidden="true"
className="flex items-center gap-4 md:flex-col md:items-start md:gap-5"
>
<span className="font-sans font-semibold text-xs tracking-[0.2em] tabular-nums opacity-50">
{numero}
</span>
<MarcaSeccion paso={paso} className={`w-10 shrink-0 ${MARCA[fondo]}`} />
</div>
<div className="min-w-0">{children}</div>
</div>
</Container>
</section>
);
}
+66 -22
View File
@@ -8,8 +8,9 @@ import { VIEWBOX_SIMBOLO, LINEAS_SIMBOLO } from '@/lib/simbolo';
// Eighteen crossing lines converge in three groups; in the "completa" variant
// each group collapses onto one of the three bars of the ≡, so the mark is
// literally produced by the resolution of the tangle rather than placed next
// to it. The "hero" variant stops at convergence and leaves the tension open —
// FraseAncla pays it off further down the page.
// to it. The "hero" variant resolves the chaos into a skyline silhouette —
// structure emerging from disorder, what creeer builds for its clients.
// FraseAncla pays off the full ≡ resolution further down the page.
type Variant = 'completa' | 'hero';
@@ -40,6 +41,55 @@ const DISPERSAS = [
// converge — many/disordered becomes few/aligned, per the manual.
const SOBREVIVE = [0, 6, 12];
// Skyline destination for the hero variant. Eighteen lines form a city
// silhouette: vertical segments are building walls, horizontal segments
// are rooftops and the ground line. The skyline reads left to right as
// buildings of increasing height — chaos becoming structure, the visual
// promise of what creeer delivers.
//
// ViewBox: 64×64. Ground at y=58. Tallest building peaks at y=10.
//
// ┌──┐
// ┌──┐ │ │ ┌─┐
// │ │ │ │ │ │┌──┐
// ┌─┤ │ │ │ │ ││ │
// │ │ │ │ │ │ ││ │
// └─┴──┴─┴──┴─┴─┴┴──┘
//
const SKYLINE = [
// Building 1 (left, medium) — x: 4–14, top: 28
{ x1: 4, y1: 58, x2: 4, y2: 28 }, // 0: left wall
{ x1: 4, y1: 28, x2: 14, y2: 28 }, // 1: rooftop
{ x1: 14, y1: 28, x2: 14, y2: 58 }, // 2: right wall
// Building 2 (tall) — x: 18–30, top: 12
{ x1: 18, y1: 58, x2: 18, y2: 12 }, // 3: left wall
{ x1: 18, y1: 12, x2: 30, y2: 12 }, // 4: rooftop
{ x1: 30, y1: 12, x2: 30, y2: 58 }, // 5: right wall
// Building 3 (short) — x: 34–40, top: 32
{ x1: 34, y1: 58, x2: 34, y2: 32 }, // 6: left wall
{ x1: 34, y1: 32, x2: 40, y2: 32 }, // 7: rooftop
{ x1: 40, y1: 32, x2: 40, y2: 58 }, // 8: right wall
// Building 4 (medium-tall) — x: 42–54, top: 20
{ x1: 42, y1: 58, x2: 42, y2: 20 }, // 9: left wall
{ x1: 42, y1: 20, x2: 54, y2: 20 }, // 10: rooftop
{ x1: 54, y1: 20, x2: 54, y2: 58 }, // 11: right wall
// Small tower on building 1 — x: 7–11, top: 22
{ x1: 7, y1: 28, x2: 7, y2: 22 }, // 12: left wall
{ x1: 7, y1: 22, x2: 11, y2: 22 }, // 13: rooftop
{ x1: 11, y1: 22, x2: 11, y2: 28 }, // 14: right wall
// Ground line segments
{ x1: 2, y1: 58, x2: 30, y2: 58 }, // 15: ground left
{ x1: 30, y1: 58, x2: 56, y2: 58 }, // 16: ground right
// Antenna on tallest building — a small vertical detail
{ x1: 24, y1: 12, x2: 24, y2: 6 }, // 17: antenna
];
const EASE = [0.22, 1, 0.36, 1] as const;
export function SecuenciaClaridad({
@@ -54,26 +104,23 @@ export function SecuenciaClaridad({
const completa = variant === 'completa';
// Where each line ends up. In "completa" the survivors land exactly on the
// symbol's own coordinates; in "hero" they merely tighten toward their
// group's axis without ever aligning.
// symbol's own coordinates; in "hero" the lines form a skyline silhouette.
const destino = (i: number) => {
if (!completa) {
// Hero variant: lines converge into the skyline shape.
const s = SKYLINE[i];
return {
x1: s.x1,
y1: s.y1,
x2: s.x2,
y2: s.y2,
opacity: i === 17 ? 0.7 : 0.85, // antenna slightly dimmer
};
}
// Completa variant: survivors land on the ≡ bars.
const { barra } = DISPERSAS[i];
const linea = LINEAS_SIMBOLO[barra];
const sobrevive = SOBREVIVE.includes(i);
if (!completa) {
const eje = linea.y1;
const d = DISPERSAS[i];
// Pulled a good way further in than it used to be: at a third of the way
// the tangle still read as a scratched-out scribble instead of as lines
// finding their axis. It must arrive close to aligned and stop short.
return {
x1: d.x1 + (26 - d.x1) * 0.5,
y1: d.y1 + (eje - d.y1) * 0.7,
x2: d.x2 + (38 - d.x2) * 0.5,
y2: d.y2 + (eje - d.y2) * 0.7,
opacity: sobrevive ? 0.95 : 0.28,
};
}
return {
x1: linea.x1,
y1: linea.y1,
@@ -87,8 +134,7 @@ export function SecuenciaClaridad({
completa && SOBREVIVE.includes(i) ? LINEAS_SIMBOLO[DISPERSAS[i].barra].grosor : 1.5;
// Reduced motion renders plain <line> elements: motion.line would apply its
// own dash scaffolding and leave the drawing invisible (see the same trap in
// IlustracionInflexion).
// own dash scaffolding and leave the drawing invisible.
if (reduced) {
return (
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
@@ -118,8 +164,6 @@ export function SecuenciaClaridad({
<svg ref={ref} viewBox={VIEWBOX_SIMBOLO} className={className} aria-hidden="true" focusable="false">
{DISPERSAS.map(({ barra: _barra, ...inicio }, i) => {
const d = destino(i);
// `barra` is grouping metadata, not an SVG attribute — destructured out
// so it never reaches the DOM.
const reposo = { ...inicio, opacity: 0.55, strokeWidth: 1.5 };
return (
<motion.line
+29
View File
@@ -0,0 +1,29 @@
import { BARRAS_SIMBOLO, VIEWBOX_SIMBOLO } from '@/lib/simbolo';
// The ≡ mark on its own, at any scale — the manual lists it under "recursos
// visuales" as "las tres líneas del símbolo en diversas orientaciones y
// escalas". Geometry comes from lib/simbolo.ts, the same source the wordmark
// reads, so this is provably the mark and not a lookalike drawn by hand.
// Decorative by definition: it never carries meaning a reader would miss.
export function SimboloMarca({ className = '' }: { className?: string }) {
return (
<svg
viewBox={VIEWBOX_SIMBOLO}
className={className}
aria-hidden="true"
focusable="false"
fill="currentColor"
>
{BARRAS_SIMBOLO.map((barra) => (
<rect
key={barra.y}
x={barra.x}
y={barra.y}
width={barra.width}
height={barra.height}
rx={barra.height / 2}
/>
))}
</svg>
);
}
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Antetitulo } from '../Antetitulo';
describe('<Antetitulo />', () => {
it('renders its copy', () => {
render(<Antetitulo>el contexto</Antetitulo>);
expect(screen.getByText('el contexto')).toBeInTheDocument();
});
it('sobre fondo oscuro usa el dorado del manual', () => {
// Sobre azul profundo, #C4942E da 6,07:1: es donde el dorado de la marca
// trabaja mejor y ahí se queda intacto.
render(<Antetitulo sobre="oscuro">las herramientas</Antetitulo>);
expect(screen.getByText('las herramientas').className.split(/\s+/)).toContain('text-dorado');
});
it('sobre fondo claro baja al dorado legible', () => {
// El mismo #C4942E cae a 2,76:1 sobre blanco y a 2,46:1 sobre gris-claro.
// A 12px y en negrita eso no se lee: el antetítulo pasa a `dorado-texto`.
render(<Antetitulo sobre="claro">lo que realmente sientes</Antetitulo>);
const classes = screen.getByText('lo que realmente sientes').className.split(/\s+/);
expect(classes).toContain('text-dorado-texto');
expect(classes).not.toContain('text-dorado');
});
it('asume fondo claro cuando no se le dice nada', () => {
// El caso por defecto tiene que ser el seguro: olvidarse del prop no puede
// producir un antetítulo ilegible.
render(<Antetitulo>cómo empezar</Antetitulo>);
expect(screen.getByText('cómo empezar').className.split(/\s+/)).toContain('text-dorado-texto');
});
it('puede ser el encabezado de su sección', () => {
// En el recorrido el antetítulo es el único <h2> que tiene la sección.
render(<Antetitulo as="h2">¿Te reconoces?</Antetitulo>);
expect(screen.getByRole('heading', { name: '¿Te reconoces?', level: 2 })).toBeInTheDocument();
});
it('lee su tamaño del token rotulo en vez de recomponerlo a mano', () => {
// `rotulo` already declares 0.75rem with 0.3em of tracking. Repeating it
// in the className left the token unused and the two definitions free
// to drift apart.
render(<Antetitulo>el contexto</Antetitulo>);
const classes = screen.getByText('el contexto').className.split(/\s+/);
expect(classes).toContain('text-rotulo');
expect(classes).not.toContain('text-xs');
expect(classes).not.toContain('tracking-[0.3em]');
});
it('usa un peso que la tipografía tiene', () => {
// Open Sans tops out at 800 and layout.tsx loads up to 800: `font-black`
// (900) fell back to 800 anyway. The eyebrow goes at 600, the weight the
// scale reserves for labels.
render(<Antetitulo>el contexto</Antetitulo>);
const classes = screen.getByText('el contexto').className.split(/\s+/);
expect(classes).toContain('font-semibold');
expect(classes).not.toContain('font-black');
});
});
+11 -1
View File
@@ -12,12 +12,22 @@ describe('<CTA />', () => {
expect(screen.getByRole('link').getAttribute('href')).toBe('#contacto');
});
it('renders the ghost variant outlined, so it reads as secondary', () => {
render(<CTA href="#mecanismo" label="Ver cómo trabajo" variant="ghost" />);
render(<CTA href="#proceso" label="Ver cómo trabajamos" variant="ghost" />);
const classes = screen.getByRole('link').className.split(/\s+/);
expect(classes).toContain('border-azul-profundo');
expect(classes).not.toContain('bg-azul-profundo'); // sólo se rellena en hover
expect(classes).toContain('hover:bg-azul-profundo');
});
it('rellena en dorado con la etiqueta en azul profundo', () => {
// Blanco sobre el dorado del manual (#C4942E) es 2,76:1 — no pasa AA para
// texto pequeño, y esta es la variante del botón que cierra la página.
// Azul profundo sobre ese mismo dorado da 6,07:1.
render(<CTA href="#contacto" label="Define tu siguiente movimiento" variant="dark" />);
const classes = screen.getByRole('link').className.split(/\s+/);
expect(classes).toContain('bg-dorado');
expect(classes).toContain('text-azul-profundo');
expect(classes).not.toContain('text-white');
});
it('keeps every variant the same height', () => {
const { container } = render(
<>
@@ -0,0 +1,23 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ClaridadHero } from '../ClaridadHero';
describe('<ClaridadHero />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<ClaridadHero className="w-full h-full" />);
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
});
it('holds the box open so nothing shifts when the scene arrives', () => {
const { container } = render(<ClaridadHero className="w-full h-full" />);
expect(container.firstElementChild).toHaveClass('w-full', 'h-full');
});
// The stub observer in the test setup never reports an intersection, which is
// exactly the case worth pinning: until the box comes near the viewport,
// nothing mounts and three.js is never fetched.
it('does not fetch three.js before the box comes near the viewport', () => {
const { container } = render(<ClaridadHero className="w-full h-full" />);
expect(container.firstElementChild).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,54 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
// jsdom has no WebGL, so the real <Canvas> cannot mount. The mock keeps the
// wrapper — the part that is plain DOM and that carries the decisions worth
// protecting — testable, and reports back the frameloop it was handed.
vi.mock('@react-three/fiber', () => ({
Canvas: ({ frameloop }: { frameloop?: string }) => (
<div data-testid="lienzo" data-frameloop={frameloop} />
),
useFrame: () => {},
useThree: () => ({ camera: { position: { set: () => {} }, lookAt: () => {} }, invalidate: () => {} }),
}));
import { EscenaClaridad } from '../EscenaClaridad';
function conMovimientoReducido(reducido: boolean) {
window.matchMedia = ((query: string) => ({
matches: reducido && query.includes('reduce'),
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
afterEach(() => conMovimientoReducido(false));
describe('<EscenaClaridad />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<EscenaClaridad />);
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
});
it('takes its size from the caller, so the hero reserves the box', () => {
const { container } = render(<EscenaClaridad className="w-full h-full" />);
expect(container.firstElementChild).toHaveClass('w-full', 'h-full');
});
it('runs the loop while the hero is on screen', () => {
conMovimientoReducido(false);
const { getByTestId } = render(<EscenaClaridad />);
expect(getByTestId('lienzo')).toHaveAttribute('data-frameloop', 'always');
});
it('stops the loop for readers who asked for reduced motion', () => {
conMovimientoReducido(true);
const { getByTestId } = render(<EscenaClaridad />);
expect(getByTestId('lienzo')).toHaveAttribute('data-frameloop', 'demand');
});
});
+2 -14
View File
@@ -1,18 +1,6 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Icono, type IconoNombre } from '../Icono';
const NOMBRES: IconoNombre[] = [
'inmersion',
'desafiar',
'estrategia',
'escribeme',
'diagnostico',
'plan',
'comunicacion',
'digital',
'experiencias',
];
import { Icono, NOMBRES_ICONO } from '../Icono';
describe('<Icono />', () => {
it('is decorative: hidden from assistive tech', () => {
@@ -20,7 +8,7 @@ describe('<Icono />', () => {
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it.each(NOMBRES)('renders a non-empty svg for "%s"', (nombre) => {
it.each(NOMBRES_ICONO)('renders a non-empty svg for "%s"', (nombre) => {
const { container } = render(<Icono nombre={nombre} />);
const svg = container.querySelector('svg');
expect(svg?.children.length).toBeGreaterThan(0);
@@ -0,0 +1,26 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { IconoRed, NOMBRES_RED } from '../IconoRed';
describe('<IconoRed />', () => {
it('is decorative: the link around it carries the name', () => {
const { container } = render(<IconoRed nombre="whatsapp" />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it.each(NOMBRES_RED)('renders a non-empty svg for "%s"', (nombre) => {
const { container } = render(<IconoRed nombre={nombre} />);
expect(container.querySelector('svg')?.children.length).toBeGreaterThan(0);
});
it('never brings its own brand colour onto the page', () => {
for (const nombre of NOMBRES_RED) {
const { container } = render(<IconoRed nombre={nombre} />);
const pintados = container.querySelectorAll('[fill]:not([fill="none"]), [stroke]');
pintados.forEach((el) => {
const color = el.getAttribute('fill') ?? el.getAttribute('stroke');
if (color !== 'none') expect(color).toBe('currentColor');
});
}
});
});
@@ -0,0 +1,55 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MarcaSeccion, PASOS } from '../MarcaSeccion';
import { LINEAS_SIMBOLO } from '@/lib/simbolo';
const lineas = (paso: number) => {
const { container } = render(<MarcaSeccion paso={paso} />);
return Array.from(container.querySelectorAll('line'));
};
const visibles = (paso: number) => lineas(paso).filter((l) => l.getAttribute('opacity') !== '0');
const coords = (paso: number) =>
lineas(paso).map((l) =>
(['x1', 'y1', 'x2', 'y2'] as const).map((a) => Number(l.getAttribute(a))),
);
describe('<MarcaSeccion />', () => {
it('opens the document with all nine lines tangled', () => {
expect(visibles(0)).toHaveLength(9);
});
it("resolves into the manual's ≡ exactly, not into a lookalike", () => {
// Not a lookalike: the exact three bars that the wordmark draws.
const finales = visibles(PASOS - 1);
expect(finales).toHaveLength(3);
finales.forEach((l, i) => {
(['x1', 'y1', 'x2', 'y2'] as const).forEach((attr) => {
expect(Number(l.getAttribute(attr)), attr).toBeCloseTo(LINEAS_SIMBOLO[i][attr], 5);
});
expect(Number(l.getAttribute('stroke-width'))).toBeCloseTo(LINEAS_SIMBOLO[i].grosor, 5);
});
});
it('converges step by step: each one leaves the lines closer to where they end up', () => {
// Counting visible lines proves nothing here: the six that fade do it
// continuously as `1 - t` and only reach a literal "0" at the last step,
// so the count is [9,9,9,9,9,3] and the assertion cannot fail. What does
// move at every step is how far each line still is from its final place.
const destino = coords(PASOS - 1);
const desvio = (paso: number) =>
coords(paso).reduce(
(total, linea, i) => total + Math.hypot(...linea.map((v, j) => v - destino[i][j])),
0,
);
const serie = Array.from({ length: PASOS }, (_, p) => desvio(p));
serie.slice(1).forEach((d, i) => expect(d, `paso ${i + 1}`).toBeLessThan(serie[i]));
expect(serie[PASOS - 1]).toBe(0);
});
it('is decorative for screen readers', () => {
const { container } = render(<MarcaSeccion paso={2} />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
});
@@ -6,7 +6,7 @@ const par = {
n: 1,
name: 'Crecer',
situacion: 'El siguiente salto no tiene mapa.',
respuesta: 'Me sumerjo hasta entender tu empresa desde dentro.',
respuesta: 'Nos sumergimos hasta entender tu empresa desde dentro.',
};
const montar = (extra: Partial<Parameters<typeof ParDialogo>[0]> = {}) =>
@@ -20,7 +20,7 @@ describe('<ParDialogo />', () => {
it('renders both voices of the exchange', () => {
montar();
expect(screen.getByText(/El siguiente salto no tiene mapa/)).toBeInTheDocument();
expect(screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/)).toBeInTheDocument();
expect(screen.getByText(/Nos sumergimos hasta entender tu empresa desde dentro/)).toBeInTheDocument();
});
it('attributes the answer to creeer, not to the reader', () => {
@@ -56,7 +56,7 @@ describe('<ParDialogo />', () => {
// document. `hidden` or `display: none` would take it away from screen
// readers and from the static export the crawler sees — half the section's
// message, gone, to buy an animation.
const respuesta = screen.getByText(/Me sumerjo hasta entender tu empresa desde dentro/);
const respuesta = screen.getByText(/Nos sumergimos hasta entender tu empresa desde dentro/);
expect(respuesta).toBeInTheDocument();
expect(respuesta.closest('[hidden]')).toBeNull();
expect(screen.getByLabelText(/Respuesta de creeer a: Crecer/).className).toMatch(/opacity-0\b/);
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Revelar } from '../Revelar';
// The stub in vitest.setup.ts is inert: it observes but never fires. This one
// captures the callback so we can simulate the element entering the viewport.
let disparar: (entradas: Partial<IntersectionObserverEntry>[]) => void;
beforeEach(() => {
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: IntersectionObserverCallback) {
disparar = (entradas) => cb(entradas as IntersectionObserverEntry[], this as never);
}
observe() {}
unobserve() {}
disconnect() {}
},
);
});
describe('<Revelar />', () => {
it('no oculta nada por sí mismo', () => {
// The hidden state is CSS-only, gated on `html.js`. If the component wrote
// opacity 0 into the inline style we'd be back to the bug this redesign
// fixes: 112 invisible elements in the export and a blank page whenever
// the JS doesn't arrive.
const { container } = render(<Revelar>Tu negocio no necesita más esfuerzo.</Revelar>);
const el = container.firstElementChild as HTMLElement;
expect(el.style.opacity).toBe('');
expect(screen.getByText('Tu negocio no necesita más esfuerzo.')).toBeInTheDocument();
});
it('marca el bloque para que el CSS lo pueda gobernar', () => {
const { container } = render(<Revelar>x</Revelar>);
expect(container.firstElementChild).toHaveAttribute('data-revelar');
});
it('escalona el retardo con el índice', () => {
const { container } = render(<Revelar indice={3}>x</Revelar>);
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--i')).toBe('3');
});
it('se marca visible al entrar en viewport', () => {
const { container } = render(<Revelar>x</Revelar>);
const el = container.firstElementChild as HTMLElement;
expect(el).not.toHaveAttribute('data-visible');
disparar([{ isIntersecting: true, target: el }]);
expect(el).toHaveAttribute('data-visible');
});
});
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Seccion } from '../Seccion';
describe('<Seccion />', () => {
it('renders its content and its number', () => {
render(<Seccion numero="02" paso={1}>Dónde estás</Seccion>);
expect(screen.getByText('Dónde estás')).toBeInTheDocument();
expect(screen.getByText('02')).toBeInTheDocument();
});
it('carries its mark at the step it is given', () => {
const { container } = render(<Seccion numero="06" paso={5}>x</Seccion>);
// Step 5 resolves the ≡: three of the nine lines stay visible.
const visibles = Array.from(container.querySelectorAll('line')).filter(
(l) => l.getAttribute('opacity') !== '0',
);
expect(visibles).toHaveLength(3);
});
it('uses the manual gold on dark backgrounds, the readable gold on light ones', () => {
const { container: oscuro } = render(<Seccion numero="03" paso={2} fondo="oscuro">x</Seccion>);
expect(oscuro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado');
expect(oscuro.querySelector('svg')?.getAttribute('class')).not.toContain('text-dorado-texto');
const { container: claro } = render(<Seccion numero="04" paso={3}>x</Seccion>);
expect(claro.querySelector('svg')?.getAttribute('class')).toContain('text-dorado-texto');
});
it('is not announced to screen readers', () => {
// It is document furniture, not content: the section's own heading
// already says what it is about.
const { container } = render(<Seccion numero="05" paso={4}>x</Seccion>);
expect(container.querySelector('[aria-hidden="true"]')?.textContent).toContain('05');
});
it('only becomes an anchor target when it has an id', () => {
const { container } = render(<Seccion numero="06" paso={5} id="contacto">x</Seccion>);
const seccion = container.querySelector('section');
expect(seccion).toHaveAttribute('id', 'contacto');
expect(seccion?.className).toContain('scroll-mt-24');
});
});
@@ -0,0 +1,35 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { SimboloMarca } from '../SimboloMarca';
import { BARRAS_SIMBOLO } from '@/lib/simbolo';
describe('<SimboloMarca />', () => {
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<SimboloMarca />);
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
// The dueña's 28/jul instruction was that the ≡ has to read as an E. That
// depends entirely on the proportions in lib/simbolo.ts — squat bars and
// tight gaps turn it into a hamburger menu icon. Drawing from the canonical
// geometry rather than hand-typed numbers is what keeps that guarantee, so
// the test checks the bars really came from there.
it('draws the three canonical bars, straight from lib/simbolo', () => {
const { container } = render(<SimboloMarca />);
const barras = container.querySelectorAll('rect');
expect(barras).toHaveLength(3);
barras.forEach((barra, i) => {
expect(barra.getAttribute('x')).toBe(String(BARRAS_SIMBOLO[i].x));
expect(barra.getAttribute('y')).toBe(String(BARRAS_SIMBOLO[i].y));
expect(barra.getAttribute('width')).toBe(String(BARRAS_SIMBOLO[i].width));
expect(barra.getAttribute('height')).toBe(String(BARRAS_SIMBOLO[i].height));
});
});
it('takes its colour from currentColor via className, not a hardcoded fill', () => {
const { container } = render(<SimboloMarca className="text-dorado" />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('fill', 'currentColor');
expect(svg).toHaveClass('text-dorado');
});
});