# Guiding Light — Cursor Spotlight with Trailing Flame

## Goal

Build a three-section scrolling page. The middle section is the star: a **cursor-driven spotlight**. When the pointer enters that section a **dark veil drops over the whole section**, punched through by a soft circular hole that follows the cursor — so content (heading + paragraph) is dimmed everywhere except a moving "flashlight" window around the pointer. Inside that section a small **animated flame (Lottie) with a pulsing warm glow trails the cursor with a smooth lag**, drifting from its home position toward wherever you point and easing back to center when the pointer leaves the section. Both the spotlight hole and the flame move with lerped smoothing, not snapping. The other two sections (before/after) are plain full-screen headings you scroll past with Lenis smooth scroll.

## Tech

Vanilla HTML/CSS/JS with ES module imports. **No GSAP is used at all.** The only runtime dependencies are:

- `lenis` (npm) — page-wide smooth scroll, started with `new Lenis({ autoRaf: true })`.
- `lottie-web` (npm) — loads and plays the flame animation JSON, `renderer: "svg"`, `loop: true`, `autoplay: true`.

All motion of the spotlight and flame is produced by a single hand-rolled `requestAnimationFrame` loop that linearly interpolates (lerps) values into CSS custom properties and a `transform`. Do not reach for any tween library.

## Layout / HTML

Three stacked full-viewport `<section>`s. The middle one holds the spotlight machinery.

```html
<section class="intro">
  <h1>Enter the Field</h1>
</section>

<section class="spotlight">
  <div class="lottie-container">
    <div class="lottie"></div>
    <div class="fire-glow"></div>
  </div>

  <h1>Guided by Interaction</h1>
  <p>This space reacts with subtlety rather than instruction, responding to
     presence instead of force. Movement becomes a signal, gently shifting
     emphasis as light traces what matters in the moment. Nothing here
     competes for attention or demands a fixed outcome. Focus forms gradually,
     shaped by interaction, hesitation, and intent, allowing detail to surface
     only when it is approached.</p>

  <div class="spotlight-mask"></div>
</section>

<section class="outro">
  <h1>The Interaction Ends</h1>
</section>

<script type="module" src="./script.js"></script>
```

Use this neutral placeholder copy (no real brand names). The class names `.spotlight`, `.lottie-container`, `.lottie`, `.fire-glow`, `.spotlight-mask` are load-bearing (JS and CSS target them). lottie-web injects its SVG inside `.lottie`.

## Styling

Font: **Amarante** (Google Fonts) — a display serif, applied to the whole body.

Color tokens (on `:root`):

```css
:root {
  --light: #d0f2c2;   /* pale mint green — text */
  --dark:  #141414;   /* near-black — background & the veil */
  --mouse-x: 0;       /* px, live-updated by JS */
  --mouse-y: 0;       /* px, live-updated by JS */
}
```

- Global reset: `* { margin:0; padding:0; box-sizing:border-box; user-select:none; }`.
- `body { font-family:"Amarante",sans-serif; background-color:var(--dark); color:var(--light); }`.
- `h1 { font-size:clamp(3rem, 5vw, 7rem); font-weight:500; letter-spacing:-0.15rem; }`.
- `p { font-size:2rem; }`; `.spotlight p { width:60%; text-align:center; }`.
- `section { position:relative; width:100%; height:100svh; padding:2rem; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:1rem; }` — every section is a centered full-height column.

**Flame container:**

- `.lottie-container { position:relative; width:8rem; height:8rem; pointer-events:none; }` — the element JS translates.
- `.lottie { width:100%; height:100%; transform:scale(1.25); }` — the SVG flame, slightly enlarged.

**Warm glow behind the flame** (pure CSS, independent of JS):

- `.fire-glow { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:40%; height:100%; z-index:-1; pointer-events:none; filter:blur(20px); opacity:0.25; }`
- Background is a radial gradient of warm fire colors:
  `radial-gradient(circle, rgba(255,0,72,0.75) 0%, rgba(255,145,0,0.6) 30%, rgba(255,242,140,0.25) 50%, transparent 70%)` — hot pink-red core → orange → pale yellow → transparent.
- Continuously animated with `animation: firePulse 2s ease-in-out infinite;`:

```css
@keyframes firePulse {
  0%, 100% { transform:translate(-50%,-50%) scaleY(1);   opacity:0.5;  }
  25%      { transform:translate(-50%,-50%) scaleY(1.2);  opacity:0.4;  }
  50%      { transform:translate(-50%,-50%) scaleY(0.9);  opacity:0.35; }
  75%      { transform:translate(-50%,-50%) scaleY(1.1);  opacity:0.5;  }
}
```

**The spotlight veil / mask** (the heart of the look):

```css
.spotlight-mask {
  position:absolute; top:0; left:0; width:100%; height:100%;
  pointer-events:none;
  background: var(--dark);
  -webkit-mask: radial-gradient(
    circle 200px at var(--mouse-x) var(--mouse-y),
    transparent 0%,
    transparent 40%,
    var(--dark) 80%,
    var(--dark) 100%
  );
  mask: radial-gradient(
    circle 200px at var(--mouse-x) var(--mouse-y),
    transparent 0%,
    transparent 40%,
    var(--dark) 80%,
    var(--dark) 100%
  );
  transition: opacity 0.3s ease;
  opacity: 0;
}
.spotlight-mask.active { opacity: 0.85; }
```

How this reads: `.spotlight-mask` is a full-section overlay filled with the dark background color. The CSS mask (alpha mask) is a **200px-radius radial gradient centered at `(--mouse-x, --mouse-y)`**: alpha 0 (fully transparent) from the center out to 40% of the radius, ramping to opaque (`var(--dark)` is fully opaque) by 80%. Where the mask is transparent the veil is punched out → the section content shows through **bright**; where it's opaque the veil covers the content → **dimmed**. So the result is an inverted spotlight: a soft ~160px-radius clear circle around the cursor, everything else darkened. The veil is invisible (`opacity:0`) until JS adds `.active` (fades to `opacity:0.85` over 0.3s) when the cursor is inside the section. `--mouse-x/--mouse-y` are set live by JS on the `.spotlight` element and inherited by the mask.

Responsive: `@media (max-width:1000px){ .spotlight p { font-size:1.5rem; width:100%; } }`.

## The effect (be exhaustive — this is the whole component)

No GSAP timeline. Two synchronized behaviors both run off one `requestAnimationFrame` loop with a **lerp factor of 0.1**: (A) the spotlight hole position (via CSS vars) and (B) the flame container `translate`. Trigger is `mousemove` (plus scroll to keep it aligned).

### Init on load

- `new Lenis({ autoRaf: true })` — smooth scroll for the whole page.
- Load the flame: `lottie.loadAnimation({ container: .lottie, renderer:"svg", loop:true, autoplay:true, path: "/path/to/fire.json" })`.
- Grab `spotlight` (`.spotlight`), `lottieContainer` (`.lottie-container`), `spotlightMask` (`.spotlight-mask`).

### State & position bookkeeping

```js
const state = { isTracking: false, cursorDetected: false };
const pos = {
  mouse:  { target:{x:0,y:0}, current:{x:0,y:0}, last:{x:0,y:0} },
  lottie: { current:{x:0,y:0}, center:{x:0,y:0} },
};
```

`init()` (run once via `setTimeout(init, 100)` and again on window `resize`):

- `spotlightRect = spotlight.getBoundingClientRect()`, `lottieRect = lottieContainer.getBoundingClientRect()`.
- Store the flame's home center **relative to the spotlight section's top-left**:
  `pos.lottie.center.x = lottieRect.left - spotlightRect.left + lottieRect.width/2`; same for `.y`.
- Seed the mouse at the section center: `pos.mouse.current.x = pos.mouse.target.x = spotlightRect.width/2`; same for `.y`.

### Pointer / scroll input → `updateCursor(x, y)`

```js
function updateCursor(x, y) {
  if (!state.cursorDetected) return;
  pos.mouse.last.x = x; pos.mouse.last.y = y;              // remember last screen pos
  const r = spotlight.getBoundingClientRect();
  const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
  if (inside) {
    pos.mouse.target.x = x - r.left;                       // target = pointer, relative to section
    pos.mouse.target.y = y - r.top;
    state.isTracking = true;
    spotlightMask.classList.add("active");                // fade the veil in
  } else {
    state.isTracking = false;
    spotlightMask.classList.remove("active");             // fade the veil out
  }
}
```

Wire it up:

- `window` `mouseenter` (`{ once:true }`) and `mouseover` (`{ once:true }`): set `state.cursorDetected = true` then `updateCursor(e.clientX, e.clientY)` — first-contact bootstrap so the effect can start.
- `document` `mousemove`: `state.cursorDetected = true; updateCursor(e.clientX, e.clientY)` — the main driver.
- `window` `scroll`: if `state.cursorDetected`, call `updateCursor(pos.mouse.last.x, pos.mouse.last.y)` — re-evaluates using the last known cursor position so the spotlight stays aligned to the content as it scrolls under a stationary pointer (and correctly deactivates when the section scrolls away from the cursor).
- `window` `resize`: `init`.

### The rAF loop (`animate`) — runs forever

```js
function animate() {
  // (A) smooth the spotlight hole toward the pointer (lerp 0.1)
  pos.mouse.current.x += (pos.mouse.target.x - pos.mouse.current.x) * 0.1;
  pos.mouse.current.y += (pos.mouse.target.y - pos.mouse.current.y) * 0.1;
  spotlight.style.setProperty("--mouse-x", `${pos.mouse.current.x}px`);
  spotlight.style.setProperty("--mouse-y", `${pos.mouse.current.y}px`);

  // (B) flame target: offset from its home center while tracking, else 0 (return home)
  const targetX = state.isTracking ? pos.mouse.current.x - pos.lottie.center.x : 0;
  const targetY = state.isTracking ? pos.mouse.current.y - pos.lottie.center.y : 0;
  pos.lottie.current.x += (targetX - pos.lottie.current.x) * 0.1;   // lerp 0.1
  pos.lottie.current.y += (targetY - pos.lottie.current.y) * 0.1;
  lottieContainer.style.transform =
    `translate(${pos.lottie.current.x}px, ${pos.lottie.current.y}px)`;

  requestAnimationFrame(animate);
}
setTimeout(init, 100);
animate();
```

Behavior that produces:

- **Spotlight hole:** `pos.mouse.current` eases toward `pos.mouse.target` at 0.1/frame, and its value is written into `--mouse-x/--mouse-y`, which reposition the mask gradient's center. The hole therefore chases the cursor with a soft trailing lag rather than snapping.
- **Flame trail:** while `isTracking`, the flame's target is `current mouse − flame home center`, i.e. the vector from its resting spot to the pointer; lerping `pos.lottie.current` toward that at 0.1/frame drifts the flame toward the cursor. When the pointer leaves the section (`isTracking=false`), the target becomes `0`, so the flame eases back to its home position. The `.fire-glow` keeps pulsing independently via its CSS `firePulse` keyframes the whole time.
- Because both use the same 0.1 factor, hole and flame feel unified — a gentle, weighty follow, never instantaneous.

## Assets / images

**One Lottie JSON** (no bitmap images): a **looping stylized animated flame** — a small candle/torch-like fire, warm palette (hot red core, orange mid, pale-yellow tips), transparent background, roughly **4:3** source (≈1600×1200), a **seamless ~2s loop at 60fps**. Rendered as inline SVG at `8rem` box, scaled 1.25. Any tasteful animated flame works; do not use branded artwork. Load it from an assets path such as `/path/to/fire.json`.

## Behavior notes

- **Desktop / pointer-driven only.** The whole spotlight is `mousemove`-based (plus `scroll` re-alignment); there is no touch/click path. Outside the `.spotlight` section the veil is inactive and the flame sits at home.
- Lenis smooth scroll applies to the entire 3-section page; the spotlight/flame effect is scoped to the middle section.
- `init` is deliberately delayed 100ms after load (so layout is settled) and re-run on resize to recompute the flame's home center and section geometry.
- The rAF loop runs continuously with no reduced-motion branch. Keep `--light #d0f2c2`, `--dark #141414`, the warm glow gradient, and the mask stops (200px radius; transparent 0–40%, opaque 80–100%) at these values for the look to read correctly.
