Medido sobre el export estático: la página pedía 2.067 kB antes de que el visitante moviera un dedo, y 1.243 de ellos eran las seis fotografías del recorrido. `loading="lazy"` no salvaba nada, porque el umbral de distancia de Chrome se traga una sección que empieza por debajo de los 2.000px. Ahora la escena solo monta su fotografía cuando el escenario está a menos de pantalla y cuarto, y solo la de la escena activa y sus vecinas. La siguiente entra en el documento una escena antes de hacer falta, que da tiempo de sobra a llegar. Carga inicial: 2.067 kB → 821 kB, con cero imágenes del recorrido. El resto llega repartido según avanzas. Verificado escena por escena en el export: 2 o 3 imágenes montadas a la vez, todas cargadas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
12 KiB
TypeScript
264 lines
12 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 { FondoPuntos } from '@/components/primitives/FondoPuntos';
|
|
import { FondoFoto } from '@/components/primitives/FondoFoto';
|
|
import { ParDialogo } from '@/components/primitives/ParDialogo';
|
|
import { EscenaDialogo } from '@/components/primitives/EscenaDialogo';
|
|
import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion';
|
|
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', // 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
|
|
];
|
|
|
|
function Cabecera({ reduced }: { reduced: boolean }) {
|
|
return (
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between md:gap-12">
|
|
<div>
|
|
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
|
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.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 max-w-3xl">{puntosInflexion.title}</motion.h2>
|
|
</div>
|
|
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
|
className="mt-8 md:mt-0 shrink-0">
|
|
<IlustracionInflexion className="w-56 md:w-72 h-auto mx-auto" />
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 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">
|
|
<Cabecera reduced />
|
|
<p className="mt-14 text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</p>
|
|
<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 (
|
|
<section className="bg-gris-claro relative">
|
|
{/* The pinned stage starts the instant this container ends, so without a
|
|
bottom the dark ground began three pixels under the last line of the
|
|
title and the intro read as a squeezed strip between two dark blocks
|
|
rather than as the section's opening. */}
|
|
<Container className="relative pt-16 pb-16 md:pt-24 md:pb-24">
|
|
<Cabecera reduced={false} />
|
|
</Container>
|
|
|
|
{/* 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. */}
|
|
<p className="absolute top-24 left-6 md:left-12 text-xs tracking-[0.3em] uppercase font-black text-dorado md:top-28">
|
|
{puntosInflexion.lead}
|
|
</p>
|
|
|
|
{/* 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 />;
|
|
}
|