# Arc Coverflow Image Slider — infinite cosine-arc carousel

## Goal
Build a full-viewport **infinite horizontal image slider** whose 9 slides are laid out along a
**cosine arc** (a coverflow / carousel curve). The slide nearest the horizontal center is the
largest and is **lifted up**; slides to either side **shrink** and **sink down along the arc** the
farther they sit from center. Mouse wheel and touch-drag feed a scroll target that is
**lerp-smoothed every animation frame**; the slides recycle with a modular wrap so the row loops
**forever in both directions**. A single caption pinned near the bottom always shows the title of
the slide currently closest to center. The star of the piece is the per-frame arc-layout engine —
there is no GSAP timeline, no ScrollTrigger, just `gsap.set` called on every `requestAnimationFrame`.

## Tech
Vanilla HTML / CSS / JS with an ES module entry (`<script type="module" src="./script.js">`),
bundled by Vite. **`gsap` (npm)** is the only dependency — imported as `import gsap from "gsap"`.
**No GSAP plugins** (no ScrollTrigger, SplitText, CustomEase), **no Lenis, no Three.js.** GSAP is
used purely as a fast writer of inline transforms via `gsap.set`. All motion is a hand-written
`requestAnimationFrame` loop with manual linear interpolation. Everything must run in a fresh Vite
project with only `gsap` installed.

## Layout / HTML
Flat, static markup — all 9 slides exist in the HTML up front (they are **not** generated in JS),
plus one caption paragraph.

```html
<section class="slider">
  <div class="slide"><img src="/path/img1.jpg" alt="Profile Study" /></div>
  <div class="slide"><img src="/path/img2.jpg" alt="Pump Noir" /></div>
  <div class="slide"><img src="/path/img3.jpg" alt="Compact Disc" /></div>
  <div class="slide"><img src="/path/img4.jpg" alt="Iris Frame" /></div>
  <div class="slide"><img src="/path/img5.jpg" alt="Open Compact" /></div>
  <div class="slide"><img src="/path/img6.jpg" alt="Shelf Set" /></div>
  <div class="slide"><img src="/path/img7.jpg" alt="Hand Held" /></div>
  <div class="slide"><img src="/path/img8.jpg" alt="Clear Stack" /></div>
  <div class="slide"><img src="/path/img9.jpg" alt="Foam Pump" /></div>
  <p id="slide-title">Profile Study</p>
</section>
```

- `.slider` is the full-screen stage and the interaction surface (wheel + touch listeners attach
  here). It clips everything with `overflow: hidden`.
- Each `.slide` wraps exactly one `<img>`. There are exactly **9** slides.
- `#slide-title` is the live caption; it starts with the first slide's title (`"Profile Study"`).

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

- **Font:** `body { font-family: "PP Neue Montreal", sans-serif; }` — import PP Neue Montreal from
  `https://fonts.cdnfonts.com/css/pp-neue-montreal` (or any clean neutral grotesque sans as
  fallback). The caption is **weight 500**.
- **`img`** — `width: 100%; height: 100%; object-fit: cover;` (fills its `.slide` box; the JS sizes
  the box, `cover` handles the crop).
- **`.slider`** — `position: relative; width: 100%; height: 100svh; overflow: hidden;` No background
  color is set (default white/transparent shows behind the slides).
- **`.slide`** — `position: absolute; will-change: transform, width, height;` No `top`/`left` are
  set (they default to `0`), so the element's origin is the top-left of `.slider`; the JS positions
  it entirely through GSAP's `x`/`y` (CSS transforms) plus inline `width`/`height`.
- **`p#slide-title`** — `position: absolute; bottom: 25svh; left: 50%; transform: translateX(-50%);
  font-weight: 500;` — a single centered caption line floating in the lower quarter of the viewport.

## The effect — exhaustive spec (per-frame cosine-arc engine, `gsap.set` only)

### Config constants (use these exact values)
```js
const SLIDE_WIDTH  = 200;   // base slide box width in px (at center scale 1)
const SLIDE_HEIGHT = 275;   // base slide box height in px (≈ portrait 3:4, exactly 200:275)
const SLIDE_GAP    = 100;   // horizontal spacing between consecutive slide anchors, in "track" px
const SLIDE_COUNT  = 9;     // number of slides / images
const ARC_DEPTH    = 200;   // max downward drop (px) of a side slide along the arc
const CENTER_LIFT  = 100;   // max upward lift (px) applied to the centered slide
const SCROLL_LERP  = 0.05;  // per-frame smoothing factor toward the scroll target
```

### Slide titles (caption data, in slide order)
```js
const slideTitles = [
  "Profile Study", "Pump Noir", "Compact Disc", "Iris Frame", "Open Compact",
  "Shelf Set", "Hand Held", "Clear Stack", "Foam Pump",
];
```

### Derived quantities (computed once on load, recomputed on resize)
```js
const trackWidth  = SLIDE_COUNT * SLIDE_GAP;   // = 900  — the wrap period along the virtual track
let   windowWidth  = window.innerWidth;
let   windowHeight = window.innerHeight;
let   windowCenterX = windowWidth / 2;
let   arcBaselineY  = windowHeight * 0.4;       // vertical anchor of the arc (40% down the viewport)
```

### The core layout function — `computeSlideTransform(slideIndex, scrollOffset)`
Called for every slide, every frame. Returns the slide's pixel geometry, z-index, and its absolute
distance from center. Reproduce this math **exactly**:

```js
function computeSlideTransform(slideIndex, scrollOffset) {
  // 1. Virtual X of this slide on the track, then wrap it into [0, trackWidth):
  let wrappedOffsetX =
    (((slideIndex * SLIDE_GAP - scrollOffset) % trackWidth) + trackWidth) % trackWidth;
  // 2. Fold the far half back to the negative side so offsets live in [-450, 450):
  if (wrappedOffsetX > trackWidth / 2) wrappedOffsetX -= trackWidth;

  const slideCenterX   = windowCenterX + wrappedOffsetX;                 // px, screen space
  const normalizedDist = (slideCenterX - windowCenterX) / (windowWidth * 0.5); // = offset / halfWidth
  const absDist        = Math.min(Math.abs(normalizedDist), 1.3);        // clamp at 1.3

  // 3. Scale: linear falloff, floored at 0.25
  const scaleFactor  = Math.max(1 - absDist * 0.8, 0.25);
  const scaledWidth  = SLIDE_WIDTH  * scaleFactor;
  const scaledHeight = SLIDE_HEIGHT * scaleFactor;

  // 4. Arc drop: raised-cosine easing, 0 at center → ARC_DEPTH (200) at absDist ≥ 1
  const clampedDist = Math.min(absDist, 1);
  const arcDropY    = (1 - Math.cos(clampedDist * Math.PI)) * 0.5 * ARC_DEPTH;

  // 5. Center lift: linear, CENTER_LIFT (100) at center → 0 at absDist ≥ 0.5
  const centerLiftY = Math.max(1 - absDist * 2, 0) * CENTER_LIFT;

  return {
    x: slideCenterX - scaledWidth / 2,                            // left edge (box is centered on slideCenterX)
    y: arcBaselineY - scaledHeight / 2 + arcDropY - centerLiftY,  // top edge on the arc
    width: scaledWidth,
    height: scaledHeight,
    zIndex: Math.round((1 - absDist) * 100),                      // center 100, decreasing outward (can go negative)
    distanceFromCenter: Math.abs(wrappedOffsetX),                 // used to pick the active caption
  };
}
```

**What this produces (verify against these reference values at `scrollOffset = 0`, 1920×1080):**
the 9 slides sit symmetrically around center with wrapped offsets `0, +100, +200, +300, +400,
-400, -300, -200, -100` px. Center slide: `scale 1`, box `200×275`, `drop 0`, `lift 100`,
`z 100`. Its neighbours: `scale ~0.917`, `drop ~5`, `lift ~79`, `z 90`. Two steps out:
`scale ~0.833`, `drop ~21`, `lift ~58`. Outermost visible pair: `scale ~0.667`, `drop ~74`,
`lift ~17`, `z 58`. So the row curves: **biggest and highest in the middle, shrinking and dipping
toward both edges** — a coverflow arc.

Key nuances to preserve:
- The scale falloff is **linear** (`1 − 0.8·absDist`) with a hard **floor of 0.25**; a slide
  reaches minimum size at `absDist ≈ 0.9375`.
- The vertical arc is a **raised cosine** (`(1 − cos(d·π))/2`), so the drop eases in/out smoothly,
  reaching its full `200 px` at `absDist ≥ 1`.
- The lift is a **narrow linear spike** at the very center: full `100 px` at dead center, gone by
  `absDist = 0.5`. Combined `y = arcBaselineY − scaledHeight/2 + arcDropY − centerLiftY`.
- `absDist` is clamped to `1.3` (so extreme off-center slides don't compute past the arc), and
  z-index is `round((1−absDist)·100)` — the center slide always stacks on top, side slides can even
  go slightly negative.

### Applying the layout — `layoutSlides(scrollOffset)`
```js
function layoutSlides(scrollOffset) {
  slideElements.forEach((slideEl, i) => {
    const { x, y, width, height, zIndex } = computeSlideTransform(i, scrollOffset);
    gsap.set(slideEl, { x, y, width, height, zIndex });
  });
}
```
`gsap.set` writes `x`/`y` as a CSS `transform: translate(...)` and `width`/`height`/`zIndex` inline
— no tween, instantaneous per frame. Call `layoutSlides(0)` once immediately after defining it, so
the arc is correct on first paint before the rAF loop starts.

### Input → scroll target (wheel + touch)
Two module-level accumulators drive everything:
```js
let scrollTarget  = 0;  // raw target, mutated by input
let scrollCurrent = 0;  // smoothed value the layout actually uses
```
Handlers, all attached to `.slider`:
1. **wheel** (`{ passive: false }`, calls `e.preventDefault()`):
   `scrollTarget += e.deltaY * 0.5;`
2. **touchstart**: `touchStartX = e.touches[0].clientX;`
   **touchmove** (`{ passive: false }`, `e.preventDefault()`):
   `scrollTarget += (touchStartX - e.touches[0].clientX) * 1.2; touchStartX = e.touches[0].clientX;`
   (drag-to-scroll: dragging left advances the row, with a 1.2× gain).

There is **no momentum/inertia** beyond the lerp — input mutates `scrollTarget` instantly and
`scrollCurrent` chases it.

### Active-caption sync — `syncActiveTitle(scrollOffset)`
Every frame, find the slide with the smallest `distanceFromCenter` and, **only when the index
changes**, set `titleDisplay.textContent = slideTitles[closestIndex]`. Track the last active index
(start it at `-1`) to avoid redundant DOM writes.

```js
let activeSlideIndex = -1;
function syncActiveTitle(scrollOffset) {
  let closestIndex = 0, closestDist = Infinity;
  slideElements.forEach((_, i) => {
    const { distanceFromCenter } = computeSlideTransform(i, scrollOffset);
    if (distanceFromCenter < closestDist) { closestDist = distanceFromCenter; closestIndex = i; }
  });
  if (closestIndex !== activeSlideIndex) {
    activeSlideIndex = closestIndex;
    titleDisplay.textContent = slideTitles[closestIndex];
  }
}
```

### The render loop (the whole engine)
```js
function animate() {
  scrollCurrent += (scrollTarget - scrollCurrent) * SCROLL_LERP;  // single lerp toward target (0.05/frame)
  layoutSlides(scrollCurrent);
  syncActiveTitle(scrollCurrent);
  requestAnimationFrame(animate);
}
animate();
```
That lerp (factor `0.05`) is the only "easing" in the piece: drags and wheel flicks decelerate
softly as `scrollCurrent` glides toward `scrollTarget`. Because the wrap in
`computeSlideTransform` is modular over `trackWidth`, the row is **truly infinite** — there is no
first or last slide, no snapping, no bounds.

### Resize
On `window.resize`, recompute `windowWidth`, `windowHeight`, `windowCenterX`, and
`arcBaselineY = windowHeight * 0.4`. (The next frame re-lays everything with the new center.)

## Assets / images
- **9 slide slots filled from 8 full-bleed portrait photographs** (`img1`–`img8`, each a tall
  portrait roughly **4:5**; the slide box itself is 200:275 ≈ 0.727 and `object-fit: cover` crops any
  real ratio). There are **8 distinct files** for 9 slots, so the 9th slot **reuses** an earlier
  image (the first, `img1`).
- Aesthetic: a mostly **editorial cosmetics / beauty still-life** set — glossy product shots, chrome
  mirror compacts and a macro eye, shot on studio backdrops that swing from soft warm nude to pure
  black; one **footwear** frame breaks the theme. Real per-image subjects (generic, by role and form,
  **no brand marks or baked-in logos/text**):
  1. **Metal tin, lid on** — a closed round brushed **silver / aluminium** salve tin with a ribbed
     screw rim, standing on a smooth mid-**grey** studio surface, soft even lighting.
  2. **Suede boot, in wear** — a mid-calf **tan / beige** suede western boot with tonal stitching and
     a dark stacked wooden heel, mid-stride on a pale **off-white / grey** seamless (the one non-
     cosmetics frame).
  3. **Eye close-up** — extreme macro of an open eye with full brow, thick lashes and a sharp winged
     eyeliner flick; warm **sepia / tan-brown** monochrome, glossy skin.
  4. **Chrome compact, open, on black** — an open round polished **silver** double mirror compact,
     lid raised, high specular highlights against a pure **black** background.
  5. **Dark glamour still life** — an upright **red** lipstick with a polished **gold** barrel beside
     a glossy **black** cream jar, on a mottled cool **grey** studio backdrop.
  6. **Soft-neutral product trio** — a frosted **white / blush** cream jar, a **rose-pink** lipstick
     in a rose-gold case and a frosted pump bottle, arranged on a warm **beige / nude** set.
  7. **Bottle on stone** — a glossy **cream / off-white** dropper-style bottle with a **black** cap,
     perched on a rough stone slab among dried **beige** gypsophila sprigs, warm nude backdrop.
  8. **Powder compact, open, on black** — an open **silver / chrome** compact showing a warm
     **peach / nude** pressed powder pan and its mirror, glossy reflections on a **black** ground.
- The HTML still lists 9 `<img>`/caption pairs (`Profile Study, Pump Noir, Compact Disc, Iris Frame,
  Open Compact, Shelf Set, Hand Held, Clear Stack, Foam Pump`); the captions are generic labels and
  do not map literally to the subjects above. Supplying the 8 tall stills (and reusing `img1` for the
  9th slot) reproduces the current set.

## Behavior notes
- **Desktop + touch.** Wheel scroll on desktop, touch-drag on mobile — both feed the same
  `scrollTarget`. `e.preventDefault()` on wheel and touchmove suppresses native page scroll so the
  slider owns the gesture.
- **Infinite in both directions**, no start/end, no snap-to-slide. The row idles perfectly still
  (it does not autoplay) until the user interacts, then glides via the lerp.
- The caption updates **discretely** — it swaps only when a different slide becomes the closest to
  center, not continuously.
- No explicit reduced-motion branch in the original; motion is entirely user-driven, so it stays
  static when untouched.
- Keep the loop lean: it recomputes all 9 transforms twice per frame (once for layout, once for the
  caption search) with no per-frame allocations of note; `gsap.set` is the only DOM write path.
