Files
Creeer-Web/components/sections/PuntosInflexion.tsx
T
erikghandClaude Opus 5.5 89e54a6e84 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>
2026-09-30 11:27:20 -06:00

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 />;
}