feat(recorrido): una fotografía por punto, bajo las capas azul y blanca

Cada punto de inflexión trae su imagen de fondo, relacionada con lo que
plantea: una carretera de montaña sin final visible para «el siguiente
salto no tiene mapa», un edificio a medio construir, la cosecha —el
esfuerzo convertido en resultado—, cables cruzando el cielo, un puerto de
contenedores, una ciudad entera a la vez.

Pasan por FondoFoto, que las funde en luminosidad sobre el degradado azul
→ dorado: ninguna aporta color propio. Encima, el velo claro sostiene la
pregunta en azul y el velo azul se lleva el fotograma entero para la
respuesta. La foto da grano, nunca manda.

Todas de Wikimedia Commons con licencia de reuso, acreditadas en
creditosFotos. Descarté la del enredo de cables sobre una calle: bajo el
velo seguía leyéndose el rótulo de un bar ajeno.

El test del footer fijaba dos créditos a mano; ahora cuenta contra la
propia lista, que es el único sitio donde se hace cumplir la atribución.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-08-03 12:56:30 -06:00
1 parent 8f1fd2a74d
commit 156f78c5a8
9 files changed
+77 -14

No files matched your search

+53 -7
View File
@@ -4,6 +4,7 @@ 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';
@@ -13,6 +14,24 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
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">
@@ -102,6 +121,8 @@ function Escenificado() {
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">
@@ -112,20 +133,45 @@ function Escenificado() {
{/* 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">
<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>
{/* 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>
))}
{/* The frame itself turns over on the answer. The whole stage goes to
azul-profundo, not a panel inside it — the answer is not a card on
the reader's page, it is creeer's ground. */}
{/* "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.90]"
/>
<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"
className="absolute inset-0 bg-azul-profundo/[0.93]"
/>
<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