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>
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
'use client';
|
|
|
|
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';
|
|
import { EscenaDialogo } from '@/components/primitives/EscenaDialogo';
|
|
import { puntosInflexion } from '@/lib/content';
|
|
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
|
|
|
const items = puntosInflexion.items;
|
|
const TOTAL = items.length;
|
|
|
|
// One photograph per inflection point, in the order the points are declared.
|
|
// Asset wiring, not copy — `lib/content.ts` stays the source of truth for words
|
|
// only. Every file is from Wikimedia Commons under a licence that allows reuse,
|
|
// and every one is credited in `creditosFotos`; the footer renders that list.
|
|
//
|
|
// They are read through FondoFoto, which pushes them through
|
|
// mix-blend-luminosity over the brand's navy→gold gradient, so no photograph
|
|
// 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', // 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
|
|
// out at once. A pinned section that advances on scroll is exactly the thing
|
|
// someone asking for less movement is asking to be spared, and hiding five
|
|
// sixths of the copy behind a scroll position is not an option either way.
|
|
function Estatica() {
|
|
return (
|
|
<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">
|
|
<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={() => {}} />
|
|
))}
|
|
</ul>
|
|
</Container>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Escenificado() {
|
|
const contenedor = useRef<HTMLDivElement>(null);
|
|
|
|
// How far through the pinned stage the reader is, 0 → 1, measured by hand.
|
|
// `useScroll({ target })` sat at exactly 0 for the whole 5.400px of scroll —
|
|
// measured, not guessed: every scene stayed at its initial opacity from the
|
|
// top of the section to the bottom. Reading the container's own rect on each
|
|
// frame is a dozen lines, needs no measurement pass from the library, and can
|
|
// be verified in the browser, which the black box could not.
|
|
const bruto = useMotionValue(0);
|
|
|
|
// Whether the stage is close enough to be worth paying for its photographs.
|
|
// Measured on the static export: the six of them are 1,24 MB and every one
|
|
// was downloading before the reader had scrolled a pixel — `loading="lazy"`
|
|
// did not save us, because Chrome's distance threshold is generous enough to
|
|
// swallow a section that starts under 2.000px down. Two megabytes before the
|
|
// first gesture, on a page that gets opened from a WhatsApp link on mobile
|
|
// data.
|
|
const [cerca, setCerca] = useState(false);
|
|
const yaCerca = useRef(false);
|
|
|
|
useEffect(() => {
|
|
let pendiente = 0;
|
|
const medir = () => {
|
|
pendiente = 0;
|
|
const el = contenedor.current;
|
|
if (!el) return;
|
|
const { top, height } = el.getBoundingClientRect();
|
|
const recorrido = height - window.innerHeight;
|
|
bruto.set(recorrido > 0 ? Math.min(1, Math.max(0, -top / recorrido)) : 0);
|
|
// Latch, never unset: the images are cached by then, and flipping it back
|
|
// would tear down the stage every time the reader scrolled past.
|
|
if (!yaCerca.current && top < window.innerHeight * 1.25) {
|
|
yaCerca.current = true;
|
|
setCerca(true);
|
|
}
|
|
};
|
|
const alScroll = () => {
|
|
if (!pendiente) pendiente = requestAnimationFrame(medir);
|
|
};
|
|
medir();
|
|
window.addEventListener('scroll', alScroll, { passive: true });
|
|
window.addEventListener('resize', alScroll);
|
|
return () => {
|
|
if (pendiente) cancelAnimationFrame(pendiente);
|
|
window.removeEventListener('scroll', alScroll);
|
|
window.removeEventListener('resize', alScroll);
|
|
};
|
|
}, [bruto]);
|
|
|
|
// Takes the jitter out of a trackpad without lagging behind the reader; the
|
|
// hilo conductor taught this project what an overdamped spring feels like.
|
|
const progreso = useSpring(bruto, { stiffness: 260, damping: 42, restDelta: 0.0005 });
|
|
|
|
// Twelve beats, not a continuum: question, answer, question, answer… Scroll
|
|
// picks the beat and nothing else. When the cards were driven straight off
|
|
// the scroll value, every position in between was a frame you could stop on,
|
|
// and creeer's card sat there as a half-transparent grey slab.
|
|
const TIEMPOS = TOTAL * 2;
|
|
const [tiempo, setTiempo] = useState(0);
|
|
useMotionValueEvent(progreso, 'change', (v) => {
|
|
setTiempo(Math.min(TIEMPOS - 1, Math.max(0, Math.floor(v * TIEMPOS))));
|
|
});
|
|
const activo = Math.floor(tiempo / 2);
|
|
const respondida = tiempo % 2 === 1;
|
|
|
|
const barra = useTransform(progreso, [0, 1], [0, 1]);
|
|
const fondoY = useTransform(progreso, [0, 1], [0, -140]);
|
|
// A slower drift than the dots, so the photograph sits behind them in depth.
|
|
const fotoY = useTransform(progreso, [0, 1], [0, -70]);
|
|
|
|
return (
|
|
// Navy, not gris-claro: with no header block left there is no light strip
|
|
// to show, and any seam at the section's edges should be dark like the
|
|
// stage itself.
|
|
<section className="bg-azul-profundo relative">
|
|
{/* One viewport of stage, six viewports of scroll to move through it. */}
|
|
<div ref={contenedor} className="relative" style={{ height: `${TOTAL * 100}vh` }}>
|
|
<div data-escena={activo} data-tiempo={tiempo} className="sticky top-0 h-screen overflow-hidden bg-azul-profundo">
|
|
{/* The photograph of whichever point is on screen, crossfading with
|
|
the beat. Only the neighbours of the active scene are mounted:
|
|
the next one is in the document a whole scene before it is needed,
|
|
which is time enough to arrive, and the far ones cost nothing.
|
|
Mounting all six was 1,24 MB spent up front for five pictures the
|
|
reader might never reach. */}
|
|
{items.map((item, i) => (
|
|
<motion.div
|
|
key={item.name}
|
|
aria-hidden="true"
|
|
animate={{ opacity: i === activo ? 1 : 0 }}
|
|
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
|
className="absolute inset-0"
|
|
>
|
|
{cerca && Math.abs(i - activo) <= 1 && (
|
|
<motion.div style={{ y: fotoY }} className="absolute -inset-y-24 inset-x-0">
|
|
<FondoFoto src={FOTOS[i]} wash="" className="inset-0" />
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
))}
|
|
|
|
{/* "Respetando la capa azul y blanco": the photograph never carries
|
|
the frame, it only gives it grain. The light veil holds the
|
|
question readable in white; the navy veil takes the whole stage
|
|
over for the answer. The question's film is neutral black and the
|
|
answer's is the brand's navy, so the two frames are told apart by
|
|
temperature as well as by who is speaking. Both are heavy on
|
|
purpose — anything thinner and the copy fights the photograph. */}
|
|
<motion.div
|
|
aria-hidden="true"
|
|
animate={{ opacity: respondida ? 0 : 1 }}
|
|
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
|
|
className="absolute inset-0 bg-black/[0.66]"
|
|
/>
|
|
<motion.div
|
|
aria-hidden="true"
|
|
animate={{ opacity: respondida ? 1 : 0 }}
|
|
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
|
|
className="absolute inset-0 bg-azul-profundo/[0.80]"
|
|
/>
|
|
|
|
<motion.div style={{ y: fondoY }} className="absolute inset-0">
|
|
<FondoPuntos className="-bottom-10 -right-16 w-80 md:w-[34rem] text-white/10" distance={32} />
|
|
</motion.div>
|
|
|
|
<Container className="relative h-full">
|
|
{/* left-6 / md:left-12, not left-0: an absolutely positioned child
|
|
resolves against Container's padding box, so `left-0` lands on
|
|
the inside of the border and ignores the px-6 the rest of the
|
|
page is aligned to. On a phone that clipped the first glyph. */}
|
|
{/* The section's only heading now. Losing the header block cost
|
|
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. */}
|
|
<Antetitulo as="h2" sobre="oscuro" className="absolute top-24 left-6 md:left-12 md:top-28">
|
|
{puntosInflexion.lead}
|
|
</Antetitulo>
|
|
|
|
{/* The rail. Where you are in the conversation, and how much is
|
|
left — a pinned section without it feels like a page that
|
|
stopped responding. */}
|
|
<ol aria-hidden="true" className="absolute right-0 top-1/2 hidden -translate-y-1/2 list-none flex-col gap-3 p-0 md:flex">
|
|
{items.map((item, i) => (
|
|
<li key={item.name}>
|
|
<span
|
|
className={`block h-px origin-right transition-all duration-500 ${
|
|
i === activo
|
|
? 'w-10 bg-dorado'
|
|
: i < activo
|
|
? 'w-6 bg-dorado/40'
|
|
: respondida
|
|
? 'w-4 bg-white/25'
|
|
: 'w-4 bg-azul-profundo/20'
|
|
}`}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
|
|
<ol className="relative m-0 h-full list-none p-0">
|
|
{items.map((item, i) => (
|
|
<EscenaDialogo
|
|
key={item.name}
|
|
i={i}
|
|
total={TOTAL}
|
|
progreso={progreso}
|
|
activa={i === activo}
|
|
respondida={i === activo && respondida}
|
|
n={String(i + 1).padStart(2, '0')}
|
|
{...item}
|
|
/>
|
|
))}
|
|
</ol>
|
|
</Container>
|
|
|
|
<motion.span
|
|
aria-hidden="true"
|
|
style={{ scaleX: barra }}
|
|
className="absolute bottom-0 left-0 h-0.5 w-full origin-left bg-dorado"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function PuntosInflexion() {
|
|
const reduced = usePrefersReducedMotion();
|
|
return reduced ? <Estatica /> : <Escenificado />;
|
|
}
|