Files
Creeer-Web/components/sections/PuntosInflexion.tsx
T
erikghandClaude Opus 5 6078318b10 feat(recorrido): película negra en la pregunta, en vez de blanca
El fotograma de la pregunta pasa de velo claro a negro al 66%, y su
titular de azul profundo a blanco. La respuesta conserva el azul de
marca, así que las dos mitades siguen distinguiéndose —negro neutro
frente a azul— además de por quién habla.

Con ellas se van al blanco la cifra de fondo y los puntos del patrón, que
sobre negro eran invisibles.

Contraste medido sobre página verificada como hidratada: el peor punto de
los ocho fotogramas es 7,23:1, por encima del 7:1 de AAA incluso para
texto normal. Al 58% bajaba a 5,33:1 —válido para titular, escaso— y de
ahí el 66%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:31:02 -06:00

238 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-azul-profundo">
{/* 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 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 />;
}