# Photo Dump Scroll Scatter — Pinned Ring of Cards that Fly In and Out

## Goal
Build a full-screen, pinned "photo dump" gallery. A dark section pins in place for **six viewport heights of scroll**, split into **four segments**. In each segment, **15 rotated photo cards** are scattered in a loose ring around a **centered serif heading**. Every time you cross a segment boundary, the whole set re-shuffles: the current 15 cards **fly out to their nearest screen edge** (accelerating away) while 15 fresh cards from the **next image set fly in from the edges** (decelerating into place) with a half-second overlap, and the heading **cross-fades** to a new phrase. The star effect is this choreographed scatter-out / scatter-in card swap driven by scroll position while the section stays pinned.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin **`ScrollTrigger`**, and `lenis` (npm) for smooth scroll. No other plugins, no framework:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
```
Register once: `gsap.registerPlugin(ScrollTrigger);`. Run everything inside `document.addEventListener("DOMContentLoaded", …)`. Must run in a fresh Vite + npm project. Ship one `index.html` (`<link rel="stylesheet" href="./styles.css">` and `<script type="module" src="./script.js">`), one `styles.css`, one ES-module `script.js`.

## Layout / HTML
Three stacked full-screen `<section>`s. Class names are load-bearing — the JS/CSS query them. The cards are **not** in the markup; the JS creates and injects all of them into `.gallery`.

```html
<section class="intro">
  <h1>Time loosens its grip and the stack begins to shift</h1>
</section>

<section class="gallery">
  <h1></h1>
</section>

<section class="outro">
  <h1>Eventually, the stack settles and the scroll continues</h1>
</section>
```

- `.intro` and `.outro` are simple dark panels with a centered heading each — they exist only to give scroll runway before and after the pinned gallery.
- `.gallery > h1` starts **empty**; the JS fills its text from the headings array and animates it.
- All `.card` elements are generated at runtime and appended into `.gallery` (see GSAP section).

## Styling
Font (Google Fonts): **Instrument Serif** (import italic axis too):
```css
@import url("https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap");
```

Palette (CSS variables on `:root`):
- `--base-100: #fff` — heading text color (white).
- `--base-200: #4a4a4a` — the card's border color (mid-gray frame).
- `--base-300: #141414` — the `.gallery` background (near-black).
- `--base-400: #0f0f0f` — the `.intro` / `.outro` background (slightly darker near-black).

Global / reset:
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `body { font-family:"Instrument Serif", sans-serif; }`
- `h1 { font-size: clamp(3rem, 5vw, 7vw); font-weight:500; line-height:0.9; letter-spacing:-0.025rem; }`

Sections:
- `section { position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; color:var(--base-100); overflow:hidden; }`
- `section h1 { width:45%; text-align:center; will-change:opacity; z-index:2; }` — the heading is constrained to 45% width, centered, and sits **above** the cards (cards have no z-index, so `z-index:2` keeps the heading on top).
- `.intro, .outro { background-color:var(--base-400); }`
- `.gallery { background-color:var(--base-300); }`

Card (critical — this is the exact box the JS positions):
```css
.card {
  position: absolute;
  width: 250px;
  height: 300px;
  border-radius: 1rem;
  border: 0.5rem solid var(--base-200);
  box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.25);
  will-change: transform;
  overflow: hidden;
}
.card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 0.5rem;
}
```
Cards are `position:absolute` inside `.gallery` and are placed entirely with inline `left`/`top`/`rotation` written by GSAP — never by CSS coordinates. Each is a fixed **250×300** portrait card (roughly 5:6) with a thick gray frame and rounded corners.

Responsive:
- `@media (max-width:1000px) { section h1 { width:100%; padding:2rem; } }` — heading relaxes to full width on narrow screens.

## GSAP effect (be exhaustive)

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

### Config constants
```js
const CONFIG = {
  cardCount: 15,
  cardWidth: 250,
  cardHeight: 300,
  animationDuration: 0.75,
  animationOverlap: 0.5,
  headingFadeDuration: 0.5,
  headings: [
    "Order is temporary while you're passing through",
    "Memories shuffle like cards in an endless deck",
    "Each moment scatters as another takes its place",
    "The fragments float before settling once more",
  ],
};
```

### Viewport + state
```js
let viewport = {
  centerX: window.innerWidth / 2,
  centerY: window.innerHeight / 2,
  rangeMin: Math.min(window.innerWidth, window.innerHeight) * 0.35,
  rangeMax: Math.min(window.innerWidth, window.innerHeight) * 0.7,
};
let state = { activeCards: [], currentSection: 0, isAnimating: false };
```
`updateViewport()` recomputes these four values from the current window size. `rangeMin`/`rangeMax` are the inner and outer radii of the scatter ring (35% and 70% of the smaller viewport dimension).

### Ring placement — `createCards(setNumber)`
Creates and returns an array of **15** card objects. For each `i` (0–14):
1. Make a `div.card`, give it a child `<img>` whose `src` is `/c/poly-app-photo-dump/set${setNumber}/img${i + 1}.jpg` (image sets numbered 1–4; each set has 15 images).
2. Pick a random polar position around the viewport center:
   ```js
   const angle  = Math.random() * Math.PI * 2;               // 0..2π
   const radius = viewport.rangeMin + Math.random() * (viewport.rangeMax - viewport.rangeMin);
   const centerX = viewport.centerX + Math.cos(angle) * radius;
   const centerY = viewport.centerY + Math.sin(angle) * radius;
   ```
3. Place the card so its **center** lands on `(centerX, centerY)`, with a random tilt:
   ```js
   gsap.set(card, {
     left: centerX - CONFIG.cardWidth / 2,
     top:  centerY - CONFIG.cardHeight / 2,
     rotation: Math.random() * 50 - 25,     // -25°..+25°
   });
   ```
4. Append to `.gallery`, and push `{ element: card, centerX, centerY }` (remember each card's home ring point for later).

### Nearest-edge target — `getEdgePosition(centerX, centerY)`
Given a card's home center, compute an **off-screen** `{x, y}` (used directly as `left`/`top`) on whichever screen edge is closest to that point:
```js
const distances = {
  left:   centerX,
  right:  window.innerWidth  - centerX,
  top:    centerY,
  bottom: window.innerHeight - centerY,
};
const minDistance = Math.min(...Object.values(distances));
const offsetVariation = () => (Math.random() - 0.5) * 400;   // -200..+200 jitter
```
- Nearest = **left** → `x: -300 - Math.random() * 200` (i.e. -300..-500), `y: centerY - cardHeight/2 + offsetVariation()`.
- Nearest = **right** → `x: window.innerWidth + 50 + Math.random() * 200`, `y: centerY - cardHeight/2 + offsetVariation()`.
- Nearest = **top** → `x: centerX - cardWidth/2 + offsetVariation()`, `y: -400 - Math.random() * 200`.
- Nearest = **bottom** (fallthrough) → `x: centerX - cardWidth/2 + offsetVariation()`, `y: window.innerHeight + 50 + Math.random() * 200`.
So a card exits toward, and enters from, the edge it is nearest to, with vertical/horizontal jitter so the flock doesn't move in lockstep.

### The card swap — `animateCards(exitingCards, enteringCards)` (the star effect)
Returns a single `gsap.timeline()` built as follows:

**Exiting cards** — for each currently-visible card, tween it OUT to its nearest edge, added at position **`0`**:
```js
tl.to(element, {
  left: targetEdge.x,                 // getEdgePosition(centerX, centerY)
  top:  targetEdge.y,
  rotation: Math.random() * 180 - 90, // -90°..+90° tumble
  duration: 0.75,                     // CONFIG.animationDuration
  ease: "power2.in",                  // accelerate away
  onComplete: () => element.remove(), // detach from DOM when off-screen
}, 0);
```

**Entering cards** — for each of the 15 new cards, first snap it to an edge (its `getEdgePosition` from its own home center), then tween it IN to its home ring position, added at position **`0.5`** (`CONFIG.animationOverlap`):
```js
gsap.set(element, {                    // start off-screen at the edge
  left: targetEdge.x,
  top:  targetEdge.y,
  rotation: Math.random() * 180 - 90,
});
tl.to(element, {
  left: centerX - CONFIG.cardWidth / 2,   // land centered on its ring point
  top:  centerY - CONFIG.cardHeight / 2,
  rotation: Math.random() * 50 - 25,      // settle to -25°..+25°
  duration: 0.75,                         // CONFIG.animationDuration
  ease: "power2.out",                     // decelerate into place
}, 0.5);                                   // begins halfway through the exit
```
Net timing: exit tweens run t=0→0.75; enter tweens run t=0.5→1.25, so the new cards start streaming in while the old ones are still leaving — a continuous cross-flow. Note the cards animate **`left`/`top`** (layout position), plus `rotation` (transform).

### Heading cross-fade — `animateHeading(newText)`
Returns a timeline that fades the gallery heading out, swaps its text, and fades it back:
```js
gsap.timeline()
  .to(galleryHeading, { opacity: 0, duration: 0.5, ease: "power2.inOut" })
  .call(() => { galleryHeading.textContent = newText; })
  .to(galleryHeading, { opacity: 1, duration: 0.5, ease: "power2.inOut" });
```
Total ~1.0s, running in parallel with the card swap.

### Segment mapping — `getSectionIndex(progress)`
```js
if (progress < 0.25) return 0;
if (progress < 0.5)  return 1;
if (progress < 0.75) return 2;
return 3;
```
Four equal quarters of the pinned scroll → sections 0,1,2,3 → image sets 1,2,3,4.

### The pinned ScrollTrigger
```js
ScrollTrigger.create({
  trigger: ".gallery",
  start: "top top",
  end: () => `+=${window.innerHeight * 6}`,   // pin for 6 viewport heights
  pin: true,
  pinSpacing: true,
  onUpdate: ({ progress }) => {
    if (state.isAnimating) return;                       // ignore updates mid-swap
    const targetSection = getSectionIndex(progress);
    if (targetSection === state.currentSection) return;  // only fire on a boundary crossing

    state.isAnimating = true;
    const newCards = createCards(targetSection + 1);      // build the next 15 cards (already at their ring spots)

    Promise.all([
      animateCards(state.activeCards, newCards).then(),   // swap cards
      animateHeading(CONFIG.headings[targetSection]).then(), // cross-fade heading
    ]).then(() => {
      state.activeCards = newCards;
      state.currentSection = targetSection;
      state.isAnimating = false;                          // unlock for the next boundary
    });
  },
});
```
Key behaviors: the section **pins in place** while you scroll six viewport heights; the swap is **not scrubbed** — crossing a quarter boundary triggers a self-contained ~1.25s animation once, and the `isAnimating` lock prevents overlapping swaps (fast scrolls just queue the next boundary until the current swap finishes). Because `createCards` injects the new set at its ring positions and `animateCards` immediately `gsap.set`s them to the edges, the incoming cards are only ever seen flying in from the edges.

### Initial paint
```js
state.activeCards = createCards(1);                 // set 1 scattered around center
galleryHeading.textContent = CONFIG.headings[0];    // first phrase
gsap.set(galleryHeading, { opacity: 1 });
```

### Resize handling — `reinitialize()`
```js
window.addEventListener("resize", () => {
  state.activeCards.forEach(({ element }) => element.remove());
  updateViewport();
  state.activeCards = createCards(state.currentSection + 1);  // rebuild current set at new ring
  ScrollTrigger.refresh();
});
```

## Assets / images
**60 photo cards total = 4 sets × 15 images.** Source images are **landscape-oriented photographs (≈4:3, wider than tall)**, but each is displayed in a fixed **250×300 portrait card with `object-fit: cover`** — so the middle vertical slice of each landscape shot is what actually shows; exact crop doesn't matter. File layout the JS expects: `set1/img1.jpg … set1/img15.jpg`, `set2/img1.jpg … set2/img15.jpg`, etc. No brands, logos, or text anywhere.

The photos are **general scenic / atmospheric stock-style imagery** rather than one tight subject per set — a loose mix of landscapes, cityscapes, and interiors that reads as a warm, cinematic "photo dump". Suggested roles by set:

- **Set 1 (segment 1)** — 15 scenic photographs (any cohesive-feeling grab bag).
- **Set 2 (segment 2)** — 15 mixed-subject atmospheric photos, loosely themed around **sky / flight and neon night cities**. Verified contents include: an **aerial view of a turquoise river meandering through bright-green grassland** under a pale blue sky (teal + green); an **aerial shot above a blanket of white cumulus clouds** with a clear blue sky above (white + soft blue); a **night sky with a bright moon and stars over dramatic clouds lit fiery red-orange** against deep navy; an **airplane silhouette flying across a blazing red-orange sunset sky**; a **sunset seen through an oval airplane window**, warm orange-and-blue sky over a low cloud layer; and several **neon-lit cyberpunk city streets at night/dusk** in magenta/purple tones — a glowing car on a wet foggy avenue, two figures beside a red-lit neon monolith, and a tree-lined boulevard leading to backlit neon towers. Dominant colors across the set span teals and greens, cool blues and whites, fiery reds and oranges, and electric magentas/purples — no single unifying palette.
- **Set 3 (segment 3)** — 15 scenic photographs (any cohesive-feeling grab bag).
- **Set 4 (segment 4)** — 15 scenic photographs (any cohesive-feeling grab bag).

If you have fewer than 15 per set, repeat images to fill each set.

## Behavior notes
- **Not scrubbed:** the pin holds the section; each segment crossing plays a one-shot card swap + heading cross-fade, gated by an `isAnimating` flag so swaps never overlap. Scrolling back up re-crosses boundaries and swaps in the appropriate set the same way.
- Heading always stays centered and on top (`z-index:2`); cards render beneath it.
- Cards are `position:absolute` and animated via `left`/`top` + `rotation`; keep `will-change:transform` on `.card` and `will-change:opacity` on the heading.
- `lagSmoothing(0)` + the function-based `end` keep the pin distance correct; `ScrollTrigger.refresh()` on resize plus `reinitialize()` re-derive ring positions for the new viewport.
- No SplitText, no CustomEase, no lerp/rAF loop, no Three.js — just ScrollTrigger pinning, Lenis smooth scroll, and per-boundary GSAP timelines.
</content>
</invoke>
