# PrototypeStudio Scroll Animation — Pinned Spotlight Counter + Image Column

## Goal
Build a portfolio "spotlight" section that gets **pinned and scrubbed over five viewport heights** while three things move in lockstep with scroll progress: a large uppercase **`01/10` counter** that both **updates its number** and **slides straight down** the left edge; a **vertical column of ten project images**, centered on screen, that **translates upward** so each image passes through the middle of the viewport in turn; and a bottom-right **list of ten project names** where each name **slides up within its own slice of the scroll** and **turns white while it is the active project**. The image currently crossing the horizontal midline **brightens from 50% to full opacity**. Smooth scroll via Lenis. There is a plain intro screen before and a plain outro screen after. This is **not a GSAP timeline** — it is a single pinned `ScrollTrigger` whose `onUpdate` callback drives everything with `gsap.set` off `self.progress`.

## Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
- **`gsap`** (3.x) plus the plugin **`ScrollTrigger`**.
- **`lenis`** — smooth scroll.

```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);
```

Run everything inside `document.addEventListener("DOMContentLoaded", …)`. No other plugins, no SplitText, no CustomEase, no Three.js.

## Layout / HTML
Three full-screen `<section>`s in order: `.intro`, `.spotlight`, `.outro`. Class names are load-bearing — the JS/CSS query them.

```html
<section class="intro">
  <p>A collection of selected works</p>
</section>

<section class="spotlight">
  <div class="project-index">
    <h1>01/10</h1>
  </div>

  <div class="project-images">
    <div class="project-img"><img src="…" alt="" /></div>
    <div class="project-img"><img src="…" alt="" /></div>
    <!-- …ten .project-img wrappers total, each with one <img> -->
  </div>

  <div class="project-names">
    <p>Human Form Study</p>
    <p>Interior Light</p>
    <p>Project 21</p>
    <p>Shadow Portraits</p>
    <p>Everyday Objects</p>
    <p>Unit 07 Care</p>
    <p>Motion Practice</p>
    <p>Noonlight Series</p>
    <p>Material Stillness</p>
    <p>Quiet Walk</p>
  </div>
</section>

<section class="outro">
  <p>Scroll complete</p>
</section>

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

Notes:
- `.project-images` holds **exactly 10** `.project-img` wrappers (each one `<img>`). `.project-names` holds **exactly 10** `<p>`. The two counts must match — the counter, the name windows and the image column are all keyed to `totalProjectCount = 10`.
- The counter starts as literal text `01/10`; JS overwrites its `textContent` every frame.
- Use the neutral demo copy verbatim. "PrototypeStudio" is the fictional demo brand — no real client names. Intro paragraph: `A collection of selected works`. Outro paragraph: `Scroll complete`. The ten project names above are arbitrary editorial titles.

## Styling
Font (Google Fonts): **Google Sans** — import the full ital/optical-size/weight axis:
```css
@import url("https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap");
```

Palette (exact hex):
- Page background: `#141414` (near-black).
- Text: `#fff`.
- Inactive project name: `#4a4a4a` (dim grey). Active name animates to `#fff`.

Global / reset:
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `body { font-family:"Google Sans", sans-serif; background-color:#141414; color:#fff; }`
- `img { width:100%; height:100%; object-fit:cover; }`
- `h1 { text-transform:uppercase; font-size:clamp(3rem, 5vw, 7rem); font-weight:400; line-height:1; }`
- `p { font-size:1.5rem; font-weight:500; line-height:1.25; }`

Sections:
- `section { position:relative; width:100%; height:100svh; padding:2rem; overflow:hidden; }` — every section is exactly one viewport tall with a **2rem padding** (this padding value is read by the JS, so keep it at `2rem`).
- `.intro, .outro { display:flex; justify-content:center; align-items:center; text-align:center; }` — a single centered line of copy.

`.project-index` (the counter) — no special positioning of its own; it sits in the normal flow at the top-left of the padded `.spotlight`. Its `h1` is the element GSAP moves.

`.project-images` (the centered image column):
- `position:absolute; top:0; left:50%; transform:translateX(-50%);` — horizontally centered.
- `width:35%;` — the column is 35% of viewport width; each image is 16:9 within it.
- `padding:50svh 0;` — **half-a-viewport of top and bottom padding** (this is what lets the first/last images sit near mid-screen at the extremes of scroll).
- `display:flex; flex-direction:column; gap:0.5rem;`
- `z-index:-1;` — the column sits **behind** the counter and names.

`.project-img`:
- `width:100%; aspect-ratio:16/9; overflow:hidden;`
- `opacity:0.5;` — **dimmed by default**.
- `transition:all 0.3s ease;` — so the JS-driven opacity flip eases smoothly.

`.project-names` (bottom-right name list):
- `position:absolute; right:2rem; bottom:2rem; display:flex; flex-direction:column; align-items:flex-end;` — right-aligned stack anchored to the bottom-right corner.
- `.project-names p { color:#4a4a4a; transition:color 0.3s ease; }` — dim by default, eased color change.

Performance hint (keep it): `.project-index h1, .project-images, .project-names p { will-change:transform; }`.

## GSAP effect (the important part — be exhaustive)

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

### Grab elements + measure geometry (all measured once, at load)
```js
const spotlightSection        = document.querySelector(".spotlight");
const projectIndex            = document.querySelector(".project-index h1");
const projectImgs             = document.querySelectorAll(".project-img");        // 10
const projectImagesContainer  = document.querySelector(".project-images");
const projectNames            = document.querySelectorAll(".project-names p");    // 10
const projectNamesContainer   = document.querySelector(".project-names");
const totalProjectCount       = projectNames.length;                             // 10

const spotlightSectionHeight  = spotlightSection.offsetHeight;                   // ≈ 100svh
const spotlightSectionPadding = parseFloat(getComputedStyle(spotlightSection).padding); // 2rem → 32
const projectIndexHeight      = projectIndex.offsetHeight;                       // counter h1 height
const containerHeight         = projectNamesContainer.offsetHeight;             // name-list height
const imagesHeight            = projectImagesContainer.offsetHeight;            // tall image column (10 imgs + gaps + 100svh padding)

// Travel distances:
const moveDistanceIndex  = spotlightSectionHeight - spotlightSectionPadding * 2 - projectIndexHeight;  // + (counter slides DOWN)
const moveDistanceNames  = spotlightSectionHeight - spotlightSectionPadding * 2 - containerHeight;     // + (names slide UP by -value)
const moveDistanceImages = window.innerHeight - imagesHeight;                                          // − (column slides UP)

const imgActivationThreshold = window.innerHeight / 2;  // the horizontal midline of the viewport
```
- `moveDistanceIndex` is the vertical span from the top of the padded content box to where the counter's bottom edge reaches the bottom of that box → the counter travels **down** by this many px across the full scroll.
- `moveDistanceNames` is the analogous span for the name list → each name travels **up** by up to this much (applied negatively).
- `moveDistanceImages` is `innerHeight − imagesHeight`, a **negative** number (the column is far taller than the viewport), so multiplying by progress translates the column **upward**.

### The single pinned ScrollTrigger (no timeline — everything in `onUpdate`)
```js
ScrollTrigger.create({
  trigger: ".spotlight",
  start: "top top",
  end: `+=${window.innerHeight * 5}px`,   // pinned/scrubbed across FIVE viewport heights
  pin: true,
  pinSpacing: true,
  scrub: 1,                                // 1s catch-up smoothing on the scrub
  onUpdate: (self) => {
    const progress = self.progress;        // 0 → 1 over the 5vh runway

    // 1) COUNTER TEXT — which project index are we on (1-based, clamped to 10)
    const currentIndex = Math.min(
      Math.floor(progress * totalProjectCount) + 1,
      totalProjectCount,
    );
    projectIndex.textContent =
      `${String(currentIndex).padStart(2, "0")}/${String(totalProjectCount).padStart(2, "0")}`;
    // → "01/10", "02/10", … "10/10"

    // 2) COUNTER POSITION — slide the h1 straight down
    gsap.set(projectIndex, { y: progress * moveDistanceIndex });

    // 3) IMAGE COLUMN — translate the whole column upward
    gsap.set(projectImagesContainer, { y: progress * moveDistanceImages });

    // 4) IMAGE OPACITY — brighten whichever image is crossing the midline
    projectImgs.forEach((img) => {
      const r = img.getBoundingClientRect();
      if (r.top <= imgActivationThreshold && r.bottom >= imgActivationThreshold) {
        gsap.set(img, { opacity: 1 });     // the image spanning the viewport's vertical center
      } else {
        gsap.set(img, { opacity: 0.5 });   // everything else stays dimmed
      }
    });

    // 5) PROJECT NAMES — each name owns a 1/10 slice of progress
    projectNames.forEach((p, index) => {
      const startProgress = index / totalProjectCount;
      const endProgress   = (index + 1) / totalProjectCount;
      const projectProgress = Math.max(
        0,
        Math.min(1, (progress - startProgress) / (endProgress - startProgress)),
      );

      gsap.set(p, { y: -projectProgress * moveDistanceNames });  // slide UP within its window

      if (projectProgress > 0 && projectProgress < 1) {
        gsap.set(p, { color: "#fff" });    // active → white
      } else {
        gsap.set(p, { color: "#4a4a4a" }); // idle → dim grey
      }
    });
  },
});
```

Precise behavior of each sub-effect:
- **Counter number** — `Math.floor(progress * 10) + 1`, capped at 10. It steps `01/10 → 10/10` as you scrub; the `/10` denominator is `totalProjectCount` zero-padded. It only reads `10/10` at the very end (the `Math.min` clamp).
- **Counter slide** — a simple linear `y = progress * moveDistanceIndex` (a `gsap.set`, so it tracks the scrub 1:1). Moves the big `01/10` from the top of the padded box down to the bottom.
- **Image column** — `y = progress * moveDistanceImages` (moveDistanceImages is negative), so the tall centered column glides upward the whole scroll. Because of the `50svh` top/bottom padding, at `progress ≈ 0` the first image sits near the vertical center and at `progress ≈ 1` the last image sits near the vertical center; in between, each of the ten images passes through the midline in sequence.
- **Image activation** — every frame, each image's live `getBoundingClientRect()` is tested: if the viewport midline (`innerHeight/2`) falls between the image's `top` and `bottom`, that image is set to `opacity:1`, all others to `opacity:0.5`. Combined with the CSS `transition:all 0.3s ease`, the "spotlight" image brightens as it reaches center and dims as it leaves. Exactly one image is typically full-opacity at a time.
- **Name windows** — the ten names divide the 0→1 progress into ten equal slices. Name `index` is "active" only while `progress` is inside `[index/10, (index+1)/10)`; within that slice its local `projectProgress` ramps 0→1. It uses that local ramp to (a) slide up by `-projectProgress * moveDistanceNames` and (b) hold color `#fff`. Outside its slice `projectProgress` clamps to 0 or 1, the name returns to `#4a4a4a`, and its `y` sits at `0` (before its slice) or `-moveDistanceNames` (after its slice — i.e. it stays parked up). The CSS `transition:color 0.3s ease` softens the grey↔white flip.

**No ease/duration/stagger anywhere** — every motion is a `gsap.set` inside `onUpdate`, so the *only* easing/smoothing is (a) ScrollTrigger's `scrub: 1` on the driving progress and (b) the two CSS `transition`s (0.3s ease on image opacity and name color). No `gsap.to`/`gsap.timeline` is used. No SplitText, CustomEase, lerp/rAF interpolation, or Three.js.

## Assets / images
**Ten landscape 16:9 photos**, each rendered `100%` width/height with `object-fit:cover` inside a column that is 35% of viewport width — so they read as a vertical portfolio strip. Source them roughly landscape 16:9; the exact crop doesn't matter (`cover` handles it). The set is **not** one uniform color story: the outdoor portraits and the interior are **warm and sun-lit** (golden-hour light, beige/tan/cream neutrals, pops of coral and orange), while two of the cosmetic still-lifes are **cool and moody** (grey or near-black backdrops). The mix is roughly: **three lifestyle portrait subjects** (two of them repeated as a second near-identical crop, so five portrait frames total), **one warm minimal interior**, and **four product/cosmetic still-lifes**. Real subjects in the current set (by role, no brands or logos anywhere):
- **Portrait — man in coral tank (appears twice, two crops):** a young man with curly brown hair in a coral/peach tank top, leaning against a pale sage-green wall that catches his sharp cast shadow in warm sunlight; pale stucco building and a potted plant behind him.
- **Portrait — backlit woman (appears twice, two crops):** a smiling woman with dark curly hair haloed by a low golden-hour sun, wearing white wired earphones, in a grey tank; amber, cream and green foliage tones.
- **Portrait — orange jacket:** a man in a vivid **orange** bomber jacket standing in a sunlit courtyard, pale stucco buildings and a small tree behind him under a bright washed-out sky.
- **Interior:** a serene warm minimal room — a wooden-framed armchair and a potted plant by a tall window, warm grid-shaped light and shadow raking across a beige wall and pale wood floor.
- **Still-life — moody grey:** a red lipstick with a gold band, a glossy black jar, and a clear glass pump bottle, lit against a cool grey concrete backdrop.
- **Still-life — warm blush:** a frosted glass cream jar with a white lid, a rose-gold/mauve lipstick, and a frosted pump bottle, with a blush rose and petals on a cream surface; soft pink and beige tones.
- **Still-life — dried flowers:** a glossy white cosmetic bottle with a black cap resting on a stone, surrounded by dried beige gypsophila against a neutral taupe backdrop.
- **Still-life — dark chrome:** an open silver/chrome powder compact showing a nude beige powder pan and its mirror, on a near-black backdrop.

## Behavior notes
- **Trigger:** scroll only. Nothing autoplays. The whole spotlight is scrubbed (`scrub: 1`) — it plays forward on scroll-down and reverses on scroll-up; parking the scroll freezes it mid-sequence.
- **Pin runway:** the `.spotlight` section is pinned with `pinSpacing: true` for `+= window.innerHeight * 5` px, so ScrollTrigger inserts a 5-viewport spacer automatically — no manual margin needed. The intro sits above and the outro below in normal flow.
- **Heights use `svh`** (`100svh` sections, `50svh` column padding) so mobile browser chrome doesn't break the full-screen layout.
- **Responsive** (`@media (max-width:1000px)`): the image column relaxes to `width: calc(100% - 4rem)` and `gap: 25svh` (near-full-width images spaced far apart), and every project name is forced white (`.project-names p { color:#fff !important; }`) since the dim/active distinction is less useful at that size. The pin + scrub effect still runs.
- Keep the `will-change:transform` hints on the counter h1, the image column and the name paragraphs.
- No reduced-motion handling in the original.
