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

+14 -5
View File
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Footer } from '../Footer';
import { creditosFotos } from '@/lib/content';
describe('<Footer />', () => {
it('renders the tagline and the legal line', () => {
@@ -12,11 +13,19 @@ describe('<Footer />', () => {
render(<Footer />);
expect(screen.queryByRole('heading', { level: 1 })).toBeNull();
});
it('credits the stock photos per their CC BY-SA 4.0 licence', () => {
it('credits every reused photograph, with author, licence and modification notice', () => {
render(<Footer />);
expect(screen.getAllByText(/CC BY-SA 4\.0/)).toHaveLength(2);
expect(screen.getByText(/Golden Gate Bridge/)).toBeInTheDocument();
expect(screen.getByText(/Monte Nardi/)).toBeInTheDocument();
expect(screen.getAllByText(/modificada/).length).toBe(2);
// The licences these photographs carry require three things: naming the
// author, linking the licence, and saying the image was modified. This is
// the only place in the project where that obligation is enforced, so it
// counts against `creditosFotos` itself rather than a number typed here —
// adding a photograph without crediting it has to fail, and a hard-coded
// count only fails once someone remembers to update it.
for (const credito of creditosFotos) {
expect(credito.desc).toMatch(/—\s*\S/); // the author, after the em dash
expect(screen.getByText(credito.desc)).toHaveAttribute('href', credito.href);
expect(screen.getAllByText(credito.licencia).length).toBeGreaterThan(0);
}
expect(screen.getAllByText(/modificada/)).toHaveLength(creditosFotos.length);
});
});
+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
+10 -2
View File
@@ -181,9 +181,17 @@ export const footer = {
legal: '© 2026 creeer',
};
// CC BY-SA 4.0 requires attribution + a link to the licence + noting the
// image was modified (both got a navy/gold duotone treatment).
// Every reused photograph is credited here and the footer renders the list.
// CC BY-SA / CC BY require attribution + a link to the licence + noting the
// image was modified — all of them get the navy/gold duotone treatment, and the
// six in `recorrido` are additionally cropped and resized.
export const creditosFotos = [
{ desc: 'Foto: Golden Gate Bridge — Dietmar Rabich', href: 'https://commons.wikimedia.org/wiki/File:San_Francisco_(CA,_USA),_Golden_Gate_Bridge_--_2022_--_3023_(bw).jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: amanecer, Monte Nardi — Tony 1212', href: 'https://commons.wikimedia.org/wiki/File:Sunrise_over_the_summit_of_Mount_Nardi_from_the_south.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: montaña de Sheikh, Somalilandia — Abdifatah Y. Barwaaqo', href: 'https://commons.wikimedia.org/wiki/File:Sheikh_mountain,_Somaliland.jpg', licencia: 'dominio público', licenciaHref: 'https://commons.wikimedia.org/wiki/File:Sheikh_mountain,_Somaliland.jpg' },
{ desc: 'Foto: edificio en construcción — PattayaPatrol', href: 'https://commons.wikimedia.org/wiki/File:DFC_1635_Unfinished_concrete_building_with_exposed_floors_and_metal_scaffolding_rising_along_its_side_green_safety_netting_hanging_from_the_structure.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: cosechadora en el trigo — Michael Gäbler', href: 'https://commons.wikimedia.org/wiki/File:The_combine_Claas_Lexion_584_in_the_wheat_harvest.jpg', licencia: 'CC BY-SA 3.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/3.0/' },
{ desc: 'Foto: pájaros sobre cables — Alex Martinez', href: 'https://commons.wikimedia.org/wiki/File:Birds_On_Wires_(Unsplash).jpg', licencia: 'CC0', licenciaHref: 'https://creativecommons.org/publicdomain/zero/1.0/' },
{ desc: 'Foto: puerto de contenedores de Miami — James R. Tourtellotte', href: 'https://commons.wikimedia.org/wiki/File:Aerial_photograph_of_the_Port_of_Miami_Container_Port.jpg', licencia: 'dominio público', licenciaHref: 'https://commons.wikimedia.org/wiki/File:Aerial_photograph_of_the_Port_of_Miami_Container_Port.jpg' },
{ desc: 'Foto: Cracovia desde el aire — Robin', href: 'https://commons.wikimedia.org/wiki/File:Aerial_photograph_of_Krak%C3%B3w.jpg', licencia: 'CC BY 2.0', licenciaHref: 'https://creativecommons.org/licenses/by/2.0/' },
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB