
Persiana
GratisCristal y luz · WebGL
La pieza
Un paisaje de sierra pintado a mano en Canvas 2D, sin imágenes: cinco crestas generadas con ruido fBm, con perspectiva atmosférica, niebla en los valles, jirones que derivan y abetos en las lomas cercanas. Al bajar, cada capa se desplaza a su propia velocidad y el día avanza por cinco momentos: amanecer malva, mediodía azul, atardecer cobre, hora azul con las luces del pueblo encendiéndose y noche con luna llena, estrellas que titilan y alguna estrella fugaz. El texto cambia de capítulo en su sitio, un raíl inferior permite saltar a cada hora y un reloj acompaña el scroll. Con ratón, el paisaje responde al puntero; en táctil se mece solo. Encaja como portada de refugios, casas rurales, bodegas de montaña, rutas guiadas o eventos al aire libre. La paleta de cada momento, la forma de la cordillera (semilla) y el color de acento se cambian en una línea.
Encaja bien en webs de
Antes de copiar
Cambia lo que quieras y copia: el prompt sale ya con el nombre, los colores y los textos de tu cliente. Lo que dejes en blanco se queda como en la demo.
Gratis y entero
Qué trae el prompt, por apartados
Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.
# Recrea en un único archivo HTML: Sierra en capas
Un fondo a pantalla completa con cinco crestas de montaña generadas con ruido que, al bajar con el scroll, viajan en paralaje y pasan del amanecer a la noche cerrada, con niebla en los valles, abetos en la cresta más cercana, luces de un pueblo y luna llena al final.
Eres un desarrollador front-end creativo senior. Genera un único `index.html` autocontenido que
reproduzca exactamente la pieza descrita: mismo layout, textos, colores, movimiento e interacción.
HTML, CSS y JS en un solo archivo, sin build ni frameworks. Librerías solo por CDN y en las versiones
indicadas. Todos los valores numéricos de este documento son definitivos: úsalos tal cual.
Responde solo con el archivo completo.
## Qué es
Una escena fija de 100svh (un `<section class="escena">` con `position:fixed`) dentro de un recorrido de scroll de `500svh`. Todo el paisaje se pinta a mano en un `<canvas>` 2D, sin WebGL ni imágenes:
- **Cielo** en degradado vertical de cuatro paradas (cénit, medio, horizonte, horizonte).
- **Cinco capas de sierra** (0 = la más lejana, 4 = la más cercana). Cada cresta es una línea generada con ruido de valor 1D + fBm con un toque de "crestas" (ridged). El color de cada capa se obtiene mezclando el color de bruma del momento del día con el color "cerca" según su profundidad `k` (perspectiva atmosférica: lo lejano se funde con el cielo).
- **Niebla**: al pie de cada capa (salvo la última) hay un lago de niebla en degradado y dos jirones elípticos que derivan despacio en sentidos opuestos.
- **Abetos**: abetos de tres pisos, en manchas de bosque, sobre la cresta 4 (tamaño completo) y la cresta 3 (a la mitad).
- **Sol** con halo grande en modo `screen`, halo corto y disco; **luna llena** con tres "mares"; **estrellas** que titilan; una **estrella fugaz** cada 5–11 s de noche; **luces del pueblo** (30 puntos cálidos) en la ladera de la capa 2 que se encienden al anochecer.
- **Cinco momentos del día** repartidos a lo largo del scroll (0, 25, 50, 75 y 100 %): Amanecer 07:04, Mediodía 14:10, Atardecer 21:12, Hora azul 22:05 y Noche 23:50. Entre dos momentos hay una meseta: el color se queda quieto en el 20 % inicial y final de cada tramo y cambia con `smoothstep` en el 60 % central. El paralaje, en cambio, es continuo.
- **Paralaje al hacer scroll**: cada capa se desplaza en horizontal (el ruido se muestrea en `x + desplazamiento`, así que la sierra es infinita) y baja un poco en vertical, más cuanto más cerca está. Resultado: parece que la cámara viaja por la sierra y sube a medida que avanza el día, dejando más cielo para las estrellas.
- **Encima**: una barra superior (marca, coordenadas + reloj vivo, botón «Reservar»), cinco capítulos de texto que se sustituyen en el mismo sitio (título en serif a la izquierda, texto y botones a la derecha) y, abajo, un raíl del día con cinco hitos pulsables y una línea de progreso en el color de acento.
Sensación: editorial, sereno, cálido; una web de refugio de montaña de estudio premiado.
## Librerías y esqueleto
- GSAP 3.15.0 + ScrollTrigger + SplitText (los tres por jsDelivr, como `<script>` clásicos al final del `<body>`).
- Google Fonts: **Instrument Serif** (regular e itálica, para titulares y nombres del raíl), **Geist** 400/500 (texto) y **Geist Mono** 400/500 (datos, horas, cejas). Las tres tienen ñ, tildes, ¿ y ¡.
- Nada más: ni imágenes ni iconos externos. El grano es un SVG `feTurbulence` en línea como data URI.
Cabecera exacta del documento (el `<style>` completo va en la sección siguiente):
```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Sierra en capas · Refugio Abeto</title>
<meta name="description" content="Cinco crestas de la Sierra Alta que pasan del amanecer a la noche al hacer scroll.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500&family=Geist+Mono:wght@400;500&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<script>document.documentElement.classList.add('js')</script>
<style>
/* bloque :root de «Tokens de diseño» + «CSS completo» */
</style>
</head>
```
Al final del `<body>`, exactamente en este orden:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
<script>
/* aquí va, tal cual, el código completo de «Código clave» */
</script>
```
## Tokens de diseño
```css
:root{
--crema:#f7efe3;
--crema-2:rgba(247,239,227,.86);
--crema-3:rgba(247,239,227,.56);
--linea:rgba(247,239,227,.24);
--acento:#f2b279;
--tinta:#1a1216;
--noche:#05070e;
--f-display:"Instrument Serif","Times New Roman",serif;
--f-texto:"Geist",system-ui,-apple-system,"Segoe UI",sans-serif;
--f-mono:"Geist Mono",ui-monospace,"SFMono-Regular",Menlo,monospace;
--margen:clamp(20px,3.9vw,56px);
--salida:cubic-bezier(.16,1,.3,1);
}
```
### Paletas de los cinco momentos (se interpolan en el canvas)
| Momento | hora (min) | cénit | medio | horizonte | bruma | cerca | sol | solX | solY | solR | solA | luna | estrellas | luces | niebla |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Amanecer 07:04 | 424 | #1f2a4d | #6e4f66 | #f3bb8b | #d8a28e | #221a2b | #ffd9a0 | .72 | .47 | .034 | 1 | 0 | .12 | .1 | .6 |
| Mediodía 14:10 | 850 | #1b4a73 | #3f6e94 | #d6ddd3 | #aebfc0 | #1c2b27 | #fff8ea | .62 | .26 | .022 | 1 | 0 | 0 | 0 | .3 |
| Atardecer 21:12 | 1272 | #262240 | #86463a | #f59a52 | #d77c5a | #1f1320 | #ffc870 | .3 | .5 | .046 | 1 | 0 | 0 | .05 | .45 |
| Hora azul 22:05 | 1325 | #10183a | #2d3c6a | #9b8aa6 | #4f5b86 | #0e1328 | #ffb27a | .22 | .64 | .046 | 0 | .55 | .45 | .85 | .35 |
| Noche 23:50 | 1430 | #04060d | #0e1632 | #1d2a4c | #1a2644 | #05070e | #ffb27a | .2 | .7 | .04 | 0 | 1 | 1 | 1 | .25 |
Colores fijos del canvas: luna `#f2ead8`, mares de la luna `#c9bca6`, brillo lunar de las crestas `#9fb0d8`, luces del pueblo = color de acento `#f2b279` con núcleo `rgba(255,236,200,…)`, estrellas `rgba(255,246,230,…)`.
### Las cinco capas
| n | k (profundidad) | base | amp | frec | oct | cresta | viaje | baja | árboles |
|---|---|---|---|---|---|---|---|---|---|
| 0 | .16 | .60 | .15 | 1.5 | 5 | .9 | .05 | .015 | 0 |
| 1 | .34 | .66 | .13 | 2.0 | 5 | .7 | .11 | .03 | 0 |
| 2 | .54 | .72 | .12 | 2.5 | 4 | .45 | .19 | .045 | 0 |
| 3 | .76 | .79 | .10 | 2.9 | 4 | .25 | .30 | .06 | .5 |
| 4 | 1 | .88 | .09 | 2.1 | 3 | .1 | .46 | .08 | 1 |
`base` y `amp` son fracciones del alto en apaisado; `viaje` es el desplazamiento horizontal total (en unidades `U`) de 0 a 100 % de scroll; `baja`, el descenso vertical total (fracción del alto).
## Estructura y contenido
Árbol:
```
main.recorrido (500svh, solo aporta scroll)
└─ section.escena (fixed, 100svh) aria-label="Sierra Alta, del amanecer a la noche"
├─ div.lienzo > canvas#sierra (aria-hidden)
├─ div.velo (oscurece arriba a la izquierda y abajo; aria-hidden)
├─ div.grano (grano SVG al 5,5 %; aria-hidden)
├─ header.barra: a.marca (SVG + nombre) · p.reloj (coordenadas + hora viva) · a.btn-reserva
├─ div.capitulos: 5 × article.capitulo apilados en la misma celda de grid
│ cada uno: [ceja + título] | [párrafo (+ acciones) (+ contacto)]
└─ nav.rail: línea con relleno + ol de 5 botones (hora + nombre)
```
Marcado completo y literal del `<body>` (textos incluidos, respeta acentos, comillas y el símbolo «·»):
```html
<main class="recorrido">
<section class="escena" aria-label="Sierra Alta, del amanecer a la noche">
<div class="lienzo" aria-hidden="true"><canvas id="sierra"></canvas></div>
<div class="velo" aria-hidden="true"></div>
<div class="grano" aria-hidden="true"></div>
<header class="barra">
<a class="marca" href="#capitulo-1" data-ir="0" aria-label="Refugio Abeto, volver al amanecer">
<svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">
<path d="M2 17.5 8.5 10l4 4.5 5.5-7L26 17"/><path d="M2 22.5l6-4 5 2.5 6-4.5 7 5" opacity=".55"/>
</svg>
<span>Refugio Abeto</span>
</a>
<p class="reloj" aria-hidden="true"><span class="coord">28°41′ N · 62°59′ O · 1.919 m</span><span class="hora-viva">07:04</span></p>
<a class="btn-reserva" href="#capitulo-5" data-ir="4">Reservar</a>
</header>
<div class="capitulos">
<article class="capitulo" id="capitulo-1" aria-labelledby="t1">
<div>
<p class="ceja anim"><b>01</b><i></i>Amanecer · 07:04</p>
<h1 class="titulo" id="t1">La sierra se despierta <em>en capas</em>.</h1>
</div>
<div class="cap-texto">
<p class="anim">Desde la terraza de Refugio Abeto se ven cinco crestas, una detrás de otra. Baja despacio: el día entero pasa sobre ellas, del alba a la noche cerrada.</p>
<div class="acciones anim">
<a class="btn" href="#capitulo-5" data-ir="4">Reservar una noche <span aria-hidden="true">→</span></a>
<a class="enlace" href="#capitulo-2" data-ir="1">Ver el día entero</a>
</div>
</div>
</article>
<article class="capitulo" id="capitulo-2" aria-labelledby="t2">
<div>
<p class="ceja anim"><b>02</b><i></i>Mediodía · 14:10</p>
<h2 class="titulo" id="t2">A pleno sol, las distancias <em>se acortan</em>.</h2>
</div>
<div class="cap-texto">
<p class="anim">Es la hora del abetal: sombra densa, olor a resina y la subida a la cima, 1.919 m, con agua fresca en la fuente de la cresta.</p>
</div>
</article>
<article class="capitulo" id="capitulo-3" aria-labelledby="t3">
<div>
<p class="ceja anim"><b>03</b><i></i>Atardecer · 21:12</p>
<h2 class="titulo" id="t3">La caliza se vuelve <em>cobre</em>.</h2>
</div>
<div class="cap-texto">
<p class="anim">Hacia poniente, la última cresta se tiñe de naranja. Cenamos en la terraza a las nueve y media: guiso de la sierra, queso curado de oveja y vino tinto de la casa.</p>
</div>
</article>
<article class="capitulo" id="capitulo-4" aria-labelledby="t4">
<div>
<p class="ceja anim"><b>04</b><i></i>Hora azul · 22:05</p>
<h2 class="titulo" id="t4">Veinte minutos en que <em>todo es azul</em>.</h2>
</div>
<div class="cap-texto">
<p class="anim">Las crestas se funden con el cielo y, abajo en la ladera, se encienden una a una las luces del pueblo.</p>
</div>
</article>
<article class="capitulo" id="capitulo-5" aria-labelledby="t5">
<div>
<p class="ceja anim"><b>05</b><i></i>Noche · 23:50</p>
<h2 class="titulo" id="t5">Aquí arriba, la noche <em>es de verdad</em>.</h2>
</div>
<div class="cap-texto">
<p class="anim">Sin farolas en quince kilómetros. Te dejamos prismáticos y una manta de lana; el resto lo pone el cielo.</p>
<div class="acciones anim">
<a class="btn" href="mailto:hola@refugioabeto.com?subject=Reserva">Reservar · desde 68 € IVA incl. <span aria-hidden="true">→</span></a>
</div>
<p class="contacto anim"><a href="tel:+00 000 000 000">+00 000 000 000</a> · <a href="mailto:hola@refugioabeto.com">hola@refugioabeto.com</a></p>
</div>
</article>
</div>
<nav class="rail" aria-label="Momentos del día">
<div class="rail-linea" aria-hidden="true"><i></i></div>
<ol>
<li style="--i:0"><button type="button" data-ir="0" aria-current="step"><span class="r-hora">07:04</span><span class="r-nombre">Amanecer</span></button></li>
<li style="--i:1"><button type="button" data-ir="1"><span class="r-hora">14:10</span><span class="r-nombre">Mediodía</span></button></li>
<li style="--i:2"><button type="button" data-ir="2"><span class="r-hora">21:12</span><span class="r-nombre">Atardecer</span></button></li>
<li style="--i:3"><button type="button" data-ir="3"><span class="r-hora">22:05</span><span class="r-nombre">Hora azul</span></button></li>
<li style="--i:4"><button type="button" data-ir="4"><span class="r-hora">23:50</span><span class="r-nombre">Noche</span></button></li>
</ol>
</nav>
</section>
</main>
```
Notas del contenido:
- El título del capítulo 1 es el único `<h1>`; los demás son `<h2>`. La parte final de cada titular va en `<em>` (itálica de Instrument Serif, color `#fff4e4`).
- En el `href` de `tel:` escribe `+00 000 000 000` sin espacios.
- El reloj de la barra (`.hora-viva`) arranca en «07:04» y el JS lo actualiza.
### CSS completo
Va dentro de `<style>`, justo después del bloque `:root` de «Tokens de diseño».
```css
*{box-sizing:border-box;margin:0;padding:0}
html{background:var(--noche);-webkit-text-size-adjust:100%}
body{background:var(--noche);color:var(--crema);font-family:var(--f-texto);-webkit-font-smoothing:antialiased;overflow-x:hidden}
.recorrido{position:relative;height:500svh}
.escena{position:fixed;inset:0 0 auto;height:100svh;overflow:hidden;
background:linear-gradient(180deg,#1f2a4d 0%,#6e4f66 41%,#f3bb8b 64%,#b98a86 64.2%,#5a3e4c 76%,#221a2b 76.2%)}
.lienzo,.velo,.grano{position:absolute;inset:0}
.lienzo canvas{display:block;width:100%;height:100%}
.velo{z-index:1;pointer-events:none;background:
radial-gradient(120% 75% at 0% 0%,rgba(5,6,14,.36) 0%,rgba(5,6,14,.14) 38%,rgba(5,6,14,0) 62%),
linear-gradient(0deg,rgba(4,5,10,.55) 0%,rgba(4,5,10,0) 22%)}
.grano{z-index:1;pointer-events:none;opacity:.055;background-size:180px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%' height='100%' filter='url(%23g)'/></svg>")}
/* Barra superior */
.barra{position:absolute;z-index:3;inset:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:clamp(16px,2.8svh,28px) var(--margen)}
.marca{display:inline-flex;align-items:center;gap:10px;color:var(--crema);text-decoration:none;font-weight:500;font-size:15px;letter-spacing:-.01em;border-radius:6px}
.marca svg{width:26px;height:26px;flex:none}
.reloj{display:flex;gap:18px;font-family:var(--f-mono);font-size:12px;letter-spacing:.08em;color:var(--crema-3);font-variant-numeric:tabular-nums}
.hora-viva{color:var(--crema)}
.btn-reserva{font-size:14px;color:var(--crema);text-decoration:none;padding:10px 18px;border:1px solid var(--linea);border-radius:999px;
transition:background-color .4s var(--salida),color .4s var(--salida),border-color .4s var(--salida)}
.btn-reserva:hover{background:var(--crema);color:var(--tinta);border-color:var(--crema)}
/* Capítulos */
.capitulos{position:absolute;z-index:2;left:var(--margen);right:var(--margen);top:clamp(92px,15svh,160px);display:grid}
.capitulo{grid-area:1/1;display:grid;align-content:start;grid-template-columns:minmax(0,1fr) minmax(250px,340px);gap:clamp(24px,5vw,96px);align-items:end;pointer-events:none}
.js .capitulo{opacity:0}
.capitulo.activo{pointer-events:auto}
html:not(.js) .capitulo:not(:first-child){display:none}
.ceja{display:flex;align-items:center;gap:12px;margin-bottom:18px;font-family:var(--f-mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--crema-2)}
.ceja b{font-weight:500;color:var(--acento)}
.ceja i{width:28px;height:1px;background:var(--linea)}
.titulo{font-family:var(--f-display);font-weight:400;font-size:clamp(2.6rem,6.1vw,6.3rem);line-height:.98;letter-spacing:-.018em;text-wrap:balance;text-shadow:0 1px 30px rgba(6,8,18,.25)}
.titulo em{font-style:italic;color:#fff4e4}
.cap-texto p{font-size:clamp(15px,1.12vw,17px);line-height:1.56;color:var(--crema-2);max-width:36ch;text-wrap:pretty;text-shadow:0 1px 18px rgba(6,8,18,.35)}
.acciones{display:flex;flex-wrap:wrap;align-items:center;gap:14px 22px;margin-top:22px}
.btn{display:inline-flex;align-items:center;gap:10px;background:var(--acento);color:var(--tinta);text-decoration:none;font-weight:500;font-size:15px;padding:13px 20px;border-radius:999px;transition:transform .6s var(--salida),background-color .3s}
.btn span{display:inline-block;transition:transform .6s var(--salida)}
.btn:hover{transform:translateY(-2px);background:color-mix(in srgb,var(--acento) 78%,#fff)}
.btn:hover span{transform:translateX(4px)}
.enlace{color:var(--crema);font-size:15px;text-decoration:none;padding-bottom:3px;border-radius:2px;
background:linear-gradient(currentColor,currentColor) 0 100%/100% 1px no-repeat;transition:background-size .6s var(--salida)}
.enlace:hover{background-size:0 1px;background-position:100% 100%}
.contacto{margin-top:16px;font-family:var(--f-mono);font-size:12px;letter-spacing:.06em;color:var(--crema-2)}
.contacto a{color:inherit;text-decoration:none;border-bottom:1px solid var(--linea)}
.contacto a:hover{color:var(--crema);border-color:var(--crema)}
/* Raíl del día */
.rail{position:absolute;z-index:3;left:var(--margen);right:var(--margen);bottom:calc(clamp(10px,2.4svh,26px) + env(safe-area-inset-bottom));height:66px}
.rail-linea{position:absolute;left:0;right:0;top:0;height:1px;background:var(--linea)}
.rail-linea i{position:absolute;inset:0;background:var(--acento);transform-origin:0 50%;transform:scaleX(0)}
.rail ol{list-style:none}
.rail li{position:absolute;top:0;left:calc(var(--i) * 25%);transform:translateX(calc(var(--i) * -25%))}
.rail button{position:relative;display:flex;flex-direction:column;align-items:flex-start;gap:3px;min-height:48px;padding:15px 0 4px;background:none;border:0;border-radius:6px;color:var(--crema-3);font:inherit;text-align:left;cursor:pointer;transition:color .4s var(--salida)}
.rail li:nth-child(3) button{align-items:center;text-align:center}
.rail li:last-child button{align-items:flex-end;text-align:right}
.rail button::before{content:"";position:absolute;top:-4px;left:calc(var(--i) * 25% - 4px);width:9px;height:9px;border-radius:50%;box-sizing:border-box;background:#0b0d16;border:1px solid var(--crema-3);transition:background-color .4s,border-color .4s,box-shadow .4s}
.rail li:first-child button::before{left:0}
.rail li:last-child button::before{left:auto;right:0}
.rail li:nth-child(3) button::before{left:calc(50% - 4.5px)}
.rail button:hover,.rail button[aria-current]{color:var(--crema)}
.rail button[aria-current]::before{background:var(--acento);border-color:var(--acento);box-shadow:0 0 0 5px color-mix(in srgb,var(--acento) 22%,transparent)}
.r-hora{font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;font-variant-numeric:tabular-nums}
.r-nombre{font-family:var(--f-display);font-style:italic;font-size:20px;line-height:1}
:focus-visible{outline:2px solid var(--acento);outline-offset:4px}
@media (max-width:760px){
.capitulos{top:clamp(76px,11svh,104px)}
.capitulo{grid-template-columns:1fr;gap:16px;align-items:start}
.titulo{font-size:clamp(2.3rem,10.6vw,3.3rem);line-height:1}
.ceja{margin-bottom:14px;font-size:11px}
.acciones{margin-top:18px}
.btn{font-size:14px;padding:12px 18px}
.enlace{font-size:14px}
.coord{display:none}
.r-nombre{font-size:15px}
.r-hora{font-size:10px;letter-spacing:.06em}
}
@media (max-width:380px){
.marca{font-size:14px;gap:8px}
.marca svg{width:22px;height:22px}
.btn-reserva{padding:9px 14px;font-size:13px}
.r-nombre{font-size:14px}
}
@media (max-height:620px) and (min-width:761px){
.titulo{font-size:clamp(2.2rem,4.6vw,3.8rem)}
.capitulos{top:84px}
}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{transition-duration:.01ms!important}
}
```
## Movimiento
| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| `state.p` (progreso del día) | valor 0 → 1 | según scroll | `scrub: .6` | `none` | — | ScrollTrigger sobre `.recorrido`, `start:'top top'`, `end:'bottom bottom'` |
| Capas 0–4 (entrada) | `intro[n].v` 1 → 0 (desplaza la capa `H·(.22 + k·.35)` hacia abajo) | 1 → 0 | 1,9 s | `expo.out` | .1 s + stagger .09 s (de lejos a cerca) | carga |
| Sol (entrada) | `astro.v` 1 → 0 (sol `.18·H` más bajo) | 1 → 0 | 2,3 s | `power3.out` | 0 | carga |
| Capítulo saliente | `opacity`, `y` | 1 → 0, 0 → −12 px | .28 s | `power2.in` | 0 | cambia el capítulo activo |
| Palabras del titular entrante (SplitText `type:'words'`) | `opacity`, `yPercent` | 0 → 1, 55 → 0 | .85 s | `expo.out` | .1 s (.55 s la primera vez) + stagger .035 s | cambia el capítulo activo |
| `.anim` del capítulo entrante (ceja, párrafo, acciones, contacto) | `opacity`, `y` | 0 → 1, 16 → 0 px | .6 s | `power3.out` | retardo del titular + .08 s, stagger .06 s | cambia el capítulo activo |
| Paralaje horizontal de la capa n | desplazamiento del ruido | `p · viaje · U` px | continuo | lineal | — | scroll |
| Descenso de la capa n | `dy` | `p · baja · H` px | continuo | lineal | — | scroll |
| Paralaje de puntero | `off += mx·k·18`, `dy += my·k·8`; sol `mx·4`, luna `mx·3` | ±1 suavizado | amortiguación `1 − e^(−dt·2.4)` | — | — | ratón (en táctil, vaivén automático `sin(t·.11)·.5`, `sin(t·.07)·.3`) |
| Jirones de niebla | x | deriva `(7 + n·4) px/s`, alternando sentido | continuo | lineal | — | tiempo |
| Estrellas | alfa | `.55 + .45·sin(t·v + f)` | continuo | — | — | tiempo |
| Luces del pueblo | alfa | `.78 + .22·sin(t·v + f)` | continuo | — | — | tiempo |
| Estrella fugaz | trazo con degradado | recorrido de `.9·l` hacia abajo-izquierda | .9 s, alfa `sin(π·q)` | — | cada 5–11 s | solo si `estrellas > .7` |
| Botón `.btn` hover | `translateY`, flecha `translateX` | 0 → −2 px, 0 → 4 px | .6 s | `cubic-bezier(.16,1,.3,1)` | — | hover |
| Enlace `.enlace` hover | subrayado `background-size` | 100 % → 0 (sale por la derecha) | .6 s | `cubic-bezier(.16,1,.3,1)` | — | hover |
| Relleno del raíl | `scaleX` | = `state.p` | continuo | — | — | scroll |
Momento activo = `Math.round(p·4)`. El reloj interpola linealmente (sin meseta) entre las horas de los momentos: 424 → 850 → 1272 → 1325 → 1430 minutos.
## Código clave
Todo el JavaScript, literal. Va dentro del último `<script>`:
```js
(() => {
'use strict';
const SEMILLA = 1919;
const ACENTO = '#f2b279';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const G = window.gsap, ST = window.ScrollTrigger, SPLIT = window.SplitText;
const hayGsap = !!(G && ST);
const recorrido = $('.recorrido'), escena = $('.escena'), canvas = $('#sierra');
const caps = $$('.capitulo'), botones = $$('.rail button');
const relleno = $('.rail-linea i'), horaViva = $('.hora-viva');
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) { caps[0].style.opacity = 1; return; }
/* ---------- utilidades ---------- */
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const suave = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const hex = h => { const n = parseInt(h.slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; };
const mezcla = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
const rgba = (c, a = 1) => `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${a})`;
const reloj = m => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m) % 60).padStart(2, '0')}`;
function mulberry32(a) {
return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
const hash = n => { let t = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b); t ^= t >>> 13;
t = Math.imul(t, 0xc2b2ae35); t ^= t >>> 16; return (t >>> 0) / 4294967296; };
/* ---------- ruido 1D con fBm y crestas ---------- */
const azar = mulberry32(SEMILLA);
const TABLA = new Float32Array(256);
for (let i = 0; i < 256; i++) TABLA[i] = azar();
function ruido(x) {
const i = Math.floor(x), f = x - i, u = f * f * f * (f * (f * 6 - 15) + 10);
const a = TABLA[i & 255], b = TABLA[(i + 1) & 255];
return a + (b - a) * u;
}
function fbm(x, oct, cresta, desfase) {
let s = 0, amp = .5, fr = 1, n = 0;
for (let o = 0; o < oct; o++) {
let v = ruido(x * fr + desfase);
const r = 1 - Math.abs(v * 2 - 1);
v += (r * r - v) * cresta;
s += v * amp; n += amp; amp *= .5; fr *= 2.07;
}
return s / n;
}
/* ---------- fases del día (paletas) ---------- */
const FASES = [
{ hora: 424, cenit: '#1f2a4d', medio: '#6e4f66', horizonte: '#f3bb8b', bruma: '#d8a28e', cerca: '#221a2b', sol: '#ffd9a0', solX: .72, solY: .47, solR: .034, solA: 1, luna: 0, estrellas: .12, luces: .1, niebla: .6 },
{ hora: 850, cenit: '#1b4a73', medio: '#3f6e94', horizonte: '#d6ddd3', bruma: '#aebfc0', cerca: '#1c2b27', sol: '#fff8ea', solX: .62, solY: .26, solR: .022, solA: 1, luna: 0, estrellas: 0, luces: 0, niebla: .3 },
{ hora: 1272, cenit: '#262240', medio: '#86463a', horizonte: '#f59a52', bruma: '#d77c5a', cerca: '#1f1320', sol: '#ffc870', solX: .3, solY: .5, solR: .046, solA: 1, luna: 0, estrellas: 0, luces: .05, niebla: .45 },
{ hora: 1325, cenit: '#10183a', medio: '#2d3c6a', horizonte: '#9b8aa6', bruma: '#4f5b86', cerca: '#0e1328', sol: '#ffb27a', solX: .22, solY: .64, solR: .046, solA: 0, luna: .55, estrellas: .45, luces: .85, niebla: .35 },
{ hora: 1430, cenit: '#04060d', medio: '#0e1632', horizonte: '#1d2a4c', bruma: '#1a2644', cerca: '#05070e', sol: '#ffb27a', solX: .2, solY: .7, solR: .04, solA: 0, luna: 1, estrellas: 1, luces: 1, niebla: .25 }
];
const COLORES = ['cenit', 'medio', 'horizonte', 'bruma', 'cerca', 'sol'];
const NUMEROS = ['solX', 'solY', 'solR', 'solA', 'luna', 'estrellas', 'luces', 'niebla'];
FASES.forEach(f => COLORES.forEach(k => { f[k] = hex(f[k]); }));
const F = {};
function fase(p) {
const s = clamp(p, 0, 1) * 4, i = Math.min(3, Math.floor(s)), t = s - i, e = suave(.2, .8, t);
const a = FASES[i], b = FASES[i + 1];
for (const k of COLORES) F[k] = mezcla(a[k], b[k], e);
for (const k of NUMEROS) F[k] = a[k] + (b[k] - a[k]) * e;
F.s = s; F.hora = a.hora + (b.hora - a.hora) * t;
return F;
}
/* ---------- las cinco capas (0 = la más lejana) ---------- */
const CAPAS = [
{ k: .16, base: .60, amp: .15, frec: 1.5, oct: 5, cresta: .9, viaje: .05, baja: .015, arboles: 0 },
{ k: .34, base: .66, amp: .13, frec: 2.0, oct: 5, cresta: .7, viaje: .11, baja: .03, arboles: 0 },
{ k: .54, base: .72, amp: .12, frec: 2.5, oct: 4, cresta: .45, viaje: .19, baja: .045, arboles: 0 },
{ k: .76, base: .79, amp: .10, frec: 2.9, oct: 4, cresta: .25, viaje: .30, baja: .06, arboles: .5 },
{ k: 1, base: .88, amp: .09, frec: 2.1, oct: 3, cresta: .1, viaje: .46, baja: .08, arboles: 1 }
];
const LUZ = hex(ACENTO), LUNA = hex('#f2ead8'), MARES = hex('#c9bca6'), BRILLO_LUNA = hex('#9fb0d8');
/* ---------- estado ---------- */
let W = 1, H = 1, U = 1, dpr = 1, T = .44, K = 1;
const state = { p: 0 };
const intro = CAPAS.map(() => ({ v: reduce || !hayGsap ? 0 : 1 }));
const astro = { v: reduce || !hayGsap ? 0 : 1 };
let mx = 0, my = 0, tx = 0, ty = 0, raton = false, tiempo = 0;
let estrellas = [], luces = [], meteoro = null, proximo = 3;
const yv = f => (T + (f - .44) * K) * H;
function crestaY(c, n, x, off) {
const h = clamp((fbm((x + off) / U * c.frec, c.oct, c.cresta, n * 37.17) - .18) / .64, 0, 1);
return yv(c.base - c.amp * h);
}
function sembrar() {
const r = mulberry32(SEMILLA + 7);
const total = Math.min(420, Math.round(W * H / 4200));
estrellas = Array.from({ length: total }, () => ({
x: r(), y: r() * (T + .08), r: .35 + Math.pow(r(), 3) * 1.25, f: r() * 6.28, v: .6 + r() * 1.6
}));
const q = mulberry32(SEMILLA + 13);
luces = Array.from({ length: 30 }, () => ({
du: (q() + q() + q() - 1.5) * .055, dy: .006 + Math.pow(q(), 1.6) * .045, s: .7 + q() * 1.1, f: q() * 6.28, v: 1 + q() * 3
}));
}
function tamano() {
const r = escena.getBoundingClientRect();
W = Math.max(1, r.width); H = Math.max(1, r.height);
const tactil = matchMedia('(pointer: coarse)').matches;
dpr = Math.min(window.devicePixelRatio || 1, tactil || W < 768 ? 1.5 : 2);
canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
U = Math.max(W, H * 1.1);
T = H > W * 1.1 ? .52 : .44;
K = (1 - T) / .56;
sembrar();
}
/* ---------- dibujo ---------- */
function dibujar() {
const p = state.p, f = fase(p), movil = W < 700;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
// cielo
const cg = ctx.createLinearGradient(0, 0, 0, H);
cg.addColorStop(0, rgba(f.cenit));
cg.addColorStop(T - .03, rgba(f.medio));
cg.addColorStop(Math.min(1, T + .2), rgba(f.horizonte));
cg.addColorStop(1, rgba(f.horizonte));
ctx.fillStyle = cg;
ctx.fillRect(0, 0, W, H);
// estrellas
if (f.estrellas > .01) {
const deriva = p * U * .02;
for (const e of estrellas) {
const y = e.y * H;
const a = f.estrellas * (reduce ? .8 : .55 + .45 * Math.sin(tiempo * e.v + e.f)) * (1 - suave(.3, .95, e.y / (T + .08)));
if (a < .02) continue;
const x = ((e.x * W - deriva) % W + W) % W;
ctx.fillStyle = `rgba(255,246,230,${a.toFixed(3)})`;
ctx.beginPath(); ctx.arc(x, y, e.r, 0, 6.2832); ctx.fill();
}
if (meteoro) {
const q = meteoro.t / .9, a = Math.sin(Math.PI * clamp(q, 0, 1)) * f.estrellas;
const hx = meteoro.x - q * meteoro.l * .9, hy = meteoro.y + q * meteoro.l * .45;
const g = ctx.createLinearGradient(hx, hy, hx + meteoro.l * .36, hy - meteoro.l * .18);
g.addColorStop(0, `rgba(255,248,236,${(.9 * a).toFixed(3)})`);
g.addColorStop(1, 'rgba(255,248,236,0)');
ctx.strokeStyle = g; ctx.lineWidth = 1.2; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx + meteoro.l * .36, hy - meteoro.l * .18); ctx.stroke();
}
}
// sol
if (f.solA > .01) {
const sx = f.solX * W - p * U * .02 + mx * 4;
let sy = f.solY <= .44 ? f.solY * T / .44 * H : yv(f.solY);
if (H > W * 1.1) sy = Math.max(sy, .45 * H); // en vertical, el sol nunca queda detrás del texto
sy += astro.v * H * .18;
const R = f.solR * Math.min(W, H) * (movil ? 1.25 : 1);
ctx.globalCompositeOperation = 'screen';
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(W, H) * .7);
g.addColorStop(0, rgba(f.sol, .55 * f.solA));
g.addColorStop(.16, rgba(f.sol, .2 * f.solA));
g.addColorStop(1, rgba(f.sol, 0));
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
const h = ctx.createRadialGradient(sx, sy, R * .8, sx, sy, R * 3.6);
h.addColorStop(0, rgba(f.sol, .45 * f.solA)); h.addColorStop(1, rgba(f.sol, 0));
ctx.fillStyle = h; ctx.fillRect(sx - R * 3.6, sy - R * 3.6, R * 7.2, R * 7.2);
ctx.globalCompositeOperation = 'source-over';
const d = ctx.createRadialGradient(sx, sy, 0, sx, sy, R * 1.1);
d.addColorStop(0, `rgba(255,253,246,${f.solA})`);
d.addColorStop(.8, rgba(f.sol, f.solA));
d.addColorStop(1, rgba(f.sol, 0));
ctx.fillStyle = d; ctx.beginPath(); ctx.arc(sx, sy, R * 1.1, 0, 6.2832); ctx.fill();
}
// luna
if (f.luna > .01) {
const vert = H > W * 1.1;
const lx = (vert ? .8 : .62) * W - p * U * .01 + mx * 3, ly = (vert ? T + .005 : .42) * H;
const R = .024 * Math.min(W, H) * (movil ? 1.3 : 1);
ctx.globalCompositeOperation = 'screen';
const g = ctx.createRadialGradient(lx, ly, 0, lx, ly, R * 9);
g.addColorStop(0, rgba(LUNA, .22 * f.luna)); g.addColorStop(1, rgba(LUNA, 0));
ctx.fillStyle = g; ctx.fillRect(lx - R * 9, ly - R * 9, R * 18, R * 18);
ctx.globalCompositeOperation = 'source-over';
const disco = Math.min(1, f.luna * 1.8);
ctx.fillStyle = rgba(LUNA, disco);
ctx.beginPath(); ctx.arc(lx, ly, R, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(MARES, .35 * disco);
[[-.3, -.2, .32], [.28, .12, .22], [-.05, .38, .18]].forEach(([ox, oy, rr]) => {
ctx.beginPath(); ctx.arc(lx + ox * R, ly + oy * R, rr * R, 0, 6.2832); ctx.fill();
});
}
// capas
const paso = movil ? 2 : 3;
for (let n = 0; n < 5; n++) {
const c = CAPAS[n];
const off = p * c.viaje * U + mx * c.k * 18;
const dy = p * c.baja * H + intro[n].v * H * (.22 + c.k * .35) + my * c.k * 8;
const col = mezcla(f.bruma, f.cerca, c.k);
let minY = H;
ctx.beginPath(); ctx.moveTo(-paso, H + 2);
for (let x = -paso; x <= W + paso; x += paso) {
const y = crestaY(c, n, x, off) + dy;
if (y < minY) minY = y;
ctx.lineTo(x, y);
}
ctx.lineTo(W + paso, H + 2); ctx.closePath();
if (c.arboles) abetos(c, n, off, dy);
const rim = (.16 * (1 - c.k) + .03) * f.solA;
const top = mezcla(mezcla(col, f.sol, rim), BRILLO_LUNA, .06 * f.luna * (1 - c.k));
const lg = ctx.createLinearGradient(0, minY, 0, Math.min(H, minY + H * .38));
lg.addColorStop(0, rgba(top));
lg.addColorStop(1, rgba(mezcla(col, f.cerca, .35)));
ctx.fillStyle = lg; ctx.fill();
if (n === 2 && f.luces > .01) dibujarLuces(c, n, off, dy, f);
if (n < 4) {
const sig = CAPAS[n + 1];
const dy2 = p * sig.baja * H + intro[n + 1].v * H * (.22 + sig.k * .35) + my * sig.k * 8;
const yA = yv(c.base - c.amp * .5) + dy, yB = yv(sig.base - sig.amp * .3) + dy2;
const ng = ctx.createLinearGradient(0, yA - H * .04, 0, yB);
ng.addColorStop(0, rgba(f.bruma, 0));
ng.addColorStop(1, rgba(f.bruma, f.niebla * .75));
const fondo = Math.min(H, yv(sig.base) + dy2 + 4);
ctx.fillStyle = ng; ctx.fillRect(0, yA - H * .04, W, fondo - yA + H * .04);
// jirones de niebla
for (let w = 0; w < 2; w++) {
const vel = (7 + n * 4) * (w ? -1 : 1), rx = U * (.26 + .08 * w), ry = H * (.016 + .006 * n);
const span = W + rx * 2;
const x = (((tiempo * vel - off * .8 + (n * 2 + w) * U * .37) % span) + span) % span - rx;
const y = yB - H * (.012 + .018 * w);
ctx.save(); ctx.translate(x, y); ctx.scale(1, ry / rx);
const rg = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
rg.addColorStop(0, rgba(f.bruma, f.niebla * .42)); rg.addColorStop(1, rgba(f.bruma, 0));
ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(0, 0, rx, 0, 6.2832); ctx.fill();
ctx.restore();
}
}
}
}
// abetos: abetos de tres pisos sobre la cresta, en manchas de bosque (se suman al trazado de la capa)
function abetos(c, n, off, dy) {
const g = c.arboles < 1 ? 5 : 7, esc = c.arboles * clamp(H / 900, .8, 1.2);
const i0 = Math.floor((off - 20) / g), i1 = Math.ceil((off + W + 20) / g);
for (let i = i0; i <= i1; i++) {
const r1 = hash(i * 3 + n * 7919), r2 = hash(i * 3 + 1 + n * 7919), r3 = hash(i * 3 + 2 + n * 7919);
const dens = suave(.42, .7, ruido(i * g / U * 2.3 + 50 + n * 11));
if (r1 > dens * .85) continue;
const x = i * g - off + (r3 - .5) * g * .6;
const y = crestaY(c, n, x, off) + dy + 2;
const h = (9 + r2 * 16) * esc, w = h * .5;
ctx.moveTo(x, y - h);
ctx.lineTo(x + w * .28, y - h * .66); ctx.lineTo(x + w * .14, y - h * .66);
ctx.lineTo(x + w * .42, y - h * .33); ctx.lineTo(x + w * .26, y - h * .33);
ctx.lineTo(x + w * .5, y); ctx.lineTo(x - w * .5, y);
ctx.lineTo(x - w * .26, y - h * .33); ctx.lineTo(x - w * .42, y - h * .33);
ctx.lineTo(x - w * .14, y - h * .66); ctx.lineTo(x - w * .28, y - h * .66);
ctx.closePath();
}
}
function dibujarLuces(c, n, off, dy, f) {
const u0 = c.viaje + .26;
ctx.globalCompositeOperation = 'lighter';
for (const l of luces) {
const x = (u0 + l.du) * U - off;
if (x < -12 || x > W + 12) continue;
const y = crestaY(c, n, x, off) + dy + l.dy * H;
const a = f.luces * (reduce ? .9 : .78 + .22 * Math.sin(tiempo * l.v + l.f));
const r = l.s * 4;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(LUZ, .55 * a)); g.addColorStop(1, rgba(LUZ, 0));
ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2);
ctx.fillStyle = `rgba(255,236,200,${(.95 * a).toFixed(3)})`;
ctx.fillRect(x - l.s * .5, y - l.s * .5, l.s, l.s);
}
ctx.globalCompositeOperation = 'source-over';
}
/* ---------- interfaz: capítulos, raíl y reloj ---------- */
let activo = -1, preparado = false, horaMostrada = -1;
const palabras = caps.map(() => null);
function mostrar(i, primera) {
if (i === activo) return;
const prev = activo; activo = i;
caps.forEach((c, k) => c.classList.toggle('activo', k === i));
botones.forEach((b, k) => k === i ? b.setAttribute('aria-current', 'step') : b.removeAttribute('aria-current'));
if (!hayGsap || reduce) { caps.forEach((c, k) => { c.style.opacity = k === i ? 1 : 0; }); return; }
if (prev >= 0) {
G.killTweensOf(caps[prev]);
G.to(caps[prev], { opacity: 0, y: -12, duration: .28, ease: 'power2.in' });
}
const c = caps[i], d = primera ? .55 : .1;
G.killTweensOf(c);
G.set(c, { opacity: 1, y: 0 });
if (palabras[i]) {
G.fromTo(palabras[i], { opacity: 0, yPercent: 55 },
{ opacity: 1, yPercent: 0, duration: .85, ease: 'expo.out', stagger: .035, delay: d, overwrite: true });
}
G.fromTo($$('.anim', c), { opacity: 0, y: 16 },
{ opacity: 1, y: 0, duration: .6, ease: 'power3.out', stagger: .06, delay: d + .08, overwrite: true });
}
function ui() {
if (preparado) mostrar(Math.round(F.s));
relleno.style.transform = `scaleX(${state.p.toFixed(4)})`;
const m = Math.round(F.hora);
if (m !== horaMostrada) { horaMostrada = m; horaViva.textContent = reloj(m); }
}
const pintar = () => { dibujar(); ui(); };
function irA(i) {
const top = recorrido.offsetTop, max = recorrido.offsetHeight - innerHeight;
window.scrollTo({ top: top + max * i / 4, behavior: reduce ? 'auto' : 'smooth' });
}
$$('[data-ir]').forEach(el => el.addEventListener('click', e => { e.preventDefault(); irA(+el.dataset.ir); }));
caps.forEach((c, k) => c.addEventListener('focusin', () => { if (activo !== k) irA(k); }));
/* ---------- puntero (en táctil, vaivén automático) ---------- */
addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse') return;
raton = true;
tx = e.clientX / innerWidth * 2 - 1; ty = e.clientY / innerHeight * 2 - 1;
}, { passive: true });
/* ---------- bucle (solo con la escena visible) ---------- */
let corriendo = false, visible = true, ultimo = 0, ultimoDibujo = 0, pAnterior = -1;
function tick() {
const ahora = performance.now() / 1000, dt = Math.min(.05, ahora - ultimo); ultimo = ahora;
tiempo += dt;
if (!raton) { tx = Math.sin(tiempo * .11) * .5; ty = Math.sin(tiempo * .07) * .3; }
const k = 1 - Math.exp(-dt * 2.4);
mx += (tx - mx) * k; my += (ty - my) * k;
if (meteoro) { meteoro.t += dt; if (meteoro.t > .9) { meteoro = null; proximo = tiempo + 5 + Math.random() * 6; } }
else if (F.estrellas > .7 && tiempo > proximo) {
meteoro = { t: 0, x: W * (.4 + Math.random() * .5), y: H * T * (.12 + Math.random() * .4), l: U * .12 };
}
// sin scroll, intro ni puntero en marcha, la deriva ambiental basta con 30 fps
const quieto = Math.abs(state.p - pAnterior) < 1e-5 && intro[4].v < 1e-3 && Math.abs(tx - mx) + Math.abs(ty - my) < .004 && !meteoro;
if (quieto && ahora - ultimoDibujo < 1 / 30) return;
ultimoDibujo = ahora; pAnterior = state.p;
pintar();
}
function arrancar() {
if (corriendo || reduce || !hayGsap) return;
corriendo = true; ultimo = performance.now() / 1000; G.ticker.add(tick);
}
function parar() { if (!corriendo) return; corriendo = false; G.ticker.remove(tick); }
new IntersectionObserver(([en]) => {
visible = en.isIntersecting;
visible && !document.hidden ? arrancar() : parar();
}).observe(escena);
document.addEventListener('visibilitychange', () => { document.hidden ? parar() : visible && arrancar(); });
/* ---------- scroll ---------- */
const progresoManual = () => {
const max = recorrido.offsetHeight - innerHeight;
return max > 0 ? clamp((scrollY - recorrido.offsetTop) / max, 0, 1) : 0;
};
tamano();
if (hayGsap) {
G.registerPlugin(ST);
if (SPLIT) G.registerPlugin(SPLIT);
G.ticker.lagSmoothing(0); // las animaciones siguen el reloj real aunque un fotograma tarde
if (reduce) {
const st = ST.create({ trigger: recorrido, start: 'top top', end: 'bottom bottom', onUpdate: s => { state.p = s.progress; pintar(); } });
state.p = st.progress;
} else {
G.to(state, { p: 1, ease: 'none', scrollTrigger: { trigger: recorrido, start: 'top top', end: 'bottom bottom', scrub: .6 } });
G.to(intro, { v: 0, duration: 1.9, ease: 'expo.out', stagger: .09, delay: .1 });
G.to(astro, { v: 0, duration: 2.3, ease: 'power3.out' });
}
} else {
state.p = progresoManual();
addEventListener('scroll', () => { state.p = progresoManual(); pintar(); }, { passive: true });
}
pintar();
new ResizeObserver(() => { tamano(); pintar(); }).observe(escena);
const fuentes = document.fonts ? document.fonts.ready : Promise.resolve();
Promise.race([fuentes, new Promise(r => setTimeout(r, 1500))]).then(() => {
if (hayGsap && SPLIT && !reduce) {
caps.forEach((c, k) => { palabras[k] = SPLIT.create($('.titulo', c), { type: 'words' }).words; });
}
preparado = true;
fase(state.p);
mostrar(Math.round(F.s), true);
});
})();
```
Puntos delicados que no hay que cambiar:
- `U = max(W, H·1.1)` es la escala horizontal del ruido: en móvil se ve un tramo más corto de la misma sierra, así que los picos no se aprietan.
- `T` (0,44 en apaisado, 0,52 en vertical) es la línea donde empieza la banda de montañas; `yv()` remapea las fracciones de la tabla de capas a esa banda con `K = (1 − T)/.56`.
- La curva de cada cresta es `h = clamp((fbm − .18)/.64, 0, 1)` y la cresta queda en `yv(base − amp·h)`.
- Los abetos se añaden como subtrazados al mismo `path` de la capa antes del `fill`, así comparten degradado.
- Las luces viven en la capa 2 en `u0 = viaje + .26`: con el scroll al 100 % quedan al 26 % de `U` desde la izquierda.
- `G.ticker.lagSmoothing(0)`: las animaciones siguen el reloj real.
## Interacción
- **Scroll**: controla el momento del día, el paralaje, el capítulo activo, el reloj y el relleno del raíl.
- **Raíl, marca y botones internos** (`[data-ir]`): hacen `scrollTo` suave a `top + (alto del recorrido − innerHeight)·i/4` (sin suavizado si hay movimiento reducido). «Reservar» y «Reservar una noche» llevan al capítulo 5; «Ver el día entero», al 2; la marca, al 1.
- **Teclado**: todo es enlace o botón nativo; si el foco entra en un capítulo que no es el activo (`focusin`), la página se desplaza hasta él.
- **Puntero**: con ratón, paralaje suave (capas, sol, luna). En táctil no hay puntero: el paisaje se mece solo.
- El botón final es `mailto:hola@refugioabeto.com?subject=Reserva`; el contacto, `tel:` y `mailto:`.
## Adaptación a móvil y tablet
- **≤ 760 px**: capítulos en una columna (ceja, título, párrafo, botones), `top: clamp(76px,11svh,104px)`, título `clamp(2.3rem,10.6vw,3.3rem)` con `line-height:1`, se ocultan las coordenadas (queda solo la hora), nombres del raíl a 15 px y horas a 10 px.
- **≤ 380 px**: marca a 14 px con icono de 22 px, botón «Reservar» más compacto, nombres del raíl a 14 px.
- **Alto ≤ 620 px y ancho > 760 px**: título `clamp(2.2rem,4.6vw,3.8rem)` y capítulos a 84 px del borde.
- **Canvas en vertical** (`H > W·1.1`): `T = .52` (montañas más abajo, más cielo para el texto), la luna pasa a `(0,8·W, (T + .005)·H)` y el sol nunca sube por encima de `0,45·H`. En apaisado la luna está en `(0,62·W, 0,42·H)`.
- En `W < 700` el sol es 1,25× y la luna 1,3× más grandes, y la cresta se muestrea cada 2 px (3 px en escritorio).
- El raíl coloca cada hito exactamente en `i·25 %` del ancho: primer rótulo alineado a la izquierda, el central centrado y el último a la derecha.
- Sin scroll horizontal de 360 a 1920 px.
## Accesibilidad y movimiento reducido
- HTML semántico: `main`, `section` con `aria-label`, `header`, `article` con `aria-labelledby`, `nav` con `aria-label="Momentos del día"` y `ol` de botones; el botón del momento activo lleva `aria-current="step"`.
- Canvas, velo y grano con `aria-hidden="true"`; la barra de reloj también (la información está en las cejas).
- SplitText añade `aria-label` al titular y oculta las palabras sueltas al lector.
- Foco visible: `outline: 2px solid var(--acento); outline-offset: 4px`.
- Contraste: el texto crema (`#f7efe3`, párrafos al 86 %) va siempre sobre la mitad superior del cielo, que en todas las paletas es oscura, reforzada por el `.velo` y una `text-shadow` suave; el raíl va sobre la cresta más cercana, siempre oscura.
- `prefers-reduced-motion: reduce`: sin entrada de capas ni sol, sin bucle continuo (se pinta solo al hacer scroll o redimensionar), sin paralaje de puntero ni vaivén, estrellas y luces fijas, sin estrella fugaz, capítulos que cambian sin animación y transiciones CSS a .01 ms. Los colores sí siguen al scroll, sin suavizado.
- Sin JS: se ve el degradado CSS de `.escena` (amanecer con montañas en franjas) y solo el primer capítulo.
## Rendimiento
- DPR máximo 2 (1,5 en táctil o con `W < 768`); el canvas se redimensiona con `ResizeObserver`.
- Bucle en `gsap.ticker`, que se añade y se quita: `IntersectionObserver` sobre la escena y `visibilitychange` lo paran cuando no se ve.
- Si no hay scroll, entrada ni puntero en movimiento, el bucle baja a 30 fps (la deriva ambiental no necesita más).
- Sin WebGL: no hace falta fallback de contexto; si `getContext('2d')` fallase, se muestra el primer capítulo sobre el degradado CSS.
- Si GSAP no carga, el progreso se calcula a mano con `scroll` y se pinta sin animaciones.
- La niebla de cada tramo se rellena solo hasta el valle más profundo de la capa siguiente (menos sobrepintado).
- HTML final ≈ 33 KB.
## Personalización
- **Marca y datos**: `Refugio Abeto`, `hola@refugioabeto.com`, `+00 000 000 000`, `68 €`, `Sierra Alta` y `28°41′ N · 62°59′ O · 1.919 m` aparecen donde indica el marcado. Si cambias de lugar, reescribe también los párrafos (la fuente de la cresta, el queso curado de oveja, el vino tinto de la casa) con referencias reales del nuevo sitio.
- **Color de acento** (`#f2b279`): botón principal, números de las cejas, hito activo y relleno del raíl, foco y luces del pueblo. El hover del botón y el halo del hito se derivan con `color-mix`.
- **Forma de la sierra**: cambia `1919` (cualquier entero) para otra cordillera; `amp` sube o baja los picos, `frec` los junta, `cresta` (0–1) los afila.
- **Paleta**: edita la tabla `FASES`. Mantén `medio` oscuro (luminancia < 0,15) para que el texto crema siga teniendo contraste AA.
- **Duración del recorrido**: `.recorrido{height:500svh}`; más alto, el día pasa más despacio.
- **Como fondo de otra web**: cambia `.escena` a `position:sticky; top:0` dentro de su sección y la pieza queda confinada a ese tramo.
## Comprobación final
1. Un solo archivo, `lang="es"`, sin errores en consola y sin recursos externos salvo Google Fonts y los tres scripts de GSAP 3.15.0.
2. Al cargar, las cinco crestas suben escalonadas desde abajo (de lejos a cerca) y el sol asciende; el titular entra palabra a palabra.
3. Al 0 % de scroll: amanecer malva y albaricoque, sol a la derecha asomando sobre la primera cresta, reloj «07:04».
4. Al 25 %: cielo azul, montañas lejanas gris verdoso casi blanco, sol alto y blanco entre el titular y el párrafo, «14:10».
5. Al 50 %: atardecer cobre y naranja, sol grande a la izquierda rozando la cresta lejana, «21:12».
6. Al 75 %: hora azul, luna apareciendo, luces del pueblo encendidas en la ladera, «22:05».
7. Al 100 %: noche con estrellas titilando, luna llena con mares, estrella fugaz ocasional, «23:50», relleno del raíl completo.
8. Las capas se desplazan en horizontal a distinta velocidad (la cercana 9 veces más que la lejana) y bajan al avanzar el día.
9. Hay niebla al pie de cada cresta y jirones que se mueven; abetos de tres pisos en las dos crestas cercanas.
10. El texto cambia de capítulo en su sitio con fundido; nunca se leen dos capítulos a la vez en reposo.
11. Los hitos del raíl llevan al momento correcto con scroll suave y reciben foco visible con teclado.
12. A 390 × 844 el texto ocupa la mitad superior en una columna, las montañas la inferior, el sol nunca queda detrás del texto; sin scroll horizontal a 360 px.
13. Con movimiento reducido no hay bucle, entradas ni paralaje, y cada posición de scroll muestra un fotograma estático legible.
14. Con la pestaña oculta el bucle se detiene.Prueba en frío
Pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos el resultado con la demo. Prueba hecha el 30-09-2026.

Calco prácticamente exacto: las mismas crestas, paletas, sol, luna, luces del pueblo, niebla, pinsapos, textos y raíl en escritorio y en móvil, con el mismo movimiento de scroll, entrada y puntero. Solo varía lo aleatorio en vivo, como el sitio donde cae la estrella fugaz.

La escena se reconoce enseguida: mismas crestas, colores de los cinco momentos, sol, luna y titulares. Cambian los detalles: la niebla corta en bandas rectas, hay menos pinsapos, las luces del pueblo son manchas borrosas, las coordenadas se van a la derecha y en móvil el raíl pierde los nombres y el sol roza el enlace.
Solo publicamos lo que hemos probado. Otras herramientas (ChatGPT, Gemini, Lovable…) pueden dar resultados distintos.
Paso a paso
Personaliza
Copia el Completo o el Lite
Pega e itera
Publica
Licencia en claro
Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.
Puedes
No puedes
Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.
Preguntas frecuentes
Versión 1.0 · publicada el 30-09-2026