# Visual shell and scene picker

This is a small, portable replacement for a much larger scene-specific animation file. Save the HTML as your app's `public/index.html`, then add **your own licensed images** under `public/scenery/`. Bring your own images and, if you want scene occlusion, create masks for those images. The code does not fetch weather or depend on a backend.

Replace the `<title>`, content in `#root`, and `landmarks` coordinates. Set a landmark to `null` when the image has no matching feature. The coordinates are fractions of the uncropped source image. The `point()` function maps them through `object-fit: cover`, so the glow and steam stay attached on narrow screens.

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Your app</title>
  <link rel="icon" href="/favicon.png">
  <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=Google+Sans+Code:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  <style>
    :root { --page:#fafaf9; --ink:#2a2218; --mint:#93efa4; --mint-hover:#7ad895;
      --green:#1f7a4d; --amber:#c8864a; --muted:#6b6f68; --border:#e7e5e0; }
    * { box-sizing:border-box; }
    html, body { min-height:100%; margin:0; }
    body { color:var(--ink); background:var(--page); font:16px/1.5 Inter,system-ui,sans-serif; }
    .display { font-family:"Google Sans Code",Inter,system-ui,sans-serif; }
    #stage { position:fixed; inset:0; z-index:0;
      background:linear-gradient(145deg,#dceee8 0%,#e9e9dc 55%,#c9dce2 100%); }
    #stage img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
      object-position:center; user-select:none; }
    #hero-next { opacity:0; transition:opacity .9s ease; }
    #fx { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
    #scrim { position:fixed; inset:0; z-index:1; pointer-events:none;
      background:linear-gradient(to bottom,rgba(250,250,249,.10),rgba(250,250,249,.34) 42%,rgba(250,250,249,.60)); }
    #root { position:relative; z-index:2; min-height:100vh; padding:clamp(16px,4vw,48px); }
    .glass { background:rgba(255,252,249,.68); backdrop-filter:blur(18px) saturate(1.3);
      -webkit-backdrop-filter:blur(18px) saturate(1.3); }
    .card { border:1px solid rgba(255,255,255,.55); border-radius:22px;
      box-shadow:0 8px 32px rgba(60,50,30,.14); }
    .pill { border-radius:999px; }
    :focus-visible { outline:3px solid var(--green); outline-offset:3px; }
    @media (prefers-reduced-motion:reduce) { #hero-next { transition:none; } }
  </style>
</head>
<body>
  <div id="stage" aria-hidden="true">
    <img id="hero" alt="">
    <img id="hero-next" alt="">
    <canvas id="fx"></canvas>
  </div>
  <div id="scrim"></div>
  <main id="root">
    <section class="glass card" style="max-width:600px;padding:24px">
      <h1 class="display">Your app</h1>
      <p>Replace this with your own interface.</p>
      <button class="pill" type="button">Continue</button>
    </section>
  </main>
  <script>
    const stage = document.querySelector("#stage");
    const hero = document.querySelector("#hero");
    const next = document.querySelector("#hero-next");
    const canvas = document.querySelector("#fx");
    const ctx = canvas.getContext("2d");
    const reduced = matchMedia("(prefers-reduced-motion: reduce)");
    const landmarks = { window: null, chimney: null }; // Example: {x:.52,y:.48}
    const glowScale = { dawn:.8, morning:1, midday:1, evening:1, dusk:.65, night:.4 };
    let bucket = "", pending = "", raf = 0;

    function timeBucket(date = new Date()) {
      const h = date.getHours(); // Viewer local time, not server time
      if (h >= 5 && h < 7) return "dawn";
      if (h >= 7 && h < 11) return "morning";
      if (h >= 11 && h < 16) return "midday";
      if (h >= 16 && h < 19) return "evening";
      if (h >= 19 && h < 21) return "dusk";
      return "night";
    }

    function showScene() {
      const chosen = timeBucket();
      if (chosen === bucket || chosen === pending) return;
      pending = chosen;
      const url = `/scenery/${chosen}.webp`;
      const preloaded = new Image();
      preloaded.onerror = () => { pending = ""; }; // Keep the prior image or gradient
      preloaded.onload = () => {
        if (!bucket || reduced.matches) {
          hero.src = url;
          next.style.opacity = "0";
          bucket = chosen;
          pending = "";
          return;
        }
        next.src = url;
        next.style.opacity = "1";
        setTimeout(() => {
          hero.src = url;
          next.style.opacity = "0";
          bucket = chosen;
          pending = "";
        }, 900);
      };
      preloaded.src = url;
    }

    function sizeCanvas() {
      const ratio = Math.min(devicePixelRatio || 1, 2);
      const r = stage.getBoundingClientRect();
      canvas.width = Math.round(r.width * ratio);
      canvas.height = Math.round(r.height * ratio);
      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    }

    function point(anchor) {
      if (!anchor || !hero.naturalWidth) return null;
      const r = stage.getBoundingClientRect();
      const scale = Math.max(r.width / hero.naturalWidth, r.height / hero.naturalHeight);
      return { x:(r.width - hero.naturalWidth * scale) / 2 + anchor.x * hero.naturalWidth * scale,
        y:(r.height - hero.naturalHeight * scale) / 2 + anchor.y * hero.naturalHeight * scale };
    }

    function frame(t) {
      if (document.hidden || reduced.matches) { raf = 0; return; }
      const r = stage.getBoundingClientRect();
      ctx.clearRect(0, 0, r.width, r.height);
      const windowAt = point(landmarks.window);
      if (windowAt) {
        const radius = 50 + 7 * Math.sin(t / 1500);
        const light = ctx.createRadialGradient(windowAt.x,windowAt.y,0,windowAt.x,windowAt.y,radius);
        light.addColorStop(0,`rgba(255,193,106,${.22 * glowScale[timeBucket()]})`);
        light.addColorStop(1,"rgba(255,193,106,0)");
        ctx.fillStyle = light;
        ctx.fillRect(windowAt.x-radius,windowAt.y-radius,radius*2,radius*2);
      }
      const chimneyAt = point(landmarks.chimney);
      if (chimneyAt) {
        for (let i=0;i<4;i++) {
          const rise = ((t/70 + i*38) % 145);
          ctx.beginPath();
          ctx.arc(chimneyAt.x + Math.sin(t/900+i)*10,chimneyAt.y-rise,5+rise/12,0,Math.PI*2);
          ctx.fillStyle = `rgba(245,248,244,${.13 * (1-rise/145)})`;
          ctx.fill();
        }
      }
      raf = requestAnimationFrame(frame);
    }
    function resume() { if (!raf && !document.hidden && !reduced.matches) raf = requestAnimationFrame(frame); }
    new ResizeObserver(sizeCanvas).observe(stage);
    document.addEventListener("visibilitychange", resume);
    reduced.addEventListener("change", () => {
      if (reduced.matches) { cancelAnimationFrame(raf); raf = 0; ctx.clearRect(0,0,canvas.width,canvas.height); }
      else resume();
    });
    sizeCanvas(); showScene(); resume();
    setInterval(showScene, 60_000);
  </script>
</body>
</html>
```

The source effect engine also included a pixelated boat, water ripples, birds, a foreground silhouette mask, and optional depth-aware precipitation. Those depend on exact scene geometry and an image-derived depth map. Don't copy coordinates between unrelated artwork. If you rebuild them, derive an `object-fit: cover` transform for every anchor, clip moving objects with your own foreground mask, cap the animation frame scale, and test the narrowest viewport.
