# Hero Warp Slider

## Goal
Build a **full-screen WebGL hero slider**: a Three.js fragment shader renders the current image cover-fit to the viewport, and **clicking anywhere advances to the next slide through an expanding "lens-warp bubble"** — a circular magnifying-lens distortion that grows from the center of the screen until the new image fills the frame. In sync, the overlay text (a big uppercase title split into characters, and a small description split into lines) **slides out upward and the next slide's text slides back in**, every fragment masked by an `overflow: hidden` wrapper. The star effect is the shader transition driven by a single GSAP tween on the `uProgress` uniform (0 → 1, 2.5s, `power2.inOut`) plus the GSAP/SplitText text choreography.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) with the **`SplitText`** plugin, and `three` (npm):
```js
import * as THREE from "three";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";

gsap.registerPlugin(SplitText);
gsap.config({ nullTargetWarn: false });
```
Put the slide data in a sibling module `slides.js` (`export const slides = [...]`) and the two shader strings in `shaders.js` (`export const vertexShader / fragmentShader`), imported into `script.js`. No Lenis, no ScrollTrigger — the page doesn't scroll.

## Layout / HTML
```html
<div class="slider">
  <canvas></canvas>

  <div class="slider-content">
    <div class="slide-title">
      <h1>Quiet Green</h1>
    </div>
    <div class="slide-description">
      <p>A cinematic study of solitude, nature, and a gaze that remembers something forgotten.</p>
      <div class="slide-info">
        <p>Type. Editorial</p>
        <p>Field. Fine Art</p>
        <p>Date. 2025</p>
      </div>
    </div>
  </div>
</div>
<script type="module" src="./script.js"></script>
```
- The `<canvas>` is the WebGL surface; `.slider-content` is an absolutely-positioned DOM overlay on top of it.
- Only slide 1 is in the initial HTML. Every later slide's content node is **built in JS** from the data module and swapped in during the transition.

`slides.js` — 4 entries, each `{ title, description, type, field, date, image }`:
1. `"Quiet Green"` / "A cinematic study of solitude, nature, and a gaze that remembers something forgotten." / Editorial / Fine Art / 2025 / image 1
2. `"Crimson Reign"` / "Ornate textures and ceremonial gold unravel across a sea of red—silent power in stillness." / Editorial / Conceptual / 2022 / image 2
3. `"Gilded Brow"` / "A baroque close-up capturing the tactile intimacy of skin, shadow, and the glitter of ritual." / Detail Study / Experimental / 2024 / image 3
4. `"Golden Flight"` / "A blur of motion in sun-soaked gold—freedom becomes visible only in the act of leaving." / Motion Still / Cinematic / 2023 / image 4

## Styling
Font import: `@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");`

Global reset `* { margin:0; padding:0; box-sizing:border-box; }`, `body { font-family:"Inter"; }`

- `h1`: `text-transform:uppercase; font-size:7vw; font-weight:700; line-height:1;`
- `p`: `font-size:0.95rem;`
- `.slider`: `position:relative; width:100vw; height:100svh; color:#fff; overflow:hidden;`
- `canvas`: `display:block; width:100%; height:100%;`
- `.slider-content`: `position:absolute; top:0; left:0; width:100%; height:100%; user-select:none; z-index:2;`
- `.slide-title`: `position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); width:100%; text-align:center;`
- `.slide-description`: `position:absolute; top:60%; left:60%; transform:translate(-50%,-50%); width:25%; display:flex; flex-direction:column; gap:2rem;` (sits right-of-center, below the title)
- `.slide-info p`: `text-transform:uppercase;`
- **Split-text scaffolding (required for the masked reveals):**
  - `.slide-title h1 { display:flex; justify-content:center; gap:0.2em; }`
  - `.slide-title h1 .word { display:flex; }`
  - `.slide-title h1 .char { display:block; }`
  - `.char, .line { overflow:hidden; }` — the masks
  - `.char span, .line span { display:inline-block; will-change:transform; position:relative; }` — the animated inner spans
- Responsive `@media (max-width:1000px)`: `.slide-title { top:50%; }` and `.slide-description { width:75%; text-align:center; top:unset; bottom:5%; left:50%; transform:translate(-50%,-50%); }`

## Text splitting
Two different mechanisms:

**Title — manual character splitter** (function `createCharacterElements(element)`; bail out if the element already contains `.char` nodes). Split `textContent` on spaces; for each word append `<div class="word">` containing one `<div class="char"><span>X</span></div>` per character; between words append an extra `.word` holding `<div class="char"><span> </span></div>` (a real space character in a span). This gives per-character masks while the flex `gap:0.2em` on the h1 provides word spacing.

**Description — SplitText lines.** For every `.slide-description p` run `new SplitText(element, { type: "lines", linesClass: "line" })`, then post-process each `.line` by replacing its innerHTML with `<span>${line.textContent}</span>` so each line has an inner span to translate inside the `overflow:hidden` line.

Wrap both in `processTextElements(container)` — split the title h1 with the char splitter and all description `p`s (including the three `.slide-info` rows) with the line splitter.

## Three.js scene (the stage)
- `scene = new THREE.Scene();`
- `camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);` — a fullscreen quad rig.
- `renderer = new THREE.WebGLRenderer({ canvas: document.querySelector("canvas"), antialias: true });` then `renderer.setSize(window.innerWidth, window.innerHeight);`
- One mesh: `new THREE.Mesh(new THREE.PlaneGeometry(2, 2), shaderMaterial)` added to the scene.
- `shaderMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader })` with uniforms:
  - `uTexture1: { value: null }` (current image), `uTexture2: { value: null }` (incoming image)
  - `uProgress: { value: 0.0 }` — the transition driver
  - `uResolution: { value: new THREE.Vector2(innerWidth, innerHeight) }`
  - `uTexture1Size` / `uTexture2Size`: `new THREE.Vector2(1, 1)` — the pixel dimensions of each texture, needed for cover-fit.
- **Texture loading:** with a `THREE.TextureLoader`, load each slide's image sequentially (await a promise around `loader.load`); set `texture.minFilter = texture.magFilter = THREE.LinearFilter;` and stash `texture.userData = { size: new THREE.Vector2(image.width, image.height) };`. Push into a `slideTextures` array. After loading, seed `uTexture1` with texture 0 and `uTexture2` with texture 1 (plus their sizes).
- Plain `requestAnimationFrame` loop calling `renderer.render(scene, camera)` forever.
- Resize handler: `renderer.setSize(innerWidth, innerHeight)` and `uResolution.value.set(innerWidth, innerHeight)`.

## Shaders (`shaders.js`)

**Vertex shader** — passthrough forwarding `uv` as `vUv`:
```glsl
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
```

**Fragment shader** — the lens-warp bubble. Uniforms: `sampler2D uTexture1, uTexture2; float uProgress; vec2 uResolution, uTexture1Size, uTexture2Size; varying vec2 vUv;`

Helper 1 — CSS-`cover` UVs so any aspect ratio fills the screen without stretching:
```glsl
vec2 getCoverUV(vec2 uv, vec2 textureSize) {
  vec2 s = uResolution / textureSize;
  float scale = max(s.x, s.y);
  vec2 scaledSize = textureSize * scale;
  vec2 offset = (uResolution - scaledSize) * 0.5;
  return (uv * uResolution - offset) / scaledSize;
}
```

Helper 2 — push a UV along a direction (y displacement doubled for a stretched, anamorphic feel):
```glsl
vec2 getDistortedUv(vec2 uv, vec2 direction, float factor) {
  vec2 scaledDirection = direction;
  scaledDirection.y *= 2.0;
  return uv - scaledDirection * factor;
}
```

Helper 3 — the lens. Returns a struct `LensDistortion { vec2 distortedUV; float inside; }`:
```glsl
LensDistortion getLensDistortion(vec2 p, vec2 uv, vec2 sphereCenter,
                                 float sphereRadius, float focusFactor) {
  vec2 distortionDirection = normalize(p - sphereCenter);
  float focusRadius = sphereRadius * focusFactor;
  float focusStrength = sphereRadius / 3000.0;
  float focusSdf = length(sphereCenter - p) - focusRadius;
  float sphereSdf = length(sphereCenter - p) - sphereRadius;
  float inside = smoothstep(0.0, 1.0, -sphereSdf / (sphereRadius * 0.001));

  float magnifierFactor = focusSdf / (sphereRadius - focusRadius);
  float mFactor = clamp(magnifierFactor * inside, 0.0, 1.0);
  mFactor = pow(mFactor, 5.0);

  float distortionFactor = mFactor * focusStrength;
  vec2 distortedUV = getDistortedUv(uv, distortionDirection, distortionFactor);
  return LensDistortion(distortedUV, inside);
}
```
`inside` is a razor-thin smoothstep (width = 0.1% of the radius) — effectively a hard circular edge. Inside the bubble, distortion ramps from 0 at the inner "focus" circle (25% of the radius, sharp) to max at the rim, with `pow(…, 5.0)` biasing the warp hard toward the rim.

`main()` — work in **pixel space**:
```glsl
void main() {
  vec2 center = vec2(0.5, 0.5);
  vec2 p = vUv * uResolution;

  vec2 uv1 = getCoverUV(vUv, uTexture1Size);
  vec2 uv2 = getCoverUV(vUv, uTexture2Size);

  float maxRadius = length(uResolution) * 1.5;
  float bubbleRadius = uProgress * maxRadius;
  vec2 sphereCenter = center * uResolution;
  float focusFactor = 0.25;

  float dist = length(sphereCenter - p);
  float mask = step(bubbleRadius, dist);

  vec4 currentImg = texture2D(uTexture1, uv1);
  LensDistortion distortion =
    getLensDistortion(p, uv2, sphereCenter, bubbleRadius, focusFactor);
  vec4 newImg = texture2D(uTexture2, distortion.distortedUV);

  float finalMask = max(mask, 1.0 - distortion.inside);
  vec4 color = mix(newImg, currentImg, finalMask);
  gl_FragColor = color;
}
```
Net effect: at `uProgress = 0` you see only texture 1; as progress rises a circle grows from screen center showing texture 2 warped like glass, and at `uProgress = 1` (radius = 1.5 × the viewport diagonal) texture 2 covers everything, undistorted at the center.

## GSAP effect (the important part — be exact)

### State
`currentSlideIndex = 0`, `isTransitioning = false`. Global listeners:
- `window "load"` → run the intro text animation, then initialize the renderer/textures.
- `document "click"` (anywhere) → `handleSlideChange()`.
- `window "resize"` → resize handler above.

### 1) Intro on page load
After `processTextElements` on the initial `.slider-content`, grab `chars = .char span` and `lines = .line span`, then:
- `gsap.fromTo(chars, { y: "100%" }, { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out" })`
- `gsap.fromTo(lines, { y: "100%" }, { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out", delay: 0.2 })`

Percentage `y` transforms, so each span rises exactly its own height out of the mask.

### 2) Click → shader transition
`handleSlideChange()`:
1. If `isTransitioning`, return. Set `isTransitioning = true`.
2. `nextIndex = (currentSlideIndex + 1) % slides.length` — infinite wrap.
3. Point the uniforms: `uTexture1` = texture of the current slide, `uTexture2` = texture of the next, and copy both `userData.size` vectors into `uTexture1Size`/`uTexture2Size`.
4. Kick off the text timeline (below) **and** the shader tween simultaneously:
```js
gsap.fromTo(shaderMaterial.uniforms.uProgress,
  { value: 0 },
  {
    value: 1,
    duration: 2.5,
    ease: "power2.inOut",
    onComplete: () => {
      shaderMaterial.uniforms.uProgress.value = 0;
      shaderMaterial.uniforms.uTexture1.value = slideTextures[nextIndex];
      shaderMaterial.uniforms.uTexture1Size.value = slideTextures[nextIndex].userData.size;
    },
  }
);
```
The tween animates the **uniform object's `value` property** directly. On complete it snaps progress back to 0 and promotes the new texture to `uTexture1` — visually seamless because at progress 1 the bubble already covers the screen.

### 3) Text out/in timeline (runs in parallel with the shader tween)
`animateSlideTransition(nextIndex)` builds one `gsap.timeline()`:
- **Chars out:** `.to(currentContent.querySelectorAll(".char span"), { y: "-100%", duration: 0.6, stagger: 0.025, ease: "power2.inOut" })` at time 0.
- **Lines out:** same tween on `.line span` (`y: "-100%"`, 0.6s, stagger 0.025, `power2.inOut`) at **position `0.1`** — description trails the title by 100ms.
- **`.call(fn, null, 0.5)`** — at the 0.5s mark (while the outro tails are still finishing):
  1. Build the next slide's `.slider-content` DOM node from `slides[nextIndex]` (same inner markup as the static HTML, with `Type.` / `Field.` / `Date.` prefixes) with inline `opacity: 0`.
  2. `timeline.kill()`, remove the old content node, append the new one to `.slider`.
  3. `gsap.set(newContent.querySelectorAll("span"), { y: "100%" })` as a pre-hide.
  4. **`setTimeout(…, 100)`** (lets layout settle so SplitText measures real line breaks), then:
     - `processTextElements(newContent)`;
     - `gsap.set([newChars, newLines], { y: "100%" })`; `gsap.set(newContent, { opacity: 1 })`;
     - a new timeline with `onComplete: () => { isTransitioning = false; currentSlideIndex = nextIndex; }`:
       - **Chars in:** `.to(newChars, { y: "0%", duration: 0.5, stagger: 0.025, ease: "power2.inOut" })` at time 0.
       - **Lines in:** `.to(newLines, { y: "0%", duration: 0.5, stagger: 0.1, ease: "power2.inOut" }, 0.3)` — lines enter with a chunkier 0.1 stagger, starting 0.3s in.

Timing summary: text exits over ~0.7s, new text enters from ~0.6s onward, all while the 2.5s bubble is still growing — the type settles well before the warp finishes. Clicks during the transition are ignored until the *text* timeline completes (`isTransitioning` is released by the text, not the shader tween).

## Assets / images
**4 full-bleed images**, one per slide, loaded as WebGL textures and cover-fit by the shader — any aspect works, but a cohesive cinematic set around **3:2–16:9 landscape, ≥1600px wide** looks best. Dark, painterly, editorial photography suits the white overlay type. Suggested roles:
1. **Slide 1 ("Quiet Green")** — moody portrait of a fair-haired woman lit from the side against near-black, framed by dark green foliage with small orange berries and a white flower.
2. **Slide 2 ("Crimson Reign")** — woman with long dark curls reclining on ornate red-and-gold brocade, gilded embroidery and gold objects around her.
3. **Slide 3 ("Gilded Brow")** — extreme macro of skin and a brow crowned by a glittering gold jeweled headpiece in warm light.
4. **Slide 4 ("Golden Flight")** — motion-blurred figure in pale cream running through sun-soaked golden grass with splashing water.

No brands or logos; any four images in this cinematic register work.

## Behavior notes
- **Click anywhere** on the page advances (listener on `document`), looping 1→2→3→4→1 forever. There are no arrows or dots.
- Transitions are **locked** while one runs (`isTransitioning` guard) — rapid clicks don't queue.
- Textures are awaited sequentially before the first render, so the first click always has both textures ready.
- Responsive: below 1000px the title centers vertically and the description drops to a centered block near the bottom. The canvas resizes with the window (renderer size + `uResolution`).
- No scroll, no Lenis, no reduced-motion branch in the original.
