Files
Creeer-Web/components/sections/PuntosInflexion.tsx
T
erikghandClaude Opus 5 e2396848ff feat(recorrido): los velos ceden para que se vea la fotografía
El velo claro baja de 0,90 a 0,72 y el azul de 0,93 a 0,80. La carretera,
los andamios y el trigo pasan de insinuarse a leerse, sin que la foto
llegue nunca a mandar sobre el color de marca.

Medido, no estimado: ocultando el texto y fotografiando solo el fondo que
queda detrás de cada titular, el peor contraste de los ocho fotogramas es
7,62:1 — por encima del 7:1 que pide AAA incluso para texto normal, y
este es de titular. A 0,66/0,76 bajaba a 6,32:1, que sigue siendo válido
para texto grande pero deja poco margen; de ahí el punto intermedio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:04:09 -06:00

236 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 { 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 mountain road with no visible end
'/images/recorrido/consolidarse.jpg', // a building still open to the weather
'/images/recorrido/rentabilidad.jpg', // the harvest: effort turned into result
'/images/recorrido/simplificar.jpg', // wires crossing a sky, lines multiplying
'/images/recorrido/expandirse.jpg', // a container port, new markets
'/images/recorrido/portafolio.jpg', // a whole city at once, nothing prioritised
];
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);
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);
};
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">
<Container className="relative pt-16 md:pt-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-gris-claro">
{/* The photograph of whichever point is on screen, crossfading with
the beat. Only the active one is opaque; the rest sit at zero, so
the browser still holds them decoded and the swap costs nothing. */}
{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"
>
<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 legible in azul-profundo type; the navy veil takes the
whole stage over for the answer. Both are heavy on purpose —
anything thinner and the copy starts fighting 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-gris-claro/[0.72]"
/>
<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-azul-profundo/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 />;
}