# Infinite Perspective Slider — "Perpetual Motion"

## Goal
Build a full-viewport, **infinitely draggable horizontal image slider** where slides are anchored to the bottom edge and **grow exponentially wider from left to right**, producing a receding-perspective "wall of images" that never ends in either direction. Dragging, wheeling or swiping feeds a scroll target that is **lerp-smoothed every animation frame**; the images recycle modulo 10 so the stream loops forever. The star effect is the exponential width ramp + seamless infinite wrap driven entirely by a hand-written `requestAnimationFrame` loop.

## Tech
Vanilla HTML/CSS/JS with an ES module entry (`<script type="module">`). **No animation library is used — no GSAP, no Lenis.** All motion is a custom `requestAnimationFrame` render loop with manual linear interpolation (lerp). Everything must run in a fresh Vite project with zero npm dependencies.

## Layout / HTML
Minimal, semantic; the JS generates all slides at runtime.

```html
<section class="slider">
  <div class="slider-header">
    <h1>Perpetual Motion</h1>
  </div>
</section>
```

- `.slider` is the full-screen stage and the interaction surface (wheel / touch / pointer listeners attach here).
- `.slider-header` holds a single `<h1>` pinned top-left.
- The JS creates N `<div class="slide">` children, each containing one `<img>`, and appends them into `.slider`. There is **no** markup for slides in the HTML — they are all built in script.

## Styling
Paleta / type:
- `body` background: `#edede7` (warm off-white / bone).
- Font family: **"PP Neue Montreal"** (import from `https://fonts.cdnfonts.com/css/pp-neue-montreal`). If unavailable, fall back to a clean grotesk sans-serif.
- Global reset: `* { margin:0; padding:0; box-sizing:border-box; user-select:none; }`.

`.slider`:
- `position:relative; width:100%; height:100svh; overflow:hidden;`
- `touch-action:none;` (so wheel/touch are fully custom)
- `cursor:grab;` and `.slider:active { cursor:grabbing; }`

`.slide`:
- `position:absolute; left:0; bottom:0; overflow:hidden;`
- `will-change: transform, width, height;`
- Its `width`, `height`, `zIndex` and `transform` are all set inline by the render loop every frame. Slides are **bottom-anchored** (they grow upward as they get wider).

`.slide img`:
- `width:100%; height:100%; object-fit:cover; display:block; pointer-events:none;`

`.slider-header`:
- `position:absolute; top:3rem; left:3rem;`
- `<h1>`: `width:50%; font-weight:500; font-size:clamp(3rem, 5vw, 7rem); letter-spacing:-0.02em; line-height:1;`

## The effect — exhaustive spec (rAF engine, no GSAP)

### Config constants (use these exact values)
```js
const config = {
  totalSlides: 10,   // number of UNIQUE images (mod for looping)
  lerp: 0.075,       // smoothing factor per frame
  scrollSpeed: 3.5,  // global input multiplier
  minSize: 0.1,      // smallest slide width = 10% of slider width
  growth: 0.25,      // exponential growth exponent per slide step
  aspect: 1 / 1.25,  // slide width/height ratio = 0.8 (portrait, taller than wide)
  baseline: 0.0,     // vertical offset fraction (0 = flush to bottom)
};
```

### Derived quantities
- `growthRatio = Math.exp(config.growth)` ≈ **1.2840** — each slide to the right is ~1.284× wider than its left neighbour.
- Number of DOM slides to create:
  ```js
  const slideCount =
    Math.ceil(Math.log(1 + (growthRatio - 1) / config.minSize) / config.growth) + 4;
  ```
  With these constants this evaluates to **10**. Create exactly this many `.slide` elements once, up front, and reuse them forever (object pool). Push each into a `slides[]` array and initialise a parallel `slideStreamIndex[]` array with `[0,1,2,…,slideCount-1]`.

### Core helper functions
```js
const lerp = (start, end, t) => start + (end - start) * t;
const wrap = (value, max) => ((value % max) + max) % max; // always-positive modulo
// Left edge (in px) of the slide occupying continuous stream position `position`:
const edgeX = (position, width) =>
  (width * config.minSize * (Math.pow(growthRatio, position) - 1)) /
  (growthRatio - 1);
```
`edgeX` is the closed-form sum of a geometric series: the cumulative width of every slide before `position`. Consequence: the rendered width of a slide spanning `[p, p+1)` is
`edgeX(p+1) - edgeX(p) = sliderWidth * minSize * growthRatio^p`
— i.e. slide widths follow a **geometric progression**: tiny on the left (≈10% of viewport), exponentially larger toward the right. This is the perspective illusion.

### State
```js
let scroll = 0;        // smoothed scroll position (what render uses)
let scrollTarget = 0;  // raw target updated by input events
```

### Input handlers (all attached to `.slider`)
1. **wheel** (`{ passive:false }`, calls `e.preventDefault()`):
   `scrollTarget += (e.deltaY + e.deltaX) * config.scrollSpeed * 0.0014;`
2. **touchstart**: store `lastTouchX = e.touches[0].clientX`.
   **touchmove** (`{ passive:true }`): if a touch is active,
   `scrollTarget += (lastTouchX - x) * config.scrollSpeed * 0.004;` then `lastTouchX = x`.
   **touchend**: `lastTouchX = null`.
3. **pointerdown**: `lastPointerX = e.clientX; slider.setPointerCapture(e.pointerId);`
   **pointermove**: if dragging,
   `scrollTarget += (lastPointerX - e.clientX) * config.scrollSpeed * -0.005;` then `lastPointerX = e.clientX`. (Note the **negative** 0.005 multiplier for pointer drag — sign is intentional.)
   **pointerup** / **pointercancel**: `lastPointerX = null`.

There is **no inertia/momentum** other than the lerp: input mutates `scrollTarget` instantly; `scroll` chases it.

### Render loop (runs every frame via `requestAnimationFrame`)
Call `render()` once to start; it re-schedules itself at the end.

Each frame:
1. `scroll += (scrollTarget - scroll) * config.lerp;` — the single global smoothing step (factor 0.075). This is what makes drags decelerate softly.
2. Read `sliderWidth = slider.clientWidth`, `sliderHeight = slider.clientHeight`, `baselineOffset = sliderHeight * config.baseline` (= 0 here).
3. For each slide `i` in `0…slideCount-1`:
   - Take `streamIndex = slideStreamIndex[i]`.
   - **Recycle rightward** (slide fell off the right): `while (edgeX(streamIndex + scroll, sliderWidth) > sliderWidth) streamIndex -= slideCount;`
   - **Recycle leftward** (slide fell off the left): `while (edgeX(streamIndex + scroll + 1, sliderWidth) < 0) streamIndex += slideCount;`
   - Persist it back: `slideStreamIndex[i] = streamIndex;`
   - Compute pixel geometry:
     ```js
     const left  = Math.round(edgeX(streamIndex + scroll,     sliderWidth));
     const right = Math.round(edgeX(streamIndex + scroll + 1, sliderWidth));
     const width = right - left;
     const height = width / config.aspect;   // = width * 1.25 (taller than wide)
     ```
   - **Assign image**: image number = `wrap(streamIndex, config.totalSlides) + 1` → an integer in `1…10`. Only swap the `<img>.src` when it actually changes (cache the current number on a `data-image` attribute to avoid redundant DOM writes).
   - Apply inline styles:
     ```js
     slide.style.width  = `${width}px`;
     slide.style.height = `${height}px`;
     slide.style.zIndex = Math.round(right);            // wider/rightmost slides stack on top
     slide.style.transform = `translate(${left}px, ${-baselineOffset}px)`;
     ```
4. `requestAnimationFrame(render);`

### Behavioural summary of the motion
- Positive scroll direction slides the whole exponential stream; slides continuously widen as they migrate right and shrink as they migrate left, then wrap seamlessly at both edges thanks to the two `while` recycle loops + the object pool.
- Because `zIndex = round(right)`, the largest/rightmost slide always overlaps its smaller left neighbour, reinforcing the depth.
- Images repeat every 10 stream steps (`wrap(streamIndex, 10)`), so a 10-image set produces an endless loop.
- Bottom-anchoring (`bottom:0` + `translateX` only) means slides visually "grow up" out of the bottom edge as they enlarge.

## Assets / images
- **10 full-bleed editorial photographs**, all portrait crop (~4:5, taller than wide). The code multiplies width by `1/aspect = 1.25`, so the intended aspect ratio is **width:height = 0.8** — supply tall images; `object-fit:cover` handles any real ratio.
- Referenced by a numbered filename pattern `slide-img-1.jpg … slide-img-10.jpg` (image number 1–10). Every slide plays the same **role**: a full-bleed background photo inside a bottom-anchored, exponentially-scaled frame; none is a logo, icon or UI chrome. **No brand marks, no text overlays.**
- The real set is an **architectural-interior + still-life** collection with a design-catalog / quiet-luxury mood, split between soft earthy neutrals and a few high-saturation jewel-tone accents. Concrete examples from the actual files:
  - Sculptural minimalist interiors: a curved cream-plaster alcove with a boucle lounge chair, potted plant and warm amber cove uplight (bone/cream + amber glow); one or more board-formed concrete great-rooms with a floating cantilevered stair, raw-oak plank dining table with woven wishbone chairs, a jute rug and an olive-green leather sofa (grey concrete + warm oak + green).
  - A bright white gallery space with a massive raw-stone monolith table, framed off-white prints on the walls, glass decanters and a large suspended brass ring (off-white + sandy stone-beige + brass).
  - A warm terracotta / peach-plaster arched courtyard with a potted olive tree, a round sunken plunge pool and magenta bougainvillea (terracotta-pink + muted green + magenta).
  - Saturated floral still lifes: a close-up of violet-purple bearded irises with golden-yellow beards against a deep crimson-red backdrop; a bouquet of purple irises in a clear glass vase on a dark green marble table against a rust-red wall (violet/purple + deep red + green + yellow).
  - A clear round glass water carafe casting scattered rainbow prism refractions across a soft grey paper backdrop (cool grey + spectral rainbow highlights).
- Dominant colours across the set: **warm bone/cream and stone beige, grey concrete, terracotta/peach, oak brown and olive green, brass — punctuated by high-chroma violet-purple, deep crimson-red and rainbow-prism accents.** Keep any replacements within this mix of soft sunlit neutrals plus occasional saturated jewel-tone stills.
- Reuse/repeat images if fewer than 10 are available; the modulo wrap tolerates it.

## Behavior notes
- **Desktop + touch**: wheel and pointer drag on desktop, touch swipe on mobile — all three feed the same `scrollTarget`.
- `touch-action:none` and `preventDefault` on wheel disable native page scroll so the slider owns the gesture.
- Infinite in **both** directions; no start or end; no snapping.
- The only "animation" is the per-frame lerp toward `scrollTarget` — there is no timeline, no easing curve object, no library. Keep the render loop lean (avoid per-frame allocations; only touch `img.src` on change).
- No explicit reduced-motion branch in the original; motion is user-driven (it does not auto-play), so it idles perfectly still until the user interacts.
