# Infinite Vertical Project List — Lerp-Smoothed Scroll, Recycled Slides, Per-Image Parallax, Cubic Snap

## Goal
Build a **full-viewport, endlessly looping vertical project gallery**. Each "slide" is a 100vw × 100vh split screen — one half is a project title (name + catalog number), the other half is a full-bleed image — and the halves **alternate sides** down the list. Wheel and touch scrolling drive a `translateY` on every slide through a **lerp inside a `requestAnimationFrame` loop**, so nothing jumps: the whole column glides and settles with inertia-like smoothness. Three details sell it: (1) the list is **infinite in both directions** — slides are procedurally created around the viewport and recycled out of a `Map`, so you can scroll forever; (2) each slide's image gets an **independent parallax offset** (it drifts against the scroll at a 0.2 ratio, with its own secondary lerp); and (3) when scrolling **stops**, the list eases with a **cubic ease-out** to snap the nearest slide perfectly into the frame.

## Tech
Vanilla HTML/CSS/JS with ES module imports. **No GSAP and no animation libraries are needed** — the entire engine is a hand-rolled `requestAnimationFrame` loop with linear interpolation (lerp) plus a manual cubic-ease snap. No smooth-scroll library either: the page itself never scrolls (`body { overflow: hidden }`); the wheel is hijacked to drive the slides. Everything runs on plain DOM `style.transform` writes.

## Layout / HTML
The static HTML is almost empty — the JS builds every slide by cloning a hidden template.

```
.container
  ul.project-list
    .project.template  (style="display:none" — the clone source)
      .side
        .title
          h1  "Euphoria"
          h1  "01"
      .side
        .img > img (src="" alt="")

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

The template holds both halves in the **title-left / image-right** order; the JS rewrites each clone's `innerHTML` to place the image on the correct side per slide (see below). Key classes the engine depends on: `.container`, `.project-list`, `.project`, `.template`, `.side`, `.img`, `.title`.

Put the slide data inline in `script.js` as an array of 6 objects. `isAlternate` decides which half the image lands in (`false` = title-left/image-right, `true` = image-left/title-right); it alternates down the list:

```js
const projectData = [
  { title: "Euphoria",      image: "./images/img1.jpeg", isAlternate: false },
  { title: "Scratcher",     image: "./images/img2.jpeg", isAlternate: true  },
  { title: "Ember",         image: "./images/img3.jpeg", isAlternate: false },
  { title: "Liquid Soleil", image: "./images/img4.jpeg", isAlternate: true  },
  { title: "Vacuum",        image: "./images/img5.jpeg", isAlternate: false },
  { title: "Synthesis",     image: "./images/img6.jpeg", isAlternate: true  },
];
```

## Styling
**Font** — the CSS sets `html, body { font-family: "Lay Grotesk"; }`, but **no `@font-face` / web-font is loaded**, so it simply falls back to the platform default sans-serif. Use any clean grotesk (or the system default); the type look is fully defined by the `h1` rules below, not by that family name.

**Global reset / page** — `* { margin:0; padding:0; box-sizing:border-box }`. `html, body { width:100%; height:100%; overflow:hidden }`. Background is the default white; text default black. No color palette beyond that — the images supply all the color.

**Structure CSS** (load-bearing — the transforms only work with this positioning):
- `.container`: `position: fixed; width: 100%; height: 100vh;` — the fixed stage.
- `.project-list`: `position: absolute; width: 100%; list-style: none; will-change: transform;`.
- `.project`: `width: 100vw; height: 100vh; display: flex; overflow: hidden; position: absolute; left: 0; will-change: transform;` — **every slide is absolutely stacked at the top-left**; its `translateY` is the only thing that positions it vertically. `display:flex` splits it into the two halves.
- `.side`: `flex: 1; height: 100%; overflow: hidden;` — the two equal halves.
- `.img`: `width: 100%; height: 100%; overflow: hidden;` — the clipping mask around the image.
- `img`: `position: relative; width: 100%; height: 100%; object-fit: cover; will-change: transform; transform: translateY(0) scale(1.5);` — the **`scale(1.5)` zoom is the parallax headroom**; the JS overwrites this transform every frame (always re-appending `scale(1.5)`).
- `.title`: `display: flex; justify-content: space-between; align-items: center;` — pushes the name to one edge and the number to the other within its half.
- `h1`: `text-transform: uppercase; font-size: 2.5rem; font-weight: 500; letter-spacing: -0.0125rem; padding: 0.5em;` — big uppercase grotesk; the `0.5em` padding is what separates the name from the number.

## The animation engine (exhaustive — this IS the effect)

No GSAP. Everything below is one `requestAnimationFrame` loop plus event handlers mutating a shared `state` object, and a `Map`-based slide recycler.

### Helpers, config, state
```js
const lerp = (start, end, factor) => start + (end - start) * factor;

const config = {
  SCROLL_SPEED: 0.75,     // wheel deltaY multiplier
  LERP_FACTOR: 0.05,      // main list smoothing per frame
  BUFFER_SIZE: 15,        // slides kept alive on each side of the current index
  CLEANUP_THRESHOLD: 50,  // remove slides farther than this (in indices) from current
  MAX_VELOCITY: 120,      // per-wheel-event clamp
  SNAP_DURATION: 500,     // ms of the snap ease
};

const state = {
  currentY: 0, targetY: 0,      // the lerp pair driving the whole column
  lastY: 0,                     // targetY captured at touch start
  scrollVelocity: 0,
  isDragging: false, startY: 0,
  projects: new Map(),          // index -> .project element
  parallaxImages: new Map(),    // index -> per-image parallax controller
  projectHeight: window.innerHeight,
  isSnapping: false, snapStartTime: 0, snapStartY: 0, snapTargetY: 0,
  lastScrollTime: Date.now(),
  isScrolling: false,
};
```

### Slide indexing and the infinite data wrap
Slides are addressed by a signed integer `index` (…, −2, −1, 0, 1, 2, …). The data array (length 6) is wrapped with a **negative-safe modulo** so any index maps to a real project:
```js
const wrap = (i) => ((Math.abs(i) % projectData.length) + projectData.length) % projectData.length;
const getProjectData = (index) => projectData[wrap(index)];
```
The displayed **catalog number** is `(wrap(index) + 1)` padded to 2 digits → `"01"`…`"06"`, repeating every 6 slides.

### Creating a slide (`createProjectElement(index)`)
1. If `state.projects.has(index)`, bail (idempotent).
2. Clone the `.template`, set its `display:flex`, remove the `template` class.
3. Rewrite `innerHTML` with the two `.side` halves. If `data.isAlternate` → **image half first, title half second**; else → **title half first, image half second**. Title half is `<div class="side"><div class="title"><h1>${title}</h1><h1>${number}</h1></div></div>`; image half is `<div class="side"><div class="img"><img src="${image}" alt="${title}" /></div></div>`.
4. Position it: `project.style.transform = translateY(${index * state.projectHeight}px)` (its base slot before scroll offset).
5. Append to `.project-list`, store in `state.projects`, and create + store its parallax controller (below) in `state.parallaxImages`.

### Per-image parallax controller (`createParallaxImage(imgEl)`)
Closure over one `<img>`; captures bounds **once at creation** (right after the base transform is set, so `bounds.top ≈ index * projectHeight`):
```js
updateBounds() → rect = imgEl.getBoundingClientRect();
                 bounds = { top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY };
                 // window.scrollY is always 0 (page never scrolls)

update(scroll) {                             // called every frame with state.currentY
  const relativeScroll = -scroll - bounds.top;      // = -(slide's current on-screen Y)
  targetTranslateY = relativeScroll * 0.2;          // parallax ratio 0.2
  currentTranslateY = lerp(currentTranslateY, targetTranslateY, 0.1);  // secondary lerp 0.1
  if (Math.abs(currentTranslateY - targetTranslateY) > 0.01)
    imgEl.style.transform = `translateY(${currentTranslateY}px) scale(1.5)`;
}
```
Net effect: an image whose slide sits at on-screen Y = `y` targets `translateY = -0.2 * y`. A slide **above** the viewport (negative y) pushes its image **down**; a slide **below** (positive y) pulls it **up**; a slide perfectly framed (y ≈ 0) targets 0. The extra `scale(1.5)` gives the room to move without exposing edges, and the **0.2 ratio + independent 0.1 lerp** make each image drift slightly slower and laggier than the slide it lives in.

### Buffering & recycling (`checkAndCreateProjects`, run every frame)
- `currentIndex = Math.round(-state.targetY / state.projectHeight)`.
- Ensure every index in `[currentIndex − BUFFER_SIZE, currentIndex + BUFFER_SIZE]` (±15) exists → creates them as needed.
- **Cleanup:** any slide whose index is more than `CLEANUP_THRESHOLD` (50) away from `currentIndex` is `.remove()`d and deleted from both maps. This keeps the DOM bounded (~31 live slides in steady state) while the loop stays infinite.
- On load, `createInitialProjects()` seeds indices `−15 … +15`.

### Snap logic (cubic ease-out, manual)
- `getClosestSnapPoint()` → `-Math.round(-state.targetY / state.projectHeight) * state.projectHeight` (the nearest whole-slide offset).
- `initiateSnap()` → `isSnapping = true; snapStartTime = Date.now(); snapStartY = targetY; snapTargetY = getClosestSnapPoint();`.
- `updateSnap()` per frame:
  ```js
  const progress = Math.min((Date.now() - snapStartTime) / config.SNAP_DURATION, 1); // 0→1 over 500ms
  const t = 1 - Math.pow(1 - progress, 3);   // cubic ease-out
  state.targetY = snapStartY + (snapTargetY - snapStartY) * t;
  if (progress >= 1) { state.isSnapping = false; state.targetY = snapTargetY; }
  ```
  Note the snap animates **`targetY`** (not `currentY`); `currentY` then lerps toward it, so the snap is doubly softened.

### The rAF loop (`animate`) — order matters
```js
function animate() {
  const timeSinceLastScroll = Date.now() - state.lastScrollTime;

  // 1. Auto-snap once input has been idle > 100ms and we're not already snapping/dragging
  if (!state.isSnapping && !state.isDragging && timeSinceLastScroll > 100) {
    const snapPoint = getClosestSnapPoint();
    if (Math.abs(state.targetY - snapPoint) > 1) initiateSnap();
  }

  // 2. Advance the snap ease if active (writes targetY)
  if (state.isSnapping) updateSnap();

  // 3. Main lerp — ONLY when not dragging: currentY chases targetY at factor 0.05
  if (!state.isDragging) state.currentY += (state.targetY - state.currentY) * config.LERP_FACTOR;

  // 4. Grow/recycle the buffer around the current index
  checkAndCreateProjects();

  // 5. Position every live slide and update its image parallax
  state.projects.forEach((project, index) => {
    const y = index * state.projectHeight + state.currentY;
    project.style.transform = `translateY(${y}px)`;
    state.parallaxImages.get(index)?.update(state.currentY);
  });

  requestAnimationFrame(animate);
}
```
- **The main lerp factor is 0.05** — this exponential chase is the entire "smoothness" of the column; every input only ever nudges `targetY`.
- **While dragging, `currentY` is intentionally frozen** (step 3 is skipped): the visible column holds during a touch drag and then eases to catch up to `targetY` the moment the finger lifts.

### Input handlers
- **Wheel** (`window`, `{ passive: false }`): `e.preventDefault()`; cancel any snap (`isSnapping = false`); stamp `lastScrollTime = Date.now()`; then
  ```js
  const scrollDelta = e.deltaY * config.SCROLL_SPEED;                  // ×0.75
  state.targetY -= Math.max(Math.min(scrollDelta, config.MAX_VELOCITY), -config.MAX_VELOCITY); // clamp ±120 per event
  ```
  Scroll down (positive `deltaY`) decreases `targetY` → the column moves **up**. The ±120 clamp caps how far a single wheel tick can throw it.
- **Touch drag** (`window`):
  - `touchstart` → `isDragging = true; isSnapping = false; startY = e.touches[0].clientY; lastY = state.targetY; lastScrollTime = Date.now();`.
  - `touchmove` → if not dragging, return; else **absolute** delta from the start point: `deltaY = (e.touches[0].clientY - startY) * 1.5` (drag multiplier **1.5×**), `state.targetY = lastY + deltaY;` and re-stamp `lastScrollTime`.
  - `touchend` → `isDragging = false` (which re-enables the main lerp so the column catches up, then auto-snaps after 100ms idle).
- **Resize** (`window`): `state.projectHeight = window.innerHeight`; re-base every slide's transform to `translateY(index * projectHeight)` and call each image's `updateBounds()` (the next frame re-applies `currentY` and re-parallaxes).

### Boot
Wrap init in `DOMContentLoaded`: attach the listeners, `createInitialProjects()`, then start `animate()`.

## Assets / images
**6 images**, one per slide, each shown in a **half-viewport-wide × full-viewport-tall column** (so a tall portrait frame, roughly 1:2 at desktop widths) via `object-fit: cover` zoomed `scale(1.5)`, so most portrait-ish sources work. They should read as one **moody, editorial, cinematic portrait series** — dark, high-contrast, art-directed. By role/order:
1. Low-key studio portrait — sharp black bowl-cut hair, black turtleneck, a long pearl earring, soft blue-grey backdrop ("Euphoria" / 01).
2. Portrait with eyes closed inside a large sculptural sheer **orange** fabric headpiece billowing in warm light on cream ("Scratcher" / 02).
3. Figure fully draped in flowing **teal/sea-green** fabric, seated on a rocky desert outcrop under a clear blue sky ("Ember" / 03).
4. Face wrapped in horizontal bands of sheer black fabric — near-monochrome, very dark and high-contrast ("Liquid Soleil" / 04).
5. Profile silhouette, short bob + round glasses, lit entirely in saturated **red** ("Vacuum" / 05).
6. Extreme close-up of a face emerging from glossy, liquid-like **black-chrome** material with bright reflections ("Synthesis" / 06).

No logos, no brand marks — all titles ("Euphoria", "Scratcher", "Ember", "Liquid Soleil", "Vacuum", "Synthesis") are fictional project names. Because the data wraps every 6 slides, the same 6 images/titles repeat endlessly as you scroll.

## Behavior notes
- **Truly infinite both directions** — the recycler always keeps ±15 slides live around the current index and prunes beyond ±50, so the DOM stays small while scrolling never hits an end.
- **Snap-to-slide** kicks in ~100ms after the last input and eases (cubic ease-out over 500ms) so a whole slide always ends up perfectly framed; any new wheel/touch input cancels an in-progress snap.
- **Per-image parallax** is subtle and continuous (ratio 0.2, own lerp 0.1) — images drift within their frames as slides travel, always keeping the `scale(1.5)` zoom.
- The page never scrolls; only the slides' transforms move (`.container` is `position: fixed`, `body` is `overflow: hidden`). Wheel is `preventDefault`ed.
- Touch is fully supported (1.5× drag); note the deliberate quirk that the column visually freezes *during* a drag and catches up on release.
- No reduced-motion guard in the original — the rAF loop runs continuously. Everything degrades to a static first screen if JS is off (only the hidden template exists in the static HTML).
