# Interactive Discovery

> **Category:** Hero Section
> **Effect:** A cursor-following spotlight unmasks a warm procedural deep-core plate over a cold sedimentary strata plate
> **Platform:** Workbuddy proxy (`workbuddy`)
> **Model:** `hy4-preview-f` — Hy4 preview (hy4-preview-f)
> **License:** Free / MIT — copy, paste, ship.
> **Self-contained:** yes — one `index.html`, no build step, no external media.

---

## 🎬 Live demo

Open [`public/demos/interactive-discovery/index.html`](../public/demos/interactive-discovery/index.html) — generated by the model
above from the prompt below, with no human edits to the markup.

## ✅ Verification

<!-- VERIFICATION:START -->
_Pending — this block is written by `tools/verify.mjs` after the build run._
<!-- VERIFICATION:END -->

## 📋 Prompt

```text
DESIGN BRIEF — "Interactive Discovery"

A. WHAT IS BEING BUILT

Build ONE complete, valid, self-contained `index.html`: a single-viewport, dark, cinematic hero
for a fictional geology brand called **Lithos**. Document title (exact):
`Lithos — Layers Hold Tales of Time`. `<html lang="en">`.

The page is: a near-black sedimentary ground; a PROCEDURAL strata plate built only from CSS
gradients; a second, warmer PROCEDURAL "deep core" plate stacked directly on top of it that is
revealed ONLY inside a soft circular spotlight which trails the pointer — that reveal is the
signature effect and the whole point of the page; a fixed top bar holding the Lithos mark, the
wordmark, a centred glass pill of five nav items and a "Sign Up" pill; a two-line display
headline; one short paragraph bottom-left; one short paragraph plus an amber "Start Digging"
button bottom-right; and a slim footer rule.

Structure it with real landmarks: exactly one <header> (containing the <nav>), exactly one <main>
(the hero and every one of its layers), exactly one <footer>. Exactly one <h1>.

At viewport widths of 901px and above the page is EXACTLY ONE FRAME: `html, body { height:100%;
overflow:hidden }` and `.page { height:100dvh; overflow:hidden }`. It does not scroll. At 900px
and below the page may scroll vertically, and horizontal scrolling is forbidden at every width.

B. WHAT NOT TO BUILD — every one of these is a failure

- No React, no TypeScript, no Vite, no Tailwind, no lucide-react, no JSX, no npm, no build step.
  The crawled source prompt asked for that stack; this library ships one vanilla file instead.
  Translate every Tailwind utility into plain CSS and every lucide icon into an inline SVG you
  draw yourself.
- **No <canvas> element anywhere in the document.** The source built its spotlight mask with a
  hidden canvas plus `toDataURL()`. Do not. The mask is a CSS `radial-gradient` applied through
  `mask-image`, repositioned by writing two CSS custom properties. This is mandatory: the
  automated verifier measures the first <canvas> it finds and a hidden one fails the build.
- No external media of any kind: no `<img>`, no remote `url(...)`, no
  `https://images.higgs.ai`, no .png/.jpg/.webp/.mp4/.webm/.gif, no base64 photo blobs, no
  <iframe>, no <video>, no <object>. Both "images" in this design are procedural CSS gradients.
- No framework or motion library: no GSAP, no Three.js, no Lottie, no jQuery, no external script.
- Google Fonts is the ONLY permitted network request. Nothing else may leave the page.
- No second <h1>. No extra sections, feature cards, pricing table, testimonial, FAQ, form,
  newsletter input, cookie banner, modal, gallery, carousel or scroll indicator.
- No `console.log`, `console.warn`, `console.error`, `alert` or `debugger` anywhere.
- No text-shadow glow on the headline, no neon outline, no drop shadow under the type.
- Never truncate. Never write "rest of the code here". The file must be complete and under 90 KB.

C. THE TWO PROCEDURAL PLATES  (this replaces the source's BG_IMAGE_1 and BG_IMAGE_2)

The source revealed a second photograph through the mask. Photography is banned, so build the same
idea out of gradients: two clearly different geology plates occupying the identical box, so the
spotlight reads as a lens into a second world. Both are `position:absolute; inset:0;` children of
<main> and are never given an explicit width or height.

C.1 `#layer-base` — the cold surface plate (z-index 1). One element, this exact background stack
    (topmost first), plus `background-color:#05060a`:
      radial-gradient(120% 80% at 50% 6%, rgba(232,112,42,0.10) 0%, rgba(232,112,42,0) 62%),
      radial-gradient(90% 60% at 12% 78%, rgba(126,148,178,0.12) 0%, rgba(126,148,178,0) 66%),
      radial-gradient(70% 50% at 88% 30%, rgba(120,180,200,0.07) 0%, rgba(120,180,200,0) 70%),
      repeating-linear-gradient(178deg, rgba(255,255,255,0.030) 0 2px, rgba(255,255,255,0) 2px 9px),
      repeating-linear-gradient(172deg, rgba(255,255,255,0.018) 0 1px, rgba(255,255,255,0) 1px 27px),
      repeating-linear-gradient(184deg, rgba(255,255,255,0.012) 0 4px, rgba(255,255,255,0) 4px 61px),
      linear-gradient(180deg, #0d1014 0%, #090b0f 44%, #05060a 100%);
    The three repeating gradients are the sedimentary bedding planes; the 178/172/184deg angles keep
    them from looking printed. It carries the Ken Burns zoom (see G).

C.2 `#layer-reveal` — the warm deep core (z-index 2, `pointer-events:none`). A visibly different
    plate: molten amber strata threaded with cold mineral veins, i.e. what is under the crust.
    This exact background stack, plus `background-color:#1a0f07`:
      radial-gradient(58% 44% at 50% 54%, rgba(255,196,120,0.55) 0%, rgba(232,112,42,0.22) 42%, rgba(232,112,42,0) 74%),
      radial-gradient(40% 30% at 22% 24%, rgba(255,214,150,0.28) 0%, rgba(255,214,150,0) 70%),
      repeating-linear-gradient(176deg, rgba(255,168,88,0.30) 0 3px, rgba(255,168,88,0) 3px 14px),
      repeating-linear-gradient(184deg, rgba(120,220,255,0.16) 0 1px, rgba(120,220,255,0) 1px 34px),
      linear-gradient(180deg, #1a0f07 0%, #2a1509 46%, #0b0705 100%);
    Its mask is the signature effect (section D). Nothing else about it moves.

C.3 Overlays, above both plates and below the copy:
    - `.grain` (z-index 3): one inline SVG, `aria-hidden="true"`, `position:absolute; inset:0;
      width:100%; height:100%; opacity:0.05; mix-blend-mode:soft-light; pointer-events:none`,
      containing
        <filter id="lithosGrain" x="0" y="0" width="100%" height="100%">
          <feTurbulence type="fractalNoise" baseFrequency="0.82" numOctaves="3" stitchTiles="stitch" seed="11" result="n"/>
          <feColorMatrix type="saturate" values="0"/>
        </filter>
        <rect width="100%" height="100%" filter="url(#lithosGrain)"/>
      It is STATIC — no animation, no per-frame cost. It is what makes two gradient plates read as
      photographed rock instead of as CSS.
    - `.spot-ring` (z-index 4): see D.5.
    - `.vignette` (z-index 5): `position:absolute; inset:0; pointer-events:none;` with
      `background: radial-gradient(120% 92% at 50% 44%, rgba(0,0,0,0) 0%, rgba(0,0,0,0.30) 56%, rgba(0,0,0,0.74) 100%)`.
      This is what guarantees the white copy always wins, however bright the deep core is.
    - <main> itself is `isolation:isolate` so the grain's blend mode cannot reach outside the hero.

D. THE SPOTLIGHT — the signature effect, implement exactly this

D.1 Constants and state
    - `const SPOTLIGHT_R = 260;` (CSS pixels — the spotlight radius).
    - `const LERP = 0.10;`
    - Raw pointer `mouse = { x: innerWidth / 2, y: innerHeight / 2 }` and eased pointer
      `smooth = { x: mouse.x, y: mouse.y }`.
      NOTE: the source initialised the pointer off-screen at `{-999,-999}` so nothing was revealed
      until the mouse moved. Here the spotlight starts PARKED AT THE VIEWPORT CENTRE instead, so
      the page reads correctly on first paint, in a screenshot, and on touch devices with no
      pointer at all. This is deliberate — keep it.
    - One `mousemove` listener on `window` with `{ passive: true }` that ONLY stores
      `mouse.x = e.clientX; mouse.y = e.clientY;`. It must never touch the DOM, never read layout,
      never write a style.
    - The lerp runs in the single rAF loop (D.3): `smooth.x += (mouse.x - smooth.x) * LERP;` and the
      same for y. That 0.10 easing is the trailing weight of the light — it must be visible, so do
      not raise it above 0.12 and do not snap.

D.2 The mask — pure CSS, both prefixes, on `#layer-reveal`
      -webkit-mask-image: radial-gradient(circle 260px at var(--spot-x, 50%) var(--spot-y, 50%),
          rgba(255,255,255,1) 0%, rgba(255,255,255,1) 40%, rgba(255,255,255,0.75) 60%,
          rgba(255,255,255,0.40) 75%, rgba(255,255,255,0.12) 88%, rgba(255,255,255,0) 100%);
      mask-image: radial-gradient(circle 260px at var(--spot-x, 50%) var(--spot-y, 50%),
          rgba(255,255,255,1) 0%, rgba(255,255,255,1) 40%, rgba(255,255,255,0.75) 60%,
          rgba(255,255,255,0.40) 75%, rgba(255,255,255,0.12) 88%, rgba(255,255,255,0) 100%);
      -webkit-mask-repeat: no-repeat;  mask-repeat: no-repeat;
      -webkit-mask-size: 100% 100%;    mask-size: 100% 100%;
    These are the source's exact falloff stops (1, 1, 0.75, 0.40, 0.12, 0 at 0 / 40 / 60 / 75 / 88 /
    100%). The revealed disc is solid through its first 40% and dissolves softly to nothing at
    260px, which is the "soft circular mask" the design is named for. Because the gradient's last
    stop is fully transparent, EVERY pixel more than 260px from the spotlight centre has mask alpha
    0: the reveal plate is completely invisible there and only the cold base plate shows.

D.3 The single loop
    One `requestAnimationFrame` loop drives the entire page. Every tick, in this order:
      1. `smooth.x += (mouse.x - smooth.x) * LERP;`  (then y)
      2. write the spotlight position (D.4)
      3. `window.__DEMO__.frames += 1;`
      4. `requestAnimationFrame(frame);`
    The loop keeps running when the pointer is still — that is what lets the light settle with
    weight instead of freezing, and what keeps the telemetry honest. Never start a second loop.

D.4 Writing the position — cheap, and never layout
    Write exactly two custom properties on `#layer-reveal` itself:
      reveal.style.setProperty('--spot-x', x.toFixed(1) + 'px');
      reveal.style.setProperty('--spot-y', y.toFixed(1) + 'px');
    Skip the write entirely when both values changed by less than 0.1px since the previous frame
    (cache `lastX` / `lastY`) — that keeps a still page at zero paint cost. Never write `left`,
    `top`, `width`, `height`, `margin` or `background-position`; never call
    `getBoundingClientRect` inside the loop. `transform` and custom properties only.

D.5 The rim — `.spot-ring`
    One 520px x 520px (2 x SPOTLIGHT_R) absolutely positioned div at `top:0; left:0`,
    `border-radius:50%`, `pointer-events:none`, z-index 4, with
      background: radial-gradient(circle, rgba(255,168,88,0) 62%, rgba(255,168,88,0.12) 80%, rgba(255,168,88,0) 92%);
    Its default CSS transform is `translate3d(calc(50vw - 260px), calc(50vh - 260px), 0)` so it is
    already centred before the script runs, and the loop then overwrites it with
    `transform: translate3d(Xpx, Ypx, 0)` where X = smooth.x - SPOTLIGHT_R and Y = smooth.y - SPOTLIGHT_R.
    Transform only. It lives inside <main>, which is `overflow:hidden`, so its 520px box can never
    create a scrollbar at 390px.

D.6 Coarse pointers and resize
    - Wrap the `mousemove` listener in a `window.matchMedia('(pointer: fine)').matches` guard. On a
      touch device nothing is attached and the spotlight simply stays parked at the centre — the
      page must look deliberate, not broken.
    - One `resize` listener clamps `mouse` and `smooth` into the new `innerWidth` / `innerHeight`.
      No ResizeObserver, no scroll listener, no IntersectionObserver.

E. THE COMPOSITION — copy is fixed, use these exact strings

E.1 <header class="site-header">
    `position:fixed; top:0; left:0; right:0; z-index:100; display:flex; align-items:center;
    justify-content:space-between; padding:18px 24px;` and at >=768px `padding:22px 40px`.
    - Left — `<a class="brand" href="#top">`: `display:inline-flex; align-items:center; gap:12px;
      color:#fff; text-decoration:none;` containing
        · an inline `<svg width="26" height="26" viewBox="0 0 256 256" aria-hidden="true"
          focusable="false">` with exactly one path:
          `<path fill="#ffffff" d="M 256 256 L 128 256 L 0 128 L 128 128 Z M 256 128 L 128 128 L 0 0 L 128 0 Z"/>`
          (the source's two-diamond mark, kept verbatim);
        · `<span class="brand-word">Lithos</span>` — 24px, weight 400, italic, colour #ffffff,
          `font-family:var(--font-display)`.
    - Centre — `<nav aria-label="Primary">`, `position:absolute; left:50%; transform:translateX(-50%);`
      `display:none` below 768px, otherwise `display:flex; align-items:center; gap:2px; padding:6px;`
      with `background:rgba(255,255,255,0.16); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);
      border:1px solid rgba(255,255,255,0.28); border-radius:999px;`.
      Exactly five `<button type="button">` children, in this order and with these exact labels:
        **Course** (the active one — `background:rgba(255,255,255,0.92); color:#0a0b0d;` and
        `aria-current="page"`), then **Field Guides**, **Geology**, **Plans**, **Live Tour**
        (`color:rgba(255,255,255,0.80)`; hover `background:rgba(255,255,255,0.20); color:#ffffff`).
      Every one of the five: `padding:7px 16px; border:0; border-radius:999px; font-size:13.5px;
      font-weight:500; cursor:pointer; white-space:nowrap; font-family:inherit;
      transition:background .25s ease, color .25s ease`.
    - Right — `<a class="btn-signup" href="#signup">Sign Up</a>`: `background:#ffffff; color:#111318;
      border-radius:999px; padding:10px 24px; font-size:13.5px; font-weight:600; text-decoration:none;
      white-space:nowrap;`, hover `background:#e9ecef`. `display:none` below 768px.
    - Below 768px a `<button class="burger" type="button" aria-label="Open menu"
      aria-expanded="false" aria-controls="mobile-menu">` is shown in the right slot (40x40, 1px
      `rgba(255,255,255,0.28)` border, radius 10px, transparent background) holding three 1px x 16px
      white <span> bars. This is the source's lucide hamburger, redrawn by hand.
      `#mobile-menu` is a `<div class="mobile-menu" id="mobile-menu" hidden>` at
      `position:fixed; inset:0; z-index:90; background:rgba(5,6,10,0.84);
      backdrop-filter:blur(18px); -webkit-backdrop-filter:blur(18px); display:flex;
      flex-direction:column; align-items:center; justify-content:center; gap:18px;`
      containing the same five labels as `<a class="menu-link" href="#course">`-style links (22px,
      white) plus `<a class="menu-signup" href="#signup">Sign Up</a>` (solid white pill, dark text).
      `.mobile-menu[hidden] { display:none }` — without that rule the `hidden` attribute loses to
      the `display:flex` above. Opening sets `aria-expanded="true"`; the menu closes on a click on
      any link, on Escape, and on a resize to >=768px.

E.2 <main class="hero">
    `position:absolute; inset:0; overflow:hidden; isolation:isolate; background:#05060a;` — the
    layer stack from C, the rim from D.5, and the copy below all live here. The base plate bleeds
    behind the fixed header, exactly as the source's background image did.

E.3 The copy — inside `<div class="hero-copy">` at `position:absolute; inset:0; z-index:10;
    pointer-events:none;` (its links re-enable `pointer-events:auto` so the masked plates never
    swallow a click):
    - `<h1 class="hero-title">` — `position:absolute; top:14%; left:0; right:0; margin:0;
      padding:0 20px; text-align:center; color:#ffffff; line-height:0.95; font-weight:400;`
      containing EXACTLY two `display:block` spans and nothing else:
        · `<span class="line-1">Layers hold</span>` — `font-family:var(--font-display);
          font-style:italic; font-weight:400; font-size:var(--h1); letter-spacing:-0.05em;`
        · `<span class="line-2">tales of time</span>` — `font-family:var(--font-sans);
          font-weight:400; font-size:var(--h1); letter-spacing:-0.08em; margin-top:-4px;`
      The two lines are the whole headline. Nothing else may be inside the <h1>.
    - `<p class="lede-left">` — `position:absolute; bottom:104px; left:40px; max-width:260px;
      margin:0; font-size:14px; line-height:1.65; color:rgba(255,255,255,0.80);`, exact text:
      "Every layer of sediment records a chapter of our planet, from ancient seabeds to drifting
      ash, layered across millions of years beneath us." It is `display:none` below 640px.
    - `<div class="cta-block">` — `position:absolute; bottom:96px; right:40px; max-width:260px;
      display:flex; flex-direction:column; align-items:flex-start; gap:20px;` containing
        · `<p class="lede-right">` — `margin:0; font-size:13.5px; line-height:1.65;
          color:rgba(255,255,255,0.80);`, exact text: "Our interactive maps let you peel back the
          crust to trace how stones, fossils, and deep time combine to shape the ground beneath
          your feet."
        · `<a class="btn-dig" href="#dig">Start Digging</a>` — `background:#e8702a; color:#ffffff;
          font-size:13.5px; font-weight:500; padding:12px 28px; border-radius:999px;
          text-decoration:none; white-space:nowrap; font-family:inherit;
          transition:background .25s ease, transform .25s ease, box-shadow .25s ease;`.
          Hover: `background:#d2611f; transform:scale(1.03); box-shadow:0 10px 30px rgba(232,112,42,0.30)`.
          Active: `transform:scale(0.95)`.

E.4 <footer class="site-footer">
    `position:absolute; left:0; right:0; bottom:0; z-index:100; display:flex; align-items:center;
    justify-content:space-between; gap:16px; padding:14px 24px;
    border-top:1px solid rgba(255,255,255,0.10); color:rgba(255,255,255,0.55);
    font-size:10.5px; letter-spacing:0.14em; text-transform:uppercase;` with two non-interactive
    spans: left "Lithos Field Institute", right "Deep Time Atlas / 2026". The source had no footer;
    the house contract requires the landmark, so it is one hairline row that never collides with
    the bottom-left paragraph (bottom:104px) or the CTA block (bottom:96px).

F. THE SYSTEM

F.1 Token table — declare every one of these on `:root` and use them rather than untracked values:
      --bg:#05060a;          --ink:#ffffff;        --muted:rgba(255,255,255,0.80);
      --muted-dim:rgba(255,255,255,0.55);
      --accent:#e8702a;      --accent-hi:#d2611f;  --line:rgba(255,255,255,0.10);
      --line-strong:rgba(255,255,255,0.28);        --glass:rgba(255,255,255,0.16);
      --spot-r:260px;
      --font-sans:'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
      --font-display:'Playfair Display', 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
      --h1:clamp(38px, 6.4vw, 92px);
      --ease-out:cubic-bezier(0.16, 1, 0.3, 1);
      --duration-reveal:1100ms;  --duration-fade:1000ms;  --duration-zoom:1800ms;

F.2 Reset — `*, *::before, *::after { box-sizing:border-box }`; `html, body { margin:0; padding:0 }`;
    `body { background:#05060a; color:var(--ink); font-family:var(--font-sans);
    -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; min-width:320px }`.

F.3 Fonts — exactly two preconnects and one stylesheet, nothing else:
      <link rel="preconnect" href="https://fonts.googleapis.com">
      <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@1,400;1,500&display=swap">
    If that request fails, the system stacks above must still look intentional.

F.4 Focus — `:focus-visible { outline:2px solid #ffffff; outline-offset:3px; border-radius:inherit }`.
    Never remove an outline without replacing it. The contrast of every visible string must clear
    4.5:1 against what is behind it.

G. MOTION — the source's entrance system, with the house anti-blank guard

G.1 Keyframes (these exact names and values):
      @keyframes heroReveal { 0% { opacity:0; transform:translateY(28px); filter:blur(12px) }
                              100% { opacity:1; transform:translateY(0); filter:blur(0) } }
      @keyframes heroFadeUp { 0% { opacity:0; transform:translateY(20px) }
                              100% { opacity:1; transform:translateY(0) } }
      @keyframes heroZoom   { 0% { transform:scale(1.12) } 100% { transform:scale(1) } }

G.2 Classes:
      .hero-anim  { opacity:1; animation-duration:1000ms; animation-fill-mode:both;
                    animation-timing-function:cubic-bezier(0.16, 1, 0.3, 1); }
      .hero-reveal{ animation-name:heroReveal; animation-duration:1100ms; }
      .hero-fade  { animation-name:heroFadeUp; animation-duration:1000ms; }
      .hero-zoom  { animation:heroZoom 1800ms cubic-bezier(0.16, 1, 0.3, 1) both; }
      .is-in      { animation:none !important; opacity:1 !important; transform:none !important;
                    filter:none !important; }
    The resting state is opacity 1 and the animation only ever animates FROM 0 — the source wrote
    `.hero-anim { opacity:0 }`, which blanks the page if an animation never runs. That is the one
    source rule you must NOT copy. Use `animation-fill-mode:both` so a delayed element is hidden by
    its own 0% keyframe during the delay, not by its base style.

G.3 Stagger table (element -> class -> animation-delay):
      .site-header      hero-anim hero-fade    100ms
      .brand            hero-anim hero-fade    160ms
      .hero-title .line-1  hero-anim hero-reveal  250ms
      .hero-title .line-2  hero-anim hero-reveal  420ms
      .lede-left        hero-anim hero-fade    700ms
      .cta-block        hero-anim hero-fade    850ms
      .site-footer      hero-anim hero-fade   1000ms
      #layer-base       hero-zoom              (no delay — the Ken Burns zoom-out, scale 1.12 -> 1)

G.4 Settlement — for every element carrying .hero-anim or .hero-zoom, add a listener for its own
    `animationend` that adds `.is-in`; then, after two nested requestAnimationFrames, add `.is-in`
    to any of them whose `getAnimations()` (guarded with `typeof el.getAnimations === 'function'`)
    reports nothing running or finished. That is the "page is never blank" guard: if animations are
    unsupported or suppressed, every element is already in its final, visible state.

G.5 `@media (prefers-reduced-motion: reduce)` must contain:
      *, *::before, *::after { animation:none !important; transition:none !important; }
      .hero-anim, .hero-zoom, #layer-base { opacity:1 !important; transform:none !important;
                                            filter:none !important; }
    and the script must then attach no pointer listener and park the spotlight at the viewport
    centre (see I). The page stays completely readable and completely static — no zoom, no blur
    rise, no trailing light — while the telemetry loop keeps counting frames with zero visual
    change.

H. RESPONSIVE TABLE — every step is a token override

  >=1600px           --h1:96px;  header padding 26px 56px;  .lede-left/.cta-block offset 56px;
                     .lede-left max-width 280px; .cta-block max-width 280px
  1280px – 1599px    --h1:80px
  1024px – 1279px    --h1:68px;  header padding 20px 32px;  .lede-left/.cta-block offset 32px
  901px – 1023px     --h1:56px;  nav pill buttons 12.5px with padding 6px 12px

  <=900px  (phone + small tablet — vertical scrolling IS allowed here)
    html, body { height:auto; overflow-x:hidden }
    .page { height:auto; min-height:100dvh; overflow:visible }
    .hero { position:relative; inset:auto; min-height:100dvh; overflow:hidden;
            display:flex; flex-direction:column; justify-content:flex-end;
            padding:104px 24px 88px; }
    .hero-copy { position:static; inset:auto; pointer-events:auto;
                 display:flex; flex-direction:column; gap:22px; }
    .hero-title, .lede-left, .cta-block { position:static; inset:auto; text-align:left;
                                          max-width:420px; }
    .hero-title { padding:0; margin:0 0 4px }
    .lede-left { display:block }
    .site-footer { position:static }
    The plate layers, the grain, the rim and the vignette stay `position:absolute; inset:0` inside
    the hero, so they still cover the whole hero however tall it becomes. Nothing may extend past
    100vw: keep every padding inside the box and every max-width below the viewport.

  <=560px
    --h1:38px;  .brand-word 20px;  .lede-left font-size 13px;  .lede-right font-size 13px;
    .btn-dig padding 11px 22px;  .site-footer font-size 9.5px;  .hero padding 96px 20px 76px

  Below 768px: the centre nav pill and the desktop "Sign Up" pill are `display:none` and the
  burger plus #mobile-menu take over. Below 640px: .lede-left is `display:none` and the CTA block
  becomes the single bottom-anchored block.

I. THE JAVASCRIPT — use this structure, preserving every named constant, guard and telemetry field

  (() => {
    'use strict';
    window.__DEMO__ = {
      name: 'interactive-discovery', ready: false, frames: 0,
      effects: ['strata-layers', 'spotlight-mask', 'cursor-lerp', 'spot-ring',
                'entrance-stagger', 'ken-burns', 'grain'],
      errors: []
    };
    const D = window.__DEMO__;
    window.addEventListener('error', (e) => D.errors.push(String((e && e.message) || 'error')));

    const SPOTLIGHT_R = 260;
    const LERP = 0.10;
    const reveal = document.getElementById('layer-reveal');
    const ring = document.querySelector('.spot-ring');
    const reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
    const fine = !!(window.matchMedia && window.matchMedia('(pointer: fine)').matches);

    let vw = window.innerWidth, vh = window.innerHeight;
    const mouse = { x: vw / 2, y: vh / 2 };
    const smooth = { x: vw / 2, y: vh / 2 };
    let lastX = -1, lastY = -1;

    // entrance settlement + never-blank fallback (G.4)
    const animated = Array.prototype.slice.call(document.querySelectorAll('.hero-anim, .hero-zoom'));
    animated.forEach((el) => el.addEventListener('animationend', () => el.classList.add('is-in')));
    requestAnimationFrame(() => requestAnimationFrame(() => {
      animated.forEach((el) => {
        const running = typeof el.getAnimations === 'function'
          ? el.getAnimations().some((a) => a.playState === 'running' || a.playState === 'finished')
          : false;
        if (!running) el.classList.add('is-in');
      });
      D.ready = true;
    }));

    // mask capability guard — never throw, never go blank
    const maskOk = !!(window.CSS && window.CSS.supports &&
      (window.CSS.supports('mask-image', 'radial-gradient(#000,#000)') ||
       window.CSS.supports('-webkit-mask-image', 'radial-gradient(#000,#000)')));
    if (!maskOk && reveal) { reveal.classList.add('no-mask'); D.effects.push('mask-fallback'); }

    function write(x, y) {
      if (Math.abs(x - lastX) < 0.1 && Math.abs(y - lastY) < 0.1) return;
      lastX = x; lastY = y;
      if (reveal) {
        reveal.style.setProperty('--spot-x', x.toFixed(1) + 'px');
        reveal.style.setProperty('--spot-y', y.toFixed(1) + 'px');
      }
      if (ring) {
        ring.style.transform = 'translate3d(' + (x - SPOTLIGHT_R).toFixed(1) + 'px,' +
                               (y - SPOTLIGHT_R).toFixed(1) + 'px,0)';
      }
    }
    write(smooth.x, smooth.y);            // first paint already shows the centre spotlight

    if (fine && !reduce) {
      window.addEventListener('mousemove', (e) => {
        mouse.x = e.clientX; mouse.y = e.clientY;
      }, { passive: true });
      window.addEventListener('resize', () => {
        vw = window.innerWidth; vh = window.innerHeight;
        mouse.x = Math.min(Math.max(mouse.x, 0), vw);
        mouse.y = Math.min(Math.max(mouse.y, 0), vh);
        smooth.x = Math.min(Math.max(smooth.x, 0), vw);
        smooth.y = Math.min(Math.max(smooth.y, 0), vh);
      });
    }

    function frame() {
      requestAnimationFrame(frame);
      D.frames += 1;
      if (reduce) return;                 // static page, telemetry still counting
      smooth.x += (mouse.x - smooth.x) * LERP;
      smooth.y += (mouse.y - smooth.y) * LERP;
      write(smooth.x, smooth.y);
    }
    requestAnimationFrame(frame);
  })();

  No other JavaScript API. No console output. No unhandled promise. No network request other than
  Google Fonts. Target file size: 18-32 KB, hard ceiling 90 KB.

J. FAILURE GUARDS, collected

  J.1 If CSS animations never run, the entrance elements are already opacity 1 (G.2) and G.4 adds
      .is-in to them — the page is never blank and never unreadable.
  J.2 If `mask-image` is unsupported, `#layer-reveal.no-mask` falls back to
      `opacity:0.45; -webkit-mask-image:none; mask-image:none;` so the deep core still shows as a
      soft full-frame wash. Push 'mask-fallback' into effects and push nothing into errors.
  J.3 If `window.matchMedia` is missing, `reduce` and `fine` are false and the page simply runs
      with motion on. Nothing throws.
  J.4 If the reveal element is missing, `write()` skips it silently; the loop keeps counting frames.
  J.5 Under prefers-reduced-motion the loop still runs so `frames` advances, but it writes nothing:
      the spotlight is parked at the viewport centre, the page is static, readable and error-free.
  J.6 The whole script is one IIFE in a single <script> at the end of <body>. It must not run
      before the DOM it queries exists.

---

TECHNICAL CONTRACT (non-negotiable):

1. Deliver ONE single file: a complete, valid `index.html`. No build step, no bundler,
   no framework, no package install. It must open correctly by double-clicking the file
   and also over http://.
2. Vanilla only. No React, no Tailwind CDN, no GSAP, no Three.js, no jQuery, no Lottie.
   Plain HTML + CSS + modern JavaScript in <style> and <script> tags.
3. NO external media. No .mp4, no .webm, no .gif, no remote images, no base64 blobs of
   photos. Every visual must be generated procedurally with CSS gradients, SVG, or
   <canvas>. This keeps the page free to host and impossible to break with a dead link.
4. Google Fonts is the ONLY permitted network request, and it must be declared with a
   <link rel="preconnect"> plus a system-font fallback stack so the page still looks
   intentional offline. If you prefer, skip it and use a pure system stack.
5. Responsive and correct at 390x844 (phone), 768x1024 (tablet) and 1440x900 (desktop).
   No horizontal scrollbar at any of those widths. Nothing may overflow its container.
6. Accessibility: exactly one <h1>; real <header>/<main>/<footer>/<nav> landmarks; every
   interactive element is a <button> or <a> with an accessible name; visible :focus-visible
   ring; body text contrast ratio >= 4.5:1 against its background.
7. Motion safety: wrap all continuous motion in
   `@media (prefers-reduced-motion: reduce)` rules that reduce it to a static or
   near-static state. The page must remain fully readable and usable with motion off.
8. Performance: no layout thrash. Cache DOM reads, batch writes inside a single
   requestAnimationFrame loop, use transform/opacity for animation, and never allocate
   inside the hot loop. Target 60fps on a 2019 laptop. Total file under 90 KB.
9. Runtime telemetry hook (used by the automated verifier — implement it exactly):
   at the top of your script define
     window.__DEMO__ = { name: '<slug>', ready: false, frames: 0, effects: [], errors: [] };
   then set `ready = true` after the first paint of your main effect, increment
   `frames` once per animation frame inside your rAF loop, and push the name of each
   implemented effect into `effects`. Add a `window.addEventListener('error', ...)`
   handler that pushes messages into `errors`. Do not rename these fields.
10. Zero console output in the happy path. No console.log left behind. No uncaught
    exceptions, no failed network requests.

RESPONSE FORMAT:
Return ONLY the raw HTML inside a single ```html fenced code block. No explanation, no
preamble, no commentary before or after the fence. The file must be complete and
immediately runnable — never truncate, never write "// rest of code here".
```

---

*LandingPagePrompts Prompt Library — free, verified, model-attributed.*
