Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

Un fondo WebGL a pantalla completa pintado con un solo fragment shader: el sol entra por una persiana y dibuja sobre una pared encalada un recuadro sesgado de franjas con penumbra realista, parteluz, cordones y un fleco cálido en los bordes. La sombra de tres ramas con hojas se mece con la brisa y solo aparece dentro de la luz, y unas motas de polvo brillan en las franjas. Al hacer scroll cae la tarde: la luz sube por la pared, se estira y se vuelve dorada mientras un reloj avanza de 16:10 a 19:50. Incluye un mando para abrir o cerrar las lamas y otro para pausar la brisa. Encaja en heros de hoteles rurales, casas de huéspedes, restaurantes con jardín, estudios de interiorismo o marcas de cosmética natural. Funciona con paredes de cal blanca, almagra o azul añil lavado.

Prompt
≈12.561 tokens · con Lite
Librerías
ninguna
Demo
28,4 KB · ligero
Prueba en frío
9,8/10 · 30-09-2026

Antes de copiar

Tu marca, dentro del prompt

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.

Colores

Sin cambios: se copia con los valores de la demo.

Gratis y entero

El prompt, entero y a la vista

Qué trae el prompt, por apartados

  1. 01Qué es
  2. 02Librerías y esqueleto
  3. 03Tokens de diseño
  4. 04Estructura y contenido
  5. 05Movimiento
  6. 06Código clave
  7. 07Interacción
  8. 08Adaptación a móvil y tablet
  9. 09Accesibilidad y movimiento reducido
  10. 10Rendimiento
  11. 11Personalización
  12. 12Comprobación final
Completo
5.947 palabras · ≈12.561 tokens
Lite
1.289 palabras · ≈2.687 tokens
Librerías
ninguna
Peso de la demo
28,4 KB (ligero)
Resolución máxima (DPR)
2
Se pausa fuera de pantalla
sí
Respeta «menos movimiento»
sí
Alternativa sin WebGL
sí

Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.

prompt.md
# Recrea en un único archivo HTML: Luz de persiana

Franjas de sol de una persiana entran sesgadas sobre una pared de cal cálida, con la sombra de unas hojas que se mecen: un fondo de hero con sensación de tarde de verano, donde al hacer scroll cae la tarde.

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 landing de una sola página para **Casa Albero**, casa de huéspedes en Alvera (Valle Claro), cuyo protagonista es el fondo: un `<canvas>` WebGL fijo a pantalla completa que pinta, con un único fragment shader, una pared encalada iluminada por el sol que atraviesa una persiana de lamas.

Lo que se ve en el fondo, de abajo arriba:

1. **La pared**: color arena cálido (`#e9dcc6`) con manchas muy suaves de enlucido de cal (dos capas de fbm), un grano fino estático y un micro relieve que solo aparece dentro de la luz (como cuando el sol entra rasante).
2. **El recuadro de luz**: un paralelogramo (la imagen de la ventana proyectada) con un leve trapecio, dividido por un parteluz vertical grueso en dos hojas y cruzado por cuatro cordones finos de la persiana. Dentro, 12 franjas de luz por altura de ventana, con penumbra que se ensancha cuanto más lejos queda de la ventana, un fleco cálido en los bordes (el rojo tiene más penumbra que el azul) y una pequeña irregularidad por lama (lamas un poco desiguales y combadas).
3. **Las hojas**: tres ramas que cuelgan desde fuera de la ventana (arriba a la derecha del recuadro), con 11 hojas lanceoladas cada una, tipo olivo o laurel. La rama más lejana está más desenfocada. Se mecen con una brisa hecha de senos (ráfagas lentas) y cada hoja aletea un poco por su cuenta. Su sombra solo se ve donde hay luz.
4. **Detalles**: un halo de luz rebotada alrededor del recuadro, un rebote cálido del suelo en la parte baja de la pantalla, motas de polvo que brillan solo dentro de las franjas, viñeta suave y grano.

**Scroll = hora del día**: el progreso de scroll de la página (0 → 1) es `uDay`. Al bajar, el recuadro de luz sube por la pared, se estira y gira un poco, la penumbra crece, la luz se vuelve dorada anaranjada y la sombra de la pared más rosada. Un reloj en el hero pasa de **16:10** a **19:50**.

Encima, una landing editorial en tinta marrón oscura (`#2a1d14`) directamente sobre la pared, sin tarjetas: hero con titular enorme en serif, sección «La casa» con tres columnas, y bloque de reserva con los datos de contacto. En el hero hay un mando para abrir y cerrar las lamas y un botón para pausar la brisa.

## Librerías y esqueleto

- Sin librerías de JS. WebGL 1 a mano con un triángulo a pantalla completa.
- Fuentes de Google (tienen ñ, tildes, ¿ y ¡): **Instrument Serif** (400 y 400 cursiva) para display y **Hanken Grotesk** (400, 500, 600) para texto. Pega esto literal en el `<head>`:

```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>Casa Albero · Luz de persiana</title>
<meta name="description" content="Casa de huéspedes en Alvera: seis habitaciones encaladas, jardín con naranjo y luz de persiana.">
<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=Hanken+Grotesk:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<style>
  /* tokens + estilos: ver abajo */
</style>
</head>
```

- Justo después de abrir `<body>` va un script en línea que marca el documento: `<script>document.documentElement.classList.add("js","intro")</script>`. La clase `intro` oculta las piezas del hero hasta que el JS la quita (entrada) y `js` activa los estados iniciales de las animaciones de aparición.
- El `<canvas id="luz">` y un `<div class="respaldo">` (fallback sin WebGL) van antes de la cabecera, ambos con `aria-hidden="true"`.
- Todo el JS va en un único `<script>` clásico al final del `<body>`, dentro de una IIFE.

## Tokens de diseño

```css
:root{
  --pared:#e9dcc6;
  --sol:#ffe2b3;
  --tinta:#2a1d14;
  --pared-sombra:color-mix(in srgb, var(--pared) 84%, #5a4a44);
  --linea:color-mix(in srgb, var(--tinta) 24%, transparent);
  --f-display:"Instrument Serif", "Times New Roman", serif;
  --f-texto:"Hanken Grotesk", system-ui, sans-serif;
  --ease-out:cubic-bezier(.19,1,.22,1);
  --ease-suave:cubic-bezier(.65,.05,.36,1);
  --margen:clamp(20px, 4.2vw, 64px);
}
```

- `--pared` y `--sol` los lee el JS con `getComputedStyle` y los pasa al shader como `uWall` y `uSun`: si cambias el color en CSS, cambia la luz.
- `--pared-sombra` es el fondo del `body` (lo que se ve antes de que pinte el canvas) y la base del fallback.
- Toda la tipografía y los filetes van en `--tinta`, sin opacidades. La pared en sombra queda en torno a `#c1b198` y la tinta sobre ella da un contraste de ~7,8:1.

## Estructura y contenido

Árbol:

```
body
├─ script en línea (clases js + intro)
├─ a.saltar «Saltar al contenido»
├─ canvas#luz (fijo, z 0)            ← el fondo WebGL
├─ div.respaldo (fijo, z 0)          ← solo con .sin-webgl
├─ header.barra (z 2)                ← marca · nav · «Reservar»
├─ main#contenido (z 1)
│  ├─ section.hero#inicio            ← antetítulo, h1, entradilla, acciones · pie: hora, pista, mandos
│  ├─ section.casa#casa              ← etiqueta, h2, lista de tres piezas
│  └─ section.reservar#reservar      ← h2, lista de contacto (dl)
└─ footer.pie                        ← © y enlaces legales
```

HTML literal del `<body>` (sin el script final). En los `href="tel:…"` escribe +00 000 000 000 **sin espacios** (p. ej. `tel:+00000000000`):

```html
<body>
<script>document.documentElement.classList.add("js","intro")</script>
<a class="saltar" href="#contenido">Saltar al contenido</a>
<canvas id="luz" aria-hidden="true"></canvas>
<div class="respaldo" aria-hidden="true"></div>

<header class="barra">
  <a class="marca" href="#inicio" aria-label="Casa Albero, inicio">
    <svg viewBox="0 0 22 26" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M.7 25.3V11a10.3 10.3 0 0 1 20.6 0v14.3z"/><path d="M4 10h14M3.2 14h15.6M3.2 18h15.6M4 22h14M11 1v24"/></svg>
    <span>Casa Albero</span>
  </a>
  <nav aria-label="Principal">
    <ul>
      <li><a href="#casa">Habitaciones</a></li>
      <li><a href="#casa">Jardín</a></li>
      <li><a href="#casa">Desayunos</a></li>
      <li><a href="#reservar">Cómo llegar</a></li>
    </ul>
  </nav>
  <a class="boton boton--borde" href="#reservar">Reservar</a>
</header>

<main id="contenido">
  <section class="hero" id="inicio" aria-labelledby="titulo">
    <div class="hero__texto">
      <p class="antetitulo"><span class="raya" aria-hidden="true"></span>Casa de huéspedes · Alvera (Valle Claro)</p>
      <h1 id="titulo"><span class="linea"><span>Aquí la tarde</span></span><span class="linea"><em>se toma su tiempo.</em></span></h1>
      <p class="entradilla">Seis habitaciones encaladas en el casco antiguo, persianas de madera y un jardín con naranjo. Se desayuna hasta las doce y la tarde no tiene hora.</p>
      <div class="acciones">
        <a class="boton boton--lleno" href="#casa">Ver habitaciones
          <svg width="16" height="10" viewBox="0 0 16 10" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M0 5h14.5M10.5 1l4 4-4 4"/></svg></a>
        <a class="enlace" href="tel:+00 000 000 000">Llamar al +00 000 000 000</a>
      </div>
    </div>
    <div class="hero__pie">
      <p class="hora"><span class="hora__num" id="hora">16:10</span><span>en Alvera · la luz entra a rayas</span></p>
      <p class="pista" aria-hidden="true">Desliza y cae la tarde</p>
      <div class="mandos" role="group" aria-label="Control de la luz">
        <label for="lamas">Lamas</label>
        <input id="lamas" type="range" min="10" max="90" step="1" value="52" aria-valuetext="52 % abiertas">
        <button id="brisa" type="button"><svg viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><rect x="2" y="1" width="3" height="10"/><rect x="7" y="1" width="3" height="10"/></svg><span>Pausar brisa</span></button>
      </div>
    </div>
  </section>

  <section class="casa" id="casa" aria-labelledby="casa-titulo">
    <p class="etiqueta aparece"><span class="raya" aria-hidden="true"></span>01 — La casa</p>
    <h2 id="casa-titulo" class="aparece" style="--i:1">La luz entra a rayas a las cuatro <em>y se marcha pasadas las ocho.</em></h2>
    <ul class="piezas">
      <li class="aparece" style="--i:2">
        <span class="num">i.</span>
        <h3>Seis habitaciones</h3>
        <p>Paredes de cal, suelos de barro cocido y persianas de lamas que se suben y se bajan a mano.</p>
        <p class="precio">Desde 118 € la noche · IVA incl.</p>
      </li>
      <li class="aparece" style="--i:3">
        <span class="num">ii.</span>
        <h3>Jardín con naranjo</h3>
        <p>Sombra a mediodía, un pozo con agua fresca y hamacas de lona para después de comer.</p>
      </li>
      <li class="aparece" style="--i:4">
        <span class="num">iii.</span>
        <h3>Desayuno hasta las doce</h3>
        <p>Pan de masa madre, aceite de la comarca y zumo de las naranjas del propio jardín.</p>
      </li>
    </ul>
  </section>

  <section class="reservar" id="reservar" aria-labelledby="reservar-titulo">
    <h2 id="reservar-titulo" class="aparece">¿Te guardamos <em>la tarde?</em></h2>
    <dl class="contacto aparece" style="--i:1">
      <div><dt>Teléfono</dt><dd><a href="tel:+00 000 000 000">+00 000 000 000</a></dd></div>
      <div><dt>Correo</dt><dd><a href="mailto:hola@casaalbero.com">hola@casaalbero.com</a></dd></div>
      <div><dt>Dirección</dt><dd>Rue de la Chaux, 7 · 10400 Alvera (Valle Claro)</dd></div>
    </dl>
  </section>
</main>

<footer class="pie">
  <p style="margin:0">© 2026 Casa Albero · Alvera</p>
  <ul><li><a href="#inicio">Aviso legal</a></li><li><a href="#inicio">Privacidad</a></li></ul>
</footer>
```

### Estilos (después del bloque `:root`, literal)

```css
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
body{margin:0;background:var(--pared-sombra);color:var(--tinta);font:400 17px/1.55 var(--f-texto);
  -webkit-font-smoothing:antialiased;overflow-x:hidden}
a{color:inherit}
:focus-visible{outline:2px solid var(--tinta);outline-offset:4px;border-radius:2px}
.saltar{position:absolute;left:12px;top:-60px;z-index:20;background:var(--tinta);color:var(--pared);
  padding:10px 14px;text-decoration:none;font-weight:500;transition:top .3s var(--ease-out)}
.saltar:focus{top:12px}

/* Fondo */
#luz,.respaldo{position:fixed;inset:0;width:100%;height:100%;display:block;z-index:0}
.respaldo{display:none;background:
  repeating-linear-gradient(-26deg, transparent 0 34px, color-mix(in srgb, var(--sol) 55%, transparent) 34px 64px),
  linear-gradient(160deg, var(--pared) 0%, var(--pared-sombra) 100%)}
.sin-webgl #luz{display:none}
.sin-webgl .respaldo{display:block}

/* Barra */
.barra{position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:24px;
  padding:26px var(--margen)}
.marca{display:flex;align-items:center;gap:12px;text-decoration:none;font:400 27px/1 var(--f-display);letter-spacing:.005em}
.marca svg{width:22px;height:26px;flex:none}
.barra ul{display:flex;gap:34px;list-style:none;margin:0;padding:0}
.barra nav a{text-decoration:none;font-size:14px;font-weight:500;letter-spacing:.02em;position:relative;padding:6px 0}
.barra nav a::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:currentColor;
  transform:scaleX(0);transform-origin:right;transition:transform .5s var(--ease-out)}
.barra nav a:hover::after{transform:scaleX(1);transform-origin:left}
.boton{display:inline-flex;align-items:center;gap:10px;min-height:46px;padding:0 22px;border-radius:999px;
  font:500 15px/1 var(--f-texto);letter-spacing:.01em;text-decoration:none;
  transition:background-color .35s var(--ease-out),color .35s var(--ease-out),transform .35s var(--ease-out)}
.boton--borde{border:1px solid var(--tinta)}
.boton--borde:hover{background:var(--tinta);color:var(--pared)}
.boton--lleno{background:var(--tinta);color:var(--pared)}
.boton--lleno:hover{transform:translateY(-2px)}
.boton--lleno svg{transition:transform .45s var(--ease-out)}
.boton--lleno:hover svg{transform:translateX(4px)}

/* Hero */
main{position:relative;z-index:1}
.hero{min-height:100svh;margin-top:-98px;padding:120px var(--margen) 28px;display:flex;flex-direction:column;justify-content:space-between;gap:40px}
.hero__texto{max-width:840px;margin-top:clamp(20px, 9svh, 110px)}
.antetitulo{display:flex;align-items:center;gap:14px;margin:0 0 26px;font-size:12px;font-weight:600;
  letter-spacing:.18em;text-transform:uppercase}
.raya{width:38px;height:1px;background:currentColor;transform-origin:left}
h1{margin:0;font:400 clamp(50px, 7.4vw, 126px)/.9 var(--f-display);letter-spacing:-.012em}
h1 .linea{display:block;overflow:hidden;padding-bottom:.08em;margin-bottom:-.08em}
h1 .linea>*{display:block}
h1 em{font-style:italic}
.entradilla{max-width:43ch;margin:30px 0 0;font-size:18px;line-height:1.55;text-wrap:pretty}
.acciones{display:flex;flex-wrap:wrap;align-items:center;gap:14px 28px;margin-top:34px}
.enlace{font-weight:500;font-size:15px;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:5px}
.enlace:hover{text-decoration-thickness:2px}

.hero__pie{display:grid;grid-template-columns:1fr auto 1fr;align-items:end;gap:20px}
.hora{margin:0;font-size:13px;letter-spacing:.02em;display:flex;align-items:baseline;gap:10px}
.hora__num{font:400 34px/1 var(--f-display);font-variant-numeric:tabular-nums;letter-spacing:0}
.pista{margin:0;justify-self:center;display:flex;flex-direction:column;align-items:center;gap:10px;font-size:12px;
  font-weight:600;letter-spacing:.16em;text-transform:uppercase}
.pista::after{content:"";width:1px;height:38px;background:linear-gradient(var(--tinta),transparent);
  animation:caer 2.4s var(--ease-suave) infinite}
@keyframes caer{0%{transform:scaleY(0);transform-origin:top}45%{transform:scaleY(1);transform-origin:top}
  55%{transform:scaleY(1);transform-origin:bottom}100%{transform:scaleY(0);transform-origin:bottom}}
.mandos{justify-self:end;display:flex;align-items:center;gap:16px;padding:8px 8px 8px 20px;border-radius:999px;
  background:color-mix(in srgb, var(--pared) 88%, #fff);border:1px solid var(--linea)}
.mandos label{font-size:13px;font-weight:600;letter-spacing:.04em}
.mandos input[type=range]{-webkit-appearance:none;appearance:none;width:130px;height:28px;background:transparent;margin:0;cursor:pointer}
.mandos input[type=range]::-webkit-slider-runnable-track{height:1px;background:var(--tinta)}
.mandos input[type=range]::-moz-range-track{height:1px;background:var(--tinta)}
.mandos input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;margin-top:-7.5px;border-radius:50%;
  background:var(--tinta);border:3px solid color-mix(in srgb, var(--pared) 88%, #fff);box-shadow:0 0 0 1px var(--tinta)}
.mandos input[type=range]::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:var(--tinta);
  border:3px solid color-mix(in srgb, var(--pared) 88%, #fff);box-shadow:0 0 0 1px var(--tinta)}
.mandos input[type=range]:focus-visible{outline-offset:2px}
.mandos button{display:inline-flex;align-items:center;gap:8px;min-height:40px;padding:0 16px;border:0;border-radius:999px;
  background:var(--tinta);color:var(--pared);font:500 13px/1 var(--f-texto);cursor:pointer;transition:opacity .3s}
.mandos button:hover{opacity:.86}
.mandos button svg{width:12px;height:12px}

/* Secciones */
.casa{min-height:100svh;padding:clamp(90px, 16svh, 180px) var(--margen) 90px;display:grid;
  grid-template-columns:repeat(12, minmax(0,1fr));column-gap:24px;align-content:center}
.etiqueta{grid-column:1 / -1;margin:0 0 28px;font-size:12px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;
  display:flex;align-items:center;gap:14px}
.casa h2{grid-column:1 / span 9;margin:0;font:400 clamp(40px, 5.4vw, 86px)/.98 var(--f-display);letter-spacing:-.01em;text-wrap:balance}
.casa h2 em{font-style:italic}
.piezas{grid-column:1 / -1;display:grid;grid-template-columns:repeat(3, minmax(0,1fr));gap:24px;margin:clamp(56px, 10svh, 110px) 0 0;
  padding:0;list-style:none}
.piezas li{border-top:1px solid var(--tinta);padding-top:20px}
.piezas .num{font:italic 400 22px/1 var(--f-display)}
.piezas h3{margin:14px 0 10px;font:400 32px/1.05 var(--f-display)}
.piezas p{margin:0;max-width:34ch}
.piezas .precio{margin-top:16px;font-weight:600;font-size:15px}

.reservar{min-height:92svh;padding:120px var(--margen) 60px;display:flex;flex-direction:column;justify-content:center}
.reservar h2{margin:0 0 50px;font:400 clamp(48px, 9vw, 150px)/.9 var(--f-display);letter-spacing:-.015em}
.reservar h2 em{font-style:italic}
.contacto{display:grid;grid-template-columns:repeat(3, minmax(0,1fr));gap:24px;margin:0;border-top:1px solid var(--tinta);padding-top:22px}
.contacto div{min-width:0}
.contacto dt{font-size:12px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;margin-bottom:10px}
.contacto dd{margin:0;font:400 clamp(24px, 2.4vw, 34px)/1.15 var(--f-display);overflow-wrap:anywhere}
.contacto dd a{text-decoration:none;background:linear-gradient(currentColor,currentColor) 0 100%/0 1px no-repeat;
  transition:background-size .5s var(--ease-out)}
.contacto dd a:hover{background-size:100% 1px}
.pie{position:relative;z-index:1;display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;
  padding:22px var(--margen) 30px;font-size:13px}
.pie a{text-underline-offset:4px}
.pie ul{display:flex;gap:22px;list-style:none;margin:0;padding:0}

/* Entradas */
.js .aparece{opacity:0;transform:translateY(26px);transition:opacity 1.1s var(--ease-out),transform 1.1s var(--ease-out);
  transition-delay:calc(var(--i, 0) * 110ms)}
.js .aparece.dentro{opacity:1;transform:none}
.js .intro h1 .linea>*{transform:translateY(106%)}
.js .intro .antetitulo,.js .intro .entradilla,.js .intro .acciones,.js .intro .hero__pie,.js .intro .barra{opacity:0}
.js .intro .raya{transform:scaleX(0)}
h1 .linea>*{transition:transform 1.3s var(--ease-out)}
h1 .linea:nth-child(1)>*{transition-delay:.35s}
h1 .linea:nth-child(2)>*{transition-delay:.5s}
.antetitulo,.entradilla,.acciones,.hero__pie,.barra{transition:opacity 1.2s var(--ease-out)}
.raya{transition:transform 1.2s var(--ease-out) .2s}
.antetitulo{transition-delay:.15s}.entradilla{transition-delay:.8s}.acciones{transition-delay:.95s}.hero__pie{transition-delay:1.3s}

@media (max-width:960px){
  .barra nav{display:none}
  .casa h2{grid-column:1 / -1}
  .piezas{grid-template-columns:1fr;gap:36px}
  .contacto{grid-template-columns:1fr}
  .hero__pie{grid-template-columns:1fr auto;}
  .pista{display:none}
}
@media (max-width:640px){
  body{font-size:16px}
  .barra{padding-top:18px;padding-bottom:18px}
  .marca{font-size:24px}
  .boton{min-height:44px;padding:0 18px}
  .hero{margin-top:-80px;padding-top:96px;padding-bottom:20px;justify-content:flex-end;gap:28px}
  .hero__texto{margin-top:auto}
  .antetitulo{margin-bottom:18px;font-size:11px;letter-spacing:.16em}
  .entradilla{font-size:16px;margin-top:20px}
  .acciones{margin-top:24px;gap:12px 22px}
  .hero__pie{grid-template-columns:1fr;gap:16px}
  .hora__num{font-size:28px}
  .mandos{justify-self:stretch;justify-content:space-between;padding-left:18px}
  .mandos input[type=range]{flex:1;width:auto;min-width:0}
  .reservar h2{margin-bottom:36px}
}
@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  .js .aparece,.js .intro h1 .linea>*,.js .intro .antetitulo,.js .intro .entradilla,.js .intro .acciones,
  .js .intro .hero__pie,.js .intro .barra{opacity:1;transform:none}
  .js .intro .raya{transform:none}
  *,*::before,*::after{transition:none!important;animation:none!important}
}
```

Notas de maquetación que no hay que perder:

- La cabecera mide 98 px de alto (26 + 46 + 26) y el hero sube `margin-top:-98px` para ocupar el `100svh` entero por debajo de ella; en ≤ 640 px son 80 px.
- El hero es una columna flex con `justify-content:space-between`: el texto arriba (con `margin-top:clamp(20px, 9svh, 110px)`) y la fila `.hero__pie` pegada abajo, en rejilla de 3 columnas `1fr auto 1fr`: hora a la izquierda, pista centrada, mandos a la derecha.
- El titular son dos líneas, cada una dentro de un `span.linea` con `overflow:hidden` para la entrada de máscara; la segunda en cursiva.
- En escritorio el texto queda en la zona en sombra de la izquierda y el recuadro de luz a la derecha; en móvil la luz ocupa la mitad superior y el texto baja al tercio inferior.

## Movimiento

| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Lamas (uniform `uOpen`) | apertura | 0,02 → `lamas/100 × 0,9` (0,468 con 52) | 2600 ms | easeInOutCubic | 250 ms | carga |
| `h1 .linea:nth-child(1) > *` | translateY | 106 % → 0 | 1,3 s | `cubic-bezier(.19,1,.22,1)` | 0,35 s | quitar `.intro` (tras 2 rAF) |
| `h1 .linea:nth-child(2) > *` | translateY | 106 % → 0 | 1,3 s | `cubic-bezier(.19,1,.22,1)` | 0,5 s | ídem |
| `.barra` | opacity | 0 → 1 | 1,2 s | `--ease-out` | 0 | ídem |
| `.antetitulo` | opacity | 0 → 1 | 1,2 s | `--ease-out` | 0,15 s | ídem |
| `.raya` | scaleX (origen izquierda) | 0 → 1 | 1,2 s | `--ease-out` | 0,2 s | ídem |
| `.entradilla` | opacity | 0 → 1 | 1,2 s | `--ease-out` | 0,8 s | ídem |
| `.acciones` | opacity | 0 → 1 | 1,2 s | `--ease-out` | 0,95 s | ídem |
| `.hero__pie` | opacity | 0 → 1 | 1,2 s | `--ease-out` | 1,3 s | ídem |
| `.aparece` | opacity · translateY | 0 · 26 px → 1 · 0 | 1,1 s | `--ease-out` | `--i × 110 ms` | IntersectionObserver, umbral 0,18, una vez |
| `.pista::after` (línea de 38 px) | scaleY | 0 → 1 (origen arriba) → 0 (origen abajo) | 2,4 s en bucle | `--ease-suave` | 0 | siempre (CSS) |
| Hora del día (`uDay`) | progreso de scroll | 0 → 1 | amortiguado, k = 3,2 | `a + (b−a)(1−e^(−k·dt))` | — | scroll |
| Reloj `#hora` | texto | 16:10 → 19:50 | sigue a `uDay` | redondeo a 5 min | — | scroll |
| Recuadro de luz | desplazamiento por puntero | O.x += px·0,018 · O.y += py·0,012 · B.x += px·0,02 | amortiguado, k = 2,2 | ídem | — | ratón |
| Lamas tras la entrada | apertura | valor actual → objetivo | amortiguado, k = 6 | ídem | — | slider |
| Ramas | ángulo | base + uWind·(0,045 + i·0,015)·gust(t·0,9 + i·1,7) | continuo | senos | — | brisa activa |
| Hojas | ángulo | + uWind·0,2·sin(t·(2,3 + i·0,4) + j·1,37 + i·4,1)·s | continuo | senos | — | brisa activa |
| Motas de polvo | posición · brillo | deriva de 0,03 u/s hacia arriba + círculo de 0,12 · parpadeo sin(t·1,3) | continuo | — | — | brisa activa |
| Enlaces de nav | subrayado scaleX | 0 → 1 (entra por la izquierda, sale por la derecha) | 0,5 s | `--ease-out` | 0 | hover |
| `.boton--lleno` | translateY · flecha translateX | 0 → −2 px · 0 → 4 px | 0,35 s · 0,45 s | `--ease-out` | 0 | hover |
| Enlaces de contacto | subrayado (background-size) | 0 → 100 % × 1 px | 0,5 s | `--ease-out` | 0 | hover |

`uTime` solo avanza mientras la brisa está activa (pausar congela hojas y motas). Arranca en 12,0 para que la primera imagen ya tenga las ramas en una postura natural.

## Código clave

### Fragment shader (literal)

Vertex shader: `attribute vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }` con un triángulo `[-1,-1, 3,-1, -1,3]`.

```glsl
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform float uDay;
uniform float uOpen;
uniform float uWind;
uniform vec2 uO;
uniform vec2 uA;
uniform vec2 uB;
uniform vec3 uWall;
uniform vec3 uSun;

float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float noise(vec2 p){
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p){
  float v = 0.0, a = 0.5;
  for (int i = 0; i < 4; i++){ v += a * noise(p); p = p * 2.03 + vec2(17.1, 9.2); a *= 0.5; }
  return v;
}
mat2 rot(float a){ float c = cos(a), s = sin(a); return mat2(c, s, -s, c); }
float segment(vec2 p, vec2 a, vec2 b){
  vec2 pa = p - a, ba = b - a;
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h);
}
float leafShape(vec2 l, float L, float W, float blur){
  float x = clamp(l.x / L, -1.0, 1.0);
  float hw = W * pow(1.0 - x * x, 0.8);
  float d = max(abs(l.y) - hw, abs(l.x) - L);
  return 1.0 - smoothstep(-blur, blur, d);
}
float gust(float t){ return sin(t * 0.83) * 0.55 + sin(t * 1.91 + 1.3) * 0.25 + sin(t * 0.31 + 0.7) * 0.9; }

float foliage(vec2 q, float t){
  float sh = 0.0;
  for (int i = 0; i < 3; i++){
    float fi = float(i);
    vec2 anchor = vec2(0.5 + fi * 0.2, 1.2 + 0.05 * sin(fi * 2.3));
    float blur = 0.004 + fi * 0.006;
    float ang = -2.02 + fi * 0.3 + uWind * (0.045 + fi * 0.015) * gust(t * 0.9 + fi * 1.7);
    vec2 dir = vec2(cos(ang), sin(ang));
    float len = 0.62 - fi * 0.1;
    sh = max(sh, (1.0 - smoothstep(0.0022, 0.0022 + blur * 1.6, segment(q, anchor, anchor + dir * len))) * 0.9);
    for (int j = 0; j < 11; j++){
      float fj = float(j);
      float s = 0.16 + fj * 0.084;
      float side = mod(fj, 2.0) < 1.0 ? 1.0 : -1.0;
      vec2 c = anchor + dir * len * s;
      float flutter = uWind * 0.2 * sin(t * (2.3 + fi * 0.4) + fj * 1.37 + fi * 4.1) * s;
      float la = ang + side * (0.55 + 0.3 * hash(vec2(fi, fj))) - 0.25 * s + flutter;
      float L = 0.068 + 0.03 * hash(vec2(fj, fi + 3.0)) - 0.02 * s;
      vec2 lc = c + vec2(cos(la), sin(la)) * L * 0.95;
      sh = max(sh, leafShape(rot(-la) * (q - lc), L, L * 0.24, blur));
    }
  }
  return sh;
}

vec3 softClip(vec3 c){
  vec3 k = max(c - 0.86, 0.0);
  return min(c, 0.86) + 0.14 * (1.0 - exp(-k / 0.14));
}

void main(){
  vec2 frag = gl_FragCoord.xy;
  vec2 uv = frag / uRes;
  vec2 p = (frag - 0.5 * uRes) / uRes.y;

  // Pared -> espacio de la ventana (paralelogramo O + x*A + y*B) con un leve trapecio
  vec2 d = p - uO;
  float det = uA.x * uB.y - uA.y * uB.x;
  vec2 q = vec2(d.x * uB.y - d.y * uB.x, uA.x * d.y - uA.y * d.x) / det;
  q.y /= 1.0 + 0.3 * q.x;

  // Lamas: franjas con penumbra que crece lejos de la ventana y flecos cálidos
  float fx = clamp(q.x, 0.0, 1.0);
  float wf = mix(0.045, 0.15, fx) + 0.03 * uDay;
  float jit = hash(vec2(floor(q.y * 12.0), 4.0)) - 0.5;
  float dd = abs(fract(q.y * 12.0 + 0.05 * jit * sin(q.x * 3.1416)) - 0.5);
  float hw = uOpen * 0.5 * (1.0 + 0.12 * jit);
  vec3 lit = vec3(
    1.0 - smoothstep(hw - wf * 1.25, hw + wf * 1.25, dd),
    1.0 - smoothstep(hw - wf, hw + wf, dd),
    1.0 - smoothstep(hw - wf * 0.8, hw + wf * 0.8, dd));

  // Marco, parteluz y cordones de la persiana
  float e = 0.012 + 0.03 * fx;
  float mask = smoothstep(-e, e, q.x) * smoothstep(1.0 + e, 1.0 - e, q.x)
             * smoothstep(-e, e, q.y) * smoothstep(1.0 + e, 1.0 - e, q.y);
  mask *= 1.0 - 0.96 * (1.0 - smoothstep(0.017, 0.017 + e, abs(q.x - 0.5)));
  mask *= 1.0 - 0.45 * (1.0 - smoothstep(0.0016, 0.0016 + e * 0.6, abs(q.x - 0.17)));
  mask *= 1.0 - 0.45 * (1.0 - smoothstep(0.0016, 0.0016 + e * 0.6, abs(q.x - 0.33)));
  mask *= 1.0 - 0.45 * (1.0 - smoothstep(0.0016, 0.0016 + e * 0.6, abs(q.x - 0.67)));
  mask *= 1.0 - 0.45 * (1.0 - smoothstep(0.0016, 0.0016 + e * 0.6, abs(q.x - 0.83)));

  float leaves = foliage(q, uTime);
  vec3 light = lit * mask * (1.0 - leaves * 0.9) * mix(1.06, 0.84, fx);
  float lum = dot(light, vec3(0.3, 0.5, 0.2));

  // Luz rebotada alrededor del recuadro
  vec2 dq = q - 0.5;
  float glow = exp(-dot(dq, dq) * 2.2) * 0.1;

  // Enlucido de cal: manchas suaves, grano fino y relieve que solo se ve con luz rasante
  float m = fbm(p * 2.2 + 3.0) * 0.7 + fbm(p * 13.0) * 0.3;
  float n0 = noise(p * 64.0);
  vec2 grad = vec2(noise((p + vec2(0.0025, 0.0)) * 64.0) - n0, noise((p + vec2(0.0, 0.0025)) * 64.0) - n0);
  float bump = dot(grad, vec2(-0.55, 0.83)) * 0.8;

  vec3 albedo = uWall * (0.955 + 0.09 * m);
  vec3 sunCol = mix(uSun, uSun * vec3(1.0, 0.7, 0.44), uDay);
  float inten = mix(0.5, 0.42, uDay);
  vec3 amb = mix(vec3(0.86, 0.83, 0.81), vec3(0.8, 0.72, 0.72), uDay);

  vec3 col = albedo * (amb + sunCol * inten * light * (1.0 + bump) + sunCol * glow);
  col += albedo * sunCol * 0.06 * smoothstep(0.2, -0.6, p.y) * (1.0 - 0.5 * uDay);

  // Motas de polvo, solo dentro de la luz
  vec2 gp = p * 7.0 + vec2(0.0, uTime * 0.03);
  vec2 id = floor(gp);
  float h = hash(id);
  vec2 mp = 0.25 + 0.5 * vec2(hash(id + 7.1), hash(id + 3.3)) + 0.12 * vec2(sin(uTime * 0.4 + h * 6.28), cos(uTime * 0.33 + h * 9.0));
  float mote = smoothstep(0.035, 0.0, length(fract(gp) - mp)) * step(0.72, h) * (0.5 + 0.5 * sin(uTime * 1.3 + h * 40.0));
  col += sunCol * mote * lum * 0.3;

  vec2 vq = uv - 0.5;
  col *= 1.0 - 0.3 * dot(vq, vq);
  col = softClip(col);
  col += (hash(frag) - 0.5) * 0.022;
  gl_FragColor = vec4(col, 1.0);
}
```

Cómo funciona la proyección: `p` son coordenadas de pantalla centradas y normalizadas por el alto (y ∈ [−0,5, 0,5]). La ventana proyectada es el paralelogramo `O + x·A + y·B`; se invierte la matriz `[A B]` para obtener `q` (0..1 dentro de la ventana) y se divide `q.y` entre `1 + 0,3·q.x` para el trapecio. Las franjas son `fract(q.y·12)`; la distancia al centro de la franja `dd` se compara con la media apertura `uOpen·0,5`. El `lit` tiene tres canales con penumbras distintas (×1,25, ×1, ×0,8) para el fleco cálido.

### JavaScript (literal)

`FRAG` es exactamente el shader del bloque anterior, dentro de una plantilla de texto.

```js
(() => {
  const root = document.documentElement;
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const coarse = matchMedia("(pointer: coarse)").matches;

  // Entradas
  requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("intro")));
  const io = new IntersectionObserver((es) => es.forEach((e) => {
    if (e.isIntersecting) { e.target.classList.add("dentro"); io.unobserve(e.target); }
  }), { threshold: 0.18 });
  document.querySelectorAll(".aparece").forEach((el) => io.observe(el));

  const canvas = document.getElementById("luz");
  const slider = document.getElementById("lamas");
  const brisaBtn = document.getElementById("brisa");
  const horaEl = document.getElementById("hora");

  const VERT = `attribute vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }`;
  const FRAG = `/* aquí va, literal, el fragment shader del bloque anterior */`;

  const gl = canvas.getContext("webgl", { antialias: false, alpha: false, depth: false, stencil: false, powerPreference: "high-performance" });
  const fallback = () => root.classList.add("sin-webgl");
  if (!gl) { fallback(); return; }

  const compile = (type, src) => {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
  };
  const vs = compile(gl.VERTEX_SHADER, VERT), fs = compile(gl.FRAGMENT_SHADER, FRAG);
  const prog = gl.createProgram();
  if (!vs || !fs) { fallback(); return; }
  gl.attachShader(prog, vs); gl.attachShader(prog, fs);
  gl.bindAttribLocation(prog, 0, "aPos");
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { fallback(); return; }
  gl.useProgram(prog);
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  const U = {};
  ["uRes", "uTime", "uDay", "uOpen", "uWind", "uO", "uA", "uB", "uWall", "uSun"].forEach((n) => (U[n] = gl.getUniformLocation(prog, n)));

  const hex = (v) => {
    const h = v.trim().replace("#", "");
    const n = parseInt(h.length === 3 ? h.replace(/./g, "$&$&") : h, 16);
    return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
  };
  const css = getComputedStyle(root);
  gl.uniform3fv(U.uWall, hex(css.getPropertyValue("--pared") || "#e9dcc6"));
  gl.uniform3fv(U.uSun, hex(css.getPropertyValue("--sol") || "#ffe2b3"));

  // Estado
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
  const damp = (a, b, k, dt) => a + (b - a) * (1 - Math.exp(-k * dt));
  const openFor = (v) => (v / 100) * 0.9;
  const S = {
    time: 12.0, wind: 1, windOn: !reduce,
    open: reduce ? openFor(+slider.value) : 0.02, openT: openFor(+slider.value),
    day: reduce ? 0.2 : 0, dayT: reduce ? 0.2 : 0,
    px: 0, py: 0, pxT: 0, pyT: 0,
    scale: 1, visible: true, onScreen: true, raf: 0, last: 0, slow: 0
  };
  const introStart = performance.now() + 250;
  let introDone = reduce;

  const resize = () => {
    const cap = coarse || innerWidth < 768 ? 1.5 : 2;
    const dpr = Math.min(devicePixelRatio || 1, cap) * S.scale;
    const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); }
  };

  // Colocación del recuadro de luz según proporción y hora
  const layout = () => {
    const aspect = canvas.width / canvas.height;
    const k = clamp((aspect - 0.55) / (1.6 - 0.55), 0, 1);
    const L = (a, b) => a + (b - a) * k;
    const day = S.day;
    const O = [L(-0.44, -0.2) - day * 0.06 + S.px * 0.018, L(0.08, -0.6) + day * L(0.14, 0.3) + S.py * 0.012];
    let A = [L(0.8, 1.2), L(-0.3, -0.42)];
    const r = -day * 0.1, c = Math.cos(r), s = Math.sin(r);
    A = [A[0] * c - A[1] * s, A[0] * s + A[1] * c];
    const g = 1 + 0.24 * day;
    const B = [L(0.2, 0.36) * g + S.px * 0.02, L(0.54, 0.98) * g];
    gl.uniform2f(U.uO, O[0], O[1]);
    gl.uniform2f(U.uA, A[0], A[1]);
    gl.uniform2f(U.uB, B[0], B[1]);
  };

  const scrollDay = () => {
    const max = Math.max(1, document.documentElement.scrollHeight - innerHeight);
    return clamp(scrollY / max, 0, 1);
  };
  let lastHora = "";
  const updateHora = () => {
    const mins = Math.round((970 + S.day * 220) / 5) * 5;
    const t = `${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, "0")}`;
    if (t !== lastHora) { horaEl.textContent = t; lastHora = t; }
  };

  const draw = () => {
    gl.uniform2f(U.uRes, canvas.width, canvas.height);
    gl.uniform1f(U.uTime, S.time);
    gl.uniform1f(U.uDay, S.day);
    gl.uniform1f(U.uOpen, S.open);
    gl.uniform1f(U.uWind, S.wind);
    layout();
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  };

  const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

  const frame = (now) => {
    S.raf = 0;
    const dt = Math.min(0.05, (now - (S.last || now)) / 1000);
    S.last = now;
    if (S.windOn) S.time += dt;
    if (!introDone) {
      const t = clamp((now - introStart) / 2600, 0, 1);
      S.open = 0.02 + (S.openT - 0.02) * ease(t);
      if (t >= 1) introDone = true;
    } else S.open = damp(S.open, S.openT, 6, dt);
    if (!reduce) S.dayT = scrollDay();
    S.day = damp(S.day, S.dayT, 3.2, dt);
    S.px = damp(S.px, S.pxT, 2.2, dt);
    S.py = damp(S.py, S.pyT, 2.2, dt);
    updateHora();
    draw();

    // Calidad adaptativa: si un fotograma tarda demasiado de forma sostenida, baja la resolución
    if (dt > 0.026) S.slow++; else S.slow = Math.max(0, S.slow - 1);
    if (S.slow > 40 && S.scale > 0.55) { S.scale -= 0.15; S.slow = 0; resize(); }

    const settled = introDone && Math.abs(S.open - S.openT) < 1e-4 && Math.abs(S.day - S.dayT) < 1e-4 &&
      Math.abs(S.px - S.pxT) < 1e-3 && Math.abs(S.py - S.pyT) < 1e-3;
    if ((S.windOn || !settled) && !reduce) request();
  };
  const request = () => {
    if (S.raf || !S.visible || !S.onScreen) return;
    S.raf = requestAnimationFrame(frame);
  };
  const redraw = () => { if (reduce) { resize(); updateHora(); draw(); } else { S.last = 0; request(); } };

  resize();
  updateHora();
  draw();
  redraw();

  new ResizeObserver(() => { resize(); redraw(); }).observe(canvas);
  document.addEventListener("visibilitychange", () => {
    S.visible = document.visibilityState === "visible";
    if (S.visible) redraw(); else if (S.raf) { cancelAnimationFrame(S.raf); S.raf = 0; }
  });
  new IntersectionObserver(([e]) => {
    S.onScreen = e.isIntersecting;
    if (S.onScreen) redraw(); else if (S.raf) { cancelAnimationFrame(S.raf); S.raf = 0; }
  }).observe(canvas);
  addEventListener("scroll", redraw, { passive: true });
  addEventListener("pointermove", (e) => {
    if (e.pointerType !== "mouse") return;
    S.pxT = (e.clientX / innerWidth) * 2 - 1;
    S.pyT = -((e.clientY / innerHeight) * 2 - 1);
    redraw();
  }, { passive: true });

  slider.addEventListener("input", () => {
    S.openT = openFor(+slider.value);
    slider.setAttribute("aria-valuetext", `${slider.value} % abiertas`);
    if (reduce) S.open = S.openT;
    introDone = true;
    redraw();
  });
  brisaBtn.addEventListener("click", () => {
    S.windOn = !S.windOn;
    brisaBtn.querySelector("span").textContent = S.windOn ? "Pausar brisa" : "Reanudar brisa";
    brisaBtn.querySelector("svg").innerHTML = S.windOn
      ? '<rect x="2" y="1" width="3" height="10"/><rect x="7" y="1" width="3" height="10"/>'
      : '<path d="M2.5 1v10l8-5z"/>';
    redraw();
  });
  if (reduce) {
    brisaBtn.querySelector("span").textContent = "Reanudar brisa";
    brisaBtn.querySelector("svg").innerHTML = '<path d="M2.5 1v10l8-5z"/>';
    brisaBtn.addEventListener("click", () => { if (S.windOn) { S.last = 0; loopReduced(); } });
  }
  // Con movimiento reducido, la brisa solo se mueve si la persona la activa a mano
  function loopReduced() {
    if (!S.windOn || !S.visible) return;
    requestAnimationFrame((now) => {
      const dt = Math.min(0.05, (now - (S.last || now)) / 1000);
      S.last = now; S.time += dt * 0.6; S.open = S.openT; draw(); loopReduced();
    });
  }
})();
```

## Interacción

- **Scroll**: el progreso total de la página (`scrollY / (scrollHeight − innerHeight)`) es la hora del día. El recuadro sube `day × 0,14` (móvil) a `day × 0,30` (escritorio) alturas de pantalla, se desplaza −0,06 a la izquierda, `A` gira −0,1 rad y `B` se alarga un 24 %.
- **Ratón**: solo con `pointerType === "mouse"`. La posición normalizada (−1..1) desplaza ligeramente el recuadro de luz, como si el sol se moviera un poco. En táctil no hay efecto de puntero: la brisa y el scroll ya dan el movimiento.
- **Slider «Lamas»** (`input type=range`, 10–90, valor 52): apertura de las lamas; `uOpen = valor/100 × 0,9`. Actualiza `aria-valuetext` a «N % abiertas». Si se toca durante la entrada, la entrada termina y manda el slider.
- **Botón «Pausar brisa» / «Reanudar brisa»**: congela o reanuda `uTime`. Cambia el texto y el icono (dos barras ↔ triángulo). Si no hay brisa ni nada que amortiguar, el bucle se detiene solo.
- **Teclado**: todo es nativo (enlaces, `range`, `button`), con `:focus-visible` de 2 px en tinta y 4 px de separación. Enlace «Saltar al contenido» que aparece al recibir foco.
- Los enlaces internos (`#casa`, `#reservar`, `#inicio`) usan `scroll-behavior:smooth`, que además hace «caer la tarde» en el fondo.

## Adaptación a móvil y tablet

- **Colocación del recuadro de luz**: depende de la proporción del canvas. `k = clamp((aspect − 0,55)/(1,6 − 0,55), 0, 1)` interpola entre el encuadre móvil y el de escritorio:
  - O: (−0,44, 0,08) → (−0,20, −0,60)
  - A: (0,80, −0,30) → (1,20, −0,42)
  - B: (0,20, 0,54) → (0,36, 0,98)
- **≤ 960 px**: se oculta la navegación (queda marca + «Reservar»), el h2 de «La casa» ocupa las 12 columnas, las tres piezas y el contacto pasan a una columna, `.hero__pie` pasa a 2 columnas y se oculta la pista «Desliza y cae la tarde».
- **≤ 640 px**: cuerpo a 16 px; cabecera de 80 px; el hero alinea el contenido abajo (`justify-content:flex-end`, texto con `margin-top:auto`), el pie del hero es de una columna y los mandos ocupan todo el ancho con el slider flexible. Titular: `clamp(50px, 7.4vw, 126px)` → 50 px en móvil (dos líneas en 390 px).
- DPR máximo: 2 en escritorio y 1,5 si el puntero es grueso o el ancho es < 768 px.
- Sin scroll horizontal de 360 a 1920 px (`overflow-x:hidden` en `body`, `min-width:0` en celdas de rejilla y `overflow-wrap:anywhere` en los datos de contacto).

## Accesibilidad y movimiento reducido

- HTML semántico: `header`, `nav aria-label="Principal"`, `main#contenido`, tres `section` con `aria-labelledby`, `dl` para el contacto, `footer`. Canvas, fallback, rayas y SVG decorativos con `aria-hidden="true"`. El grupo de mandos es `role="group" aria-label="Control de la luz"` con `<label for="lamas">`.
- Contraste: tinta sobre la pared en sombra ≈ 7,8:1; sobre las franjas iluminadas, más.
- `prefers-reduced-motion: reduce`: no hay entrada ni apariciones (todo visible), no hay transiciones ni la línea de la pista, `scroll-behavior:auto`. El shader se pinta **una sola vez** con las lamas en su valor, la hora fija en `uDay = 0,2` (no sigue al scroll) y la brisa parada; el botón arranca como «Reanudar brisa» y, si la persona lo pulsa, las hojas se mueven a 0,6× de velocidad. El slider sigue funcionando y repinta bajo demanda.

## Rendimiento

- Un solo pase de fragment shader, sin texturas. Contexto con `antialias:false, alpha:false, depth:false, stencil:false`.
- DPR limitado (2 / 1,5) y **calidad adaptativa**: si más de 40 fotogramas seguidos (neto) tardan > 26 ms, la escala de resolución baja 0,15 (mínimo 0,55) y se redimensiona.
- El bucle `requestAnimationFrame` se detiene cuando la pestaña está oculta (`visibilitychange`), cuando el canvas sale de pantalla (IntersectionObserver; importa porque la pieza se muestra en un iframe) y cuando no hay brisa ni valores por amortiguar. Scroll, puntero, slider, botón y `ResizeObserver` lo reactivan.
- **Fallback sin WebGL** (o si falla la compilación): se añade `.sin-webgl` a `<html>`, se oculta el canvas y se muestra `.respaldo`: franjas `repeating-linear-gradient(-26deg, …)` con el color del sol al 55 % sobre un degradado de `--pared` a `--pared-sombra`.
- Nada de `100vh`: el hero usa `100svh` y el canvas es `position:fixed; inset:0`.

## Personalización

- **Marca y datos**: Casa Albero, Alvera, Valle Claro, Rue de la Chaux, 7 · 10400 Alvera (Valle Claro), +00 000 000 000 y hola@casaalbero.com aparecen en cabecera, hero, contacto y pie. Adapta también las referencias locales de los textos («Pan de aldea, aceite del valle») a la zona del negocio.
- **Color de la pared** (`--pared`): cualquier tono claro funciona (cal blanca `#efe9df`, almagra suave `#e8c9b4`, azul añil lavado `#cfd6df`). Mantén la luminosidad alta para que la tinta conserve el contraste.
- **Color del sol** (`--sol`): más blanco para mediodía (`#fff4dc`), más naranja para un tono de tarde permanente (`#ffcf95`).
- **Tinta** (`--tinta`): cualquier color muy oscuro; comprueba el contraste AA sobre `--pared-sombra`.
- **Franjas**: número de lamas = el `12.0` de `q.y * 12.0` (dos veces en el shader); anchura por defecto = `value` del slider; penumbra = `mix(0.045, 0.15, fx)`.
- **Hojas**: más o menos ramas en el bucle `i < 3`; hojas por rama en `j < 11`; brisa más lenta multiplicando `uTime` en la llamada a `foliage`; sin hojas, quita la línea de `foliage`.
- **Encuadre**: los tres vectores de `layout()` (O, A, B) mueven, estiran e inclinan el recuadro.
- Para usarlo solo como fondo de otra sección: conserva canvas, respaldo, tokens y el script, y cambia `position:fixed` por `absolute` dentro de un contenedor con `position:relative; overflow:hidden`.

## Comprobación final

1. Un único archivo, sin imágenes ni librerías; solo las dos fuentes de Google indicadas.
2. Al cargar, las lamas se abren en 2,6 s y el titular entra con máscara en dos líneas (0,35 s y 0,5 s).
3. El recuadro de luz tiene 12 franjas por altura de ventana, un parteluz central oscuro, cuatro cordones finos y bordes con fleco cálido.
4. Tres ramas con hojas lanceoladas se mecen y su sombra solo aparece dentro de las franjas.
5. Al hacer scroll hasta el final, el reloj pasa de 16:10 a 19:50 y la luz sube, se estira y se vuelve anaranjada.
6. El slider «Lamas» (10–90, valor 52) cambia el ancho de las franjas y anuncia «N % abiertas».
7. «Pausar brisa» congela las hojas y cambia a «Reanudar brisa» con icono de triángulo.
8. En 390 × 844 la luz ocupa la mitad superior, el titular cabe en dos líneas y los mandos ocupan todo el ancho, sin scroll horizontal.
9. Con `prefers-reduced-motion: reduce` todo está visible y quieto desde el principio y el fondo es una imagen fija.
10. Sin WebGL se ven las franjas en CSS sobre la pared, con el mismo color.
11. El bucle se para con la pestaña oculta o el canvas fuera de pantalla, y cero errores en consola.
12. Foco visible en todos los controles y contraste AA de la tinta sobre la pared.

Prueba en frío

Lo que salió al pegarlo 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.

Lo que generó la IA con el prompt Completo de Luz de persiana
Prompt CompletoFiel a la demo9,8/10

Prácticamente idéntica al original: mismo shader, misma maquetación, textos, colores y movimiento. La luz sube y se dora al hacer scroll, el reloj va de 16:10 a 19:50 y las lamas, la brisa y el puntero responden igual; solo cambia la postura de las hojas según el momento de la captura.

Lo que generó la IA con el prompt Lite de Luz de persiana
Prompt LiteFiel a la demo7,8/10

Conserva lo esencial: la persiana con sus franjas, el parteluz, las hojas que se mecen, la tarde que cae con el scroll, el reloj y los mandos. Añade filetes que no existen (sobre el pie del hero, el pie de página y cada dato de contacto) y el titular de «La casa» ocupa tres líneas en vez de dos. La pared es más lisa: no tiene el relieve de cal ni las motas de polvo.

Solo publicamos lo que hemos probado. Otras herramientas (ChatGPT, Gemini, Lovable…) pueden dar resultados distintos.

Paso a paso

Cómo usarlo en 4 pasos

  1. 01

    Personaliza

    Rellena el formulario de arriba con el nombre, los colores y los textos de tu cliente. Se meten en el prompt al copiar.
  2. 02

    Copia el Completo o el Lite

    El Completo, para Claude, ChatGPT, Gemini o Cursor. El Lite, más corto, para Lovable, Bolt o v0, que aceptan menos texto.
  3. 03

    Pega e itera

    Pégalo en una conversación nueva. Si quieres cambiar algo, pídelo con una frase corta:
    • «Haz las franjas más anchas y con menos lamas»
    • «Cambia la pared a azul añil lavado»
    • «Que las hojas se muevan más despacio»
    • «Quita las hojas y deja solo la persiana»
    • «Úsalo solo como fondo del hero, sin la hora del día al hacer scroll»
  4. 04

    Publica

    Guarda el resultado como HTML (o en tu proyecto) y súbelo a Vercel, Netlify o tu hosting de siempre. Tienes los pasos en la guía.

Guías por herramientaClaudeChatGPTGeminiCursorLovableBoltv0

Licencia en claro

Qué puedes hacer con esta pieza

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

  • Usarlo en tus webs y en las de tus clientes, también si cobras por ellas.
  • Cambiarlo todo lo que quieras.
  • Publicar el resultado sin citarnos.

No puedes

  • Revender o regalar el prompt, la demo o el código como plantilla o biblioteca.
  • Compartir tu cuenta Pro.

Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.

Preguntas frecuentes

Lo que suele preguntarse

¿Puedo poner texto encima sin que se pierda?
Sí. La pared en sombra y las franjas son claras y la tinta oscura mantiene un contraste de unos 7,8:1. En escritorio el recuadro de luz queda a la derecha para dejar la izquierda limpia para el titular; en móvil sube a la mitad superior.
¿Consume mucha batería en el móvil?
Es un único shader sin texturas, con la densidad de píxeles limitada a 1,5 en móvil, calidad que se ajusta sola si el dispositivo va justo y un bucle que se detiene fuera de pantalla, con la pestaña oculta o con la brisa en pausa.
¿Qué pasa si el navegador no tiene WebGL?
Se muestra un respaldo en CSS con franjas diagonales del mismo color de sol sobre la misma pared, así que la página no se queda en blanco.
¿Puedo usarlo en una sola sección y no en toda la página?
Sí: cambia el canvas de position fixed a absolute dentro de un contenedor con position relative y overflow hidden, y calcula la hora del día con el progreso de esa sección en lugar del de la página.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈12561 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈2687 tokens) resume lo esencial para herramientas que aceptan menos texto, como Lovable, Bolt o v0.

Versión 1.0 · publicada el 30-09-2026

Cópialo desde aquí

Tu navegador no nos deja escribir en el portapapeles. El texto ya está seleccionado: pulsa Ctrl + C (o ⌘ + C) o el botón.