# Work Section Scroll — Curved Letter Streams + WebGL Filmstrip

## Goal
Build a full-viewport, pinned "WORK" section wedged between a red intro and a red outro. While the section is pinned for **700% of the viewport height**, three layered effects run in sync, all driven by one ScrollTrigger progress value:

1. A **red dot grid** (2D canvas) that slides horizontally as you scroll.
2. Sixty HTML letter `<div>`s — fifteen each of **W, O, R, K** — that stream along **four Three.js CatmullRom curves**, projected from 3D world space to screen pixels every scroll tick, with a per-frame **lerp (0.07)** gliding each letter toward its target and a snap rule that hides the wrap-around jump.
3. A **parabolically warped Three.js plane** carrying a `CanvasTexture` filmstrip of 7 project images that slides across the screen from right to left over the full scroll.

Lenis smooths the scroll; a single `ScrollTrigger` (pin + `scrub: 1`) feeds its `progress` to all three layers in `onUpdate`.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** with the **`ScrollTrigger`** plugin, **`lenis`** for smooth scrolling, and **`three`** for the WebGL layers:

```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
import * as THREE from "three";

gsap.registerPlugin(ScrollTrigger);
```

Everything runs inside a `DOMContentLoaded` handler declared `async` (the image textures are awaited before the plane is built).

## Layout / HTML
Three stacked full-viewport sections; the middle one is the stage. All canvases and letters are created in JS:

```html
<body>
  <section class="intro"><h1>( Intro )</h1></section>
  <section class="work">
    <div class="text-container"></div>
  </section>
  <section class="outro"><h1>( Outro )</h1></section>
  <script type="module" src="./script.js"></script>
</body>
```

JS appends into `.work`, in this order:
- a 2D `<canvas id="grid-canvas">` (the dot grid),
- the letters WebGL renderer canvas (`id="letters-canvas"`),
- the cards WebGL renderer canvas (`id="cards-canvas"`),
- and 60 `div.letter` elements go inside `.text-container`.

## Styling
Global reset `* { box-sizing:border-box; margin:0; padding:0; }`.

- `body`: `background-color: #f40c3f; overflow-x: hidden;` — the signature saturated red.
- `section`: `width:100vw; height:100vh; position:relative;`
- `.intro, .outro`: flex, centered both axes, `background-color:#f40c3f; color:#000;`. Their `h1`: a display sans-serif, `font-size:5vw; font-weight:lighter; text-transform:uppercase;`. `.outro { top:-0.125em; }` (tucks it under the pinned section, hiding a hairline seam).
- `.work`: `position:relative; background-color:#000; overflow:hidden;`
- All canvases: `position:absolute; top:0; left:0;`. Stacking order matters: `#grid-canvas { z-index:0; }`, `#letters-canvas { z-index:1; }`, `.text-container { z-index:2; }`, `#cards-canvas { z-index:10; }` — the filmstrip plane renders **on top of the letters**, the dot grid behind everything.
- `.text-container`: `width:100%; height:100%; position:absolute; top:0; left:0; z-index:2; pointer-events:none; perspective:2500px; perspective-origin:center;`
- `.letter`: `position:absolute; font-family: a heavy sans-serif; font-size:14rem; font-weight:bold; color:#f40c3f; text-shadow:1px 1px 2px rgba(0,0,0,0.1); opacity:1; z-index:2; transform-origin:center; transform-style:preserve-3d; will-change:transform;`

No webfont files are required — a bold system sans-serif is fine; the look is defined by the giant 14rem red glyphs on black.

## GSAP effect (be exhaustive)

### 1. Lenis + GSAP ticker wiring
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
```

### 2. The single ScrollTrigger (drives everything)
```js
ScrollTrigger.create({
  trigger: ".work",
  start: "top top",
  end: "+=700%",
  pin: true,
  pinSpacing: true,
  scrub: 1,
  onUpdate: (self) => {
    updateTargetPositions(self.progress); // letters
    drawCardsOnCanvas(self.progress);     // filmstrip texture
    drawGrid(self.progress);              // dot grid
    cardsTexture.needsUpdate = true;      // re-upload the canvas texture
  },
});
```
No timelines, no tweened properties — the scrub-smoothed `progress` (0→1 across 7 viewport heights) is piped straight into three imperative draw/update functions. `scrub: 1` gives ~1 s of catch-up smoothing on top of Lenis.

### 3. Dot grid canvas (z-index 0)
- Sized to the viewport × `devicePixelRatio` (set canvas `width/height` to `innerWidth*dpr / innerHeight*dpr`, CSS size to `innerWidth/innerHeight` px, then `ctx.scale(dpr, dpr)`).
- `drawGrid(scrollProgress)`:
  - fill the whole canvas **black**, then `fillStyle = "#f40c3f"`;
  - `dotSize = 0.75` (arc radius, px), `spacing = 20`;
  - `rows = Math.ceil(canvas.height / spacing)`, `cols = Math.ceil(canvas.width / spacing) + 15` (extra columns so the wrap never shows);
  - `offset = (scrollProgress * spacing * 10) % spacing;`
  - draw a filled circle at `(x * spacing - offset, y * spacing)` for every cell.

The grid marches **left** as you scroll, completing 10 wrap cycles over the full pin — a subtle conveyor-belt undercurrent.

### 4. Three.js setup (two scenes, two renderers)
- Two scenes: `lettersScene` and `cardsScene`.
- Two `PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 1000)`, both at `position.z = 20`.
- Two `WebGLRenderer({ antialias: true, alpha: true })`, both `setSize(innerWidth, innerHeight)` and `setClearColor(0x000000, 0)` (transparent). Letters renderer: `setPixelRatio(devicePixelRatio)`. Cards renderer: `setPixelRatio(Math.min(devicePixelRatio, 2))`. Give their DOM elements the ids `letters-canvas` and `cards-canvas` and append both to `.work`.

### 5. The four letter paths (CatmullRom curves)
```js
const createTextAnimationPath = (yPos, amplitude) => {
  const points = [];
  for (let i = 0; i <= 20; i++) {
    const t = i / 20;
    points.push(new THREE.Vector3(
      -25 + 50 * t,                                   // x: -25 → +25
      yPos + Math.sin(t * Math.PI) * -amplitude,      // y: bowed by a half sine
      (1 - Math.pow(Math.abs(t - 0.5) * 2, 2)) * -5   // z: 0 at ends, -5 in the middle
    ));
  }
  const curve = new THREE.CatmullRomCurve3(points);
  const line = new THREE.Line(
    new THREE.BufferGeometry().setFromPoints(curve.getPoints(100)),
    new THREE.LineBasicMaterial({ color: 0x000, linewidth: 1 })
  );
  line.curve = curve;
  return line;
};
```
Four paths, added to `lettersScene` (the black lines are invisible on the black background — they're just carriers for the curves):
```js
const paths = [
  createTextAnimationPath(10, 2),
  createTextAnimationPath(3.5, 1),
  createTextAnimationPath(-3.5, -1),
  createTextAnimationPath(-10, -2),
];
```
Each row spans the full width, bows vertically (outer rows twice as much as inner rows, opposite directions), and dips **away from the camera** (z −5) at the center — that recession is what the screen projection turns into the curved, perspective-squeezed letter flow.

### 6. The 60 letter divs
For each path `i` (0–3), create **15** `div.letter` elements whose `textContent` is `["W","O","R","K"][i]` — so row 0 is all W's, row 1 all O's, row 2 all R's, row 3 all K's. Append them to `.text-container` and register each in a `Map` with `{ current: {x:0,y:0}, target: {x:0,y:0} }`.

### 7. Letter target projection (scroll-driven)
```js
const lineSpeedMultipliers = [0.8, 1, 0.7, 0.9];
const updateTargetPositions = (scrollProgress = 0) => {
  paths.forEach((line, lineIndex) => {
    line.letterElements.forEach((element, i) => {
      const point = line.curve.getPoint(
        (i / 14 + scrollProgress * lineSpeedMultipliers[lineIndex]) % 1
      );
      const vector = point.clone().project(lettersCamera);
      const positions = letterPositions.get(element);
      positions.target = {
        x: (-vector.x * 0.5 + 0.5) * window.innerWidth,
        y: (-vector.y * 0.5 + 0.5) * window.innerHeight,
      };
    });
  });
};
```
Details that matter:
- Letters are spread along the curve at `i / 14` (0 → 1 across the 15 letters), then offset by `progress × multiplier`, wrapped `% 1` — an endless conveyor along the curve.
- Each row has a **different speed multiplier** (`0.8, 1, 0.7, 0.9`), so the rows drift out of phase as you scroll.
- The projected NDC vector is mapped with **negated x and y** (`-vector.x`, `-vector.y`) — this mirrors the path both ways, making letters travel **right → left** and flipping the rows vertically (the `yPos: 10` path renders near the bottom).

### 8. Per-frame letter easing (rAF loop)
```js
const updateLetterPositions = () => {
  letterPositions.forEach((positions, element) => {
    const distX = positions.target.x - positions.current.x;
    if (Math.abs(distX) > window.innerWidth * 0.7) {
      // wrapped around the curve — snap, don't streak across the screen
      positions.current.x = positions.target.x;
      positions.current.y = positions.target.y;
    } else {
      positions.current.x = lerp(positions.current.x, positions.target.x, 0.07);
      positions.current.y = lerp(positions.current.y, positions.target.y, 0.07);
    }
    element.style.transform =
      `translate(-50%, -50%) translate3d(${positions.current.x}px, ${positions.current.y}px, 0px)`;
  });
};
```
- `lerp(start, end, t) = start + (end - start) * t` with factor **0.07** — the letters trail their targets with heavy inertia, so fast scrolls smear them elegantly along the curves.
- The `> innerWidth * 0.7` snap is essential: when a letter's curve parameter wraps `% 1`, its target jumps from one screen edge to the other; snapping prevents a visible dash across the viewport.
- The rAF loop runs continuously (independent of scroll): `updateLetterPositions()`, render `lettersScene`, render `cardsScene`, `requestAnimationFrame(animate)`.

### 9. Filmstrip texture (2D canvas → CanvasTexture)
- Load 7 image textures with `THREE.TextureLoader` (await all). On each: `generateMipmaps: true`, `minFilter: LinearMipmapLinearFilter`, `magFilter: LinearFilter`, `anisotropy: renderer.capabilities.getMaxAnisotropy()`.
- Create an offscreen canvas **4096×2048**.
- `drawCardsOnCanvas(offset = 0)`:
```js
ctx.clearRect(0, 0, 4096, 2048);
const cardWidth = 4096 / 3;        // ≈1365
const cardHeight = 2048 / 2;       // 1024 → 4:3 landscape cards
const spacing = 4096 / 2.5;        // ≈1638 (cards slightly overlap-spaced)
images.forEach((img, i) => {
  ctx.drawImage(img.image,
    i * spacing + (0.35 - offset) * 4096 * 5 - cardWidth,  // slides 5 canvas-widths right→left
    (2048 - cardHeight) / 2,
    cardWidth, cardHeight);
});
```
  At `offset = 0` the strip waits off-screen right; over the full scroll it travels `5 × 4096 = 20480` px left, so the 7 cards parade across mid-scroll.
- Wrap the canvas in a `THREE.CanvasTexture` with the same mipmap/filter/anisotropy settings plus `wrapS = wrapT = THREE.RepeatWrapping`. Set `needsUpdate = true` in the ScrollTrigger `onUpdate` after redrawing.

### 10. The warped plane
```js
const cardsPlane = new THREE.Mesh(
  new THREE.PlaneGeometry(30, 15, 50, 1),
  new THREE.MeshBasicMaterial({
    map: cardsTexture, side: THREE.DoubleSide,
    transparent: true, opacity: 1,
    depthTest: false, depthWrite: false,
  })
);
// parabolic bend: edges bulge toward the camera
const positions = cardsPlane.geometry.attributes.position;
for (let i = 0; i < positions.count; i++) {
  positions.setZ(i, Math.pow(positions.getX(i) / 15, 2) * 5);
}
positions.needsUpdate = true;
```
A 30×15 plane (50 width segments) whose z follows `(x/15)² × 5` — flat at center, +5 toward the camera at the edges — so the filmstrip appears to ride the inside of a gentle cylinder, wider than the viewport, floating over the letters (its canvas has z-index 10, and transparent clear color lets the grid and letters show through the empty texture areas).

### 11. Init + resize
- On load: `drawGrid(0)`, start the rAF loop, `updateTargetPositions(0)` (letters assemble into their resting curve immediately, easing in via the lerp).
- On `resize`: resize/rescale the grid canvas and redraw at the current `ScrollTrigger.getAll()[0]?.progress || 0`; update both cameras' `aspect` + `updateProjectionMatrix()`; `setSize` both renderers; re-clamp the cards renderer pixel ratio; recompute letter targets at the current progress.

## Assets / images
**7 project images**, drawn into **4:3 landscape** card slots (≈1365×1024 on the texture canvas). One shared role: interchangeable **project/portfolio cards** in the sliding filmstrip. They read as a cohesive futuristic-tech editorial set — e.g. an astronaut walking a bridge past glassy skyscrapers, a macro face wearing AR glasses with a HUD, a dark 3D render of mesh-wrapped concrete cylinders, an isometric empty cardboard box on a beige surface, a person in a VR headset beside a floating call UI, black AR sunglasses on a dark gradient, and a glowing cylindrical dwelling in a desert at dusk. Moody, saturated, high-production sci-fi/product photography works best against the black stage and red accents. Name them sequentially (`img1.jpg` … `img7.jpg`). If fewer are available, repeat in order.

## Behavior notes
- The page has real scroll height: intro (100vh) + pinned work section (held for 700% via `pinSpacing: true`) + outro (100vh). Lenis smooths the native scroll; ScrollTrigger's `scrub: 1` adds another layer of lag, and the letters add a third (lerp 0.07) — this triple smoothing is the signature feel.
- Scrolling back up reverses everything perfectly (all three layers are pure functions of `progress`).
- The letters keep easing after the scroll stops (rAF loop never pauses).
- Desktop-oriented: 14rem letters and fixed world-space sizes; no breakpoints, no reduced-motion handling in the original.
- WebGL required (two renderer contexts); heavy effect — don't add more pixel ratio than the caps above.
