# Editorial Portfolio Gallery — Fixed Project Number that Sticks at Center then Peels Upward as the Next Section Pushes it Out

## Goal
Build a tall, scroll-driven vertical portfolio. Eight full-viewport project sections scroll past under smooth (Lenis) scrolling. On the left edge of each section a large **two-digit project number** (01–08) sits pinned; when a section reaches the vertical center of the screen its number **switches from absolutely-positioned to `position:fixed` at `top:50vh`** so it hangs at screen-center, and the moment the **next** section climbs into view the number **peels upward out of a fixed-height mask** — the mask, an inner digit-wrapper, and each of the two digit glyphs all translate `y:-80` with **staggered durations and delays** so the number exits in layers. Meanwhile a slim right-edge **progress bar** fills top→bottom, a small **project-name list** on the left has a triangular **indicator that hops down one row per section** while the current name goes black, and a **bottom-right preview thumbnail swaps to whichever gallery image is currently crossing the viewport's vertical center**. The star effect is the layered, velocity-aware masked-number push-up driven entirely from a single per-frame `ScrollTrigger.onUpdate` reading `getBoundingClientRect()`.

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

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

gsap.registerPlugin(ScrollTrigger);
```
Wrap the whole script in `document.addEventListener("DOMContentLoaded", () => { … })`. No SplitText, no CustomEase, no canvas/WebGL. The digit-splitting and the image-grid population are done by hand in JS (see below), so the HTML you author does **not** contain the digit spans or the gallery `<img>`s — JS injects them.

## Layout / HTML
Class names are load-bearing (JS/CSS query them). Author the gallery `.images` containers **empty** — JS fills them.

```html
<nav>
  <a href="#">Index</a>
  <a href="#">Watch Showreel</a>
</nav>
<div class="nav-items">
  <a href="#">Work,</a>
  <a href="#">About,</a>
  <a href="#">Contact</a>
</div>

<div class="whitespace w-1"></div>

<div class="gallery">
  <!-- Repeat this .project block 8 times, numbers 01 → 08 -->
  <div class="project">
    <div class="index">
      <div class="mask"><h1>01</h1></div>
    </div>
    <div class="images"></div>   <!-- left empty; JS injects 6 .img each -->
  </div>
  <!-- …02 … 08 … -->
</div>

<div class="whitespace w-2"></div>

<div class="project-names">
  <div class="indicator">
    <div class="symbol"></div>
  </div>
  <div class="name active"><p>Atlas Studio</p></div>
  <div class="name"><p>Nimbus</p></div>
  <div class="name"><p>Solara</p></div>
  <div class="name"><p>Quantum</p></div>
  <div class="name"><p>Echo Labs</p></div>
  <div class="name"><p>Vesper</p></div>
  <div class="name"><p>Axon</p></div>
  <div class="name"><p>Horizon</p></div>
</div>

<div class="preview-img">
  <img src="/img1.jpg" alt="" />
</div>

<div class="progress-bar"></div>

<div class="footer"><p>Portfolio 2024</p></div>

<script type="module" src="./script.js"></script>
```
Notes:
- Exactly **8** `.project` blocks; each `.mask h1` holds a **two-character** number as text (`01`…`08`) — the split logic assumes exactly two glyphs.
- There are **8** `.name` rows matching the 8 projects; the first row carries `.active`. Names are neutral invented studio names (no real brands).
- The `.preview-img`'s initial `<img>` src is the first gallery image; JS will overwrite `.src` on scroll.

## Styling
Font: **Circular Std**, weight **500**, applied to everything. (Circular Std is a licensed geometric sans; if unavailable, fall back to any clean geometric sans such as `system-ui`/Helvetica/Inter — the layout does not depend on the exact face.) Everything is **black text on a plain white page** (no `background` set on `body`, so default white `#fff`).

Reset & type:
- `::-webkit-scrollbar { display:none; }` (hide native scrollbar).
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `img { width:100%; height:100%; object-fit:cover; }`
- `h1 { font-family:"Circular Std"; font-weight:500; font-size:58px; }`
- `p, a { text-decoration:none; text-transform:uppercase; font-family:"Circular Std"; font-size:14px; font-weight:500; color:#000; transition:color 0.3s; }`

Fixed chrome (all corners pinned to the viewport):
- `nav { position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; }` — "Index" left, "Watch Showreel" right.
- `.nav-items { position:fixed; top:2em; left:40%; display:flex; gap:0.25em; }` — three inline links.
- `nav a, .nav-items a { text-transform:none; }` (override the global uppercase for the nav links only).
- `.footer p { position:fixed; bottom:2em; left:40%; text-transform:none; }`

A **`left:40%` vertical guide** aligns `.nav-items`, `.project-names`, and the footer to the same column.

Spacers (pure scroll runway so the first/last project can travel through center):
- `.whitespace.w-1 { width:100vw; height:50vh; }`
- `.whitespace.w-2 { width:100vw; height:45vh; }`

Gallery grid:
- `.gallery { position:relative; width:100%; display:flex; flex-direction:column; gap:20em; }` — big **20em vertical gap** between projects.
- `.project { position:relative; width:100vw; height:100vh; display:flex; }` — each section is one full viewport, a **flex row**.
- `.index { flex:1; padding-left:2em; height:0; }` — left rail, **1/7 of the row width, zero height** (it only anchors the absolutely-positioned mask; it must not consume vertical space).
- `.images { flex:6; display:flex; flex-direction:column; gap:1em; height:100vh; }` — right column, **6/7 width, full height**, holding 6 stacked images with 1em gaps.
- `.img { flex:1; width:200px; background-color:lightgray; overflow:hidden; }` — each image cell splits the column height equally (`flex:1`), clips overflow.
- `.img img { opacity:0.9; }` — gallery images sit at 90% opacity.

The masked number (critical geometry):
- `.index .mask { position:absolute; top:0; left:2em; height:70px; overflow:hidden; will-change:transform; }` — the mask is a **70px-tall clipping window** at the top-left of each `.project` (its offset parent is `.project`, which is `relative`). `overflow:hidden` is what makes the digit disappear as it slides up.
- `.index .mask h1 { position:relative; will-change:transform; }`
- `.index .mask h1 span { position:relative; display:inline-block; will-change:transform; }`
- `.digit-wrapper, .digit-wrapper span { display:inline-block; position:relative; will-change:transform; }` — the JS-injected wrapper and its two digit spans are inline-blocks so each can be transformed independently.

Left project-name list + indicator:
- `.project-names { position:fixed; width:200px; top:50vh; left:40%; transform:translateX(0%); }` — fixed at vertical center, left column.
- `.indicator { position:absolute; top:0; right:0; width:18px; height:18px; display:flex; justify-content:center; align-items:center; will-change:transform; }` — a small box at the right of the name list that GSAP slides down.
- `.symbol { width:12px; height:12px; background-color:#000; clip-path:polygon(0 50%, 100% 100%, 100% 0); }` — a **left-pointing black triangle**.
- `.name { height:18px; }` — each row is exactly 18px tall (matches the indicator's 18px step).
- `.name p { color:gray; }` and `.name.active p { color:#000; }` — inactive names are gray, the active one is black.

Bottom-right preview + progress bar:
- `.preview-img { position:fixed; bottom:2em; right:2em; max-width:40vw; height:calc(50vh - 2em); opacity:0.9; }` — a floating preview panel bottom-right, up to 40vw wide, roughly half-viewport tall, at 90% opacity.
- `.progress-bar { position:fixed; top:0; right:0; width:8px; height:100vh; background-color:#000; transform-origin:top; transform:scaleY(0); }` — an **8px black bar** on the far right, scaled from the top, starting collapsed (`scaleY(0)`).

## GSAP effect (be exhaustive — this is the point)

### Smooth-scroll wiring (Lenis + GSAP ticker)
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
  lenis.raf(time * 750);
});
gsap.ticker.lagSmoothing(0);
```
Note the multiplier is **`time * 750`** (not the usual 1000) — a deliberately slower, heavier Lenis feel. Lenis pumps `ScrollTrigger.update` on every scroll; lag smoothing off.

### Up-front state (declared before any ScrollTrigger to dodge a TDZ)
A pinned trigger's `onUpdate` can fire during `ScrollTrigger.refresh()` on creation, before later declarations run, so declare these first:
```js
let scrollVelocity = 0, lastScrollTop = 0, activeIndex = -1;
```

### Manual digit split — `splitTextIntoSpans(".mask h1")`
For every `.mask h1`, read its two characters and rebuild its `innerHTML`:
```js
function splitTextIntoSpans(selector) {
  document.querySelectorAll(selector).forEach((el) => {
    const [firstDigit, secondDigit] = el.innerText;   // e.g. "0","1"
    el.innerHTML = `
      <div class="digit-wrapper">
        <span class="first">${firstDigit}</span><span class="second">${secondDigit}</span>
      </div>`;
  });
}
splitTextIntoSpans(".mask h1");
```
Result nesting inside each mask: `h1 > .digit-wrapper > span.first + span.second`. These four layers (`mask`, `.digit-wrapper`, `.first`, `.second`) are what the push-up animates independently.

### Manual gallery population — `populateGallery()`
```js
const imagesPerProject = 6;
const totalImages = 50;
let imageIndex = 1;
function populateGallery() {
  document.querySelectorAll(".images").forEach((container) => {
    for (let j = 0; j < imagesPerProject; j++) {
      if (imageIndex > totalImages) imageIndex = 1;     // wrap/cycle
      const cell = document.createElement("div");
      cell.classList.add("img");
      const img = document.createElement("img");
      img.src = `/img${imageIndex}.jpg`;
      img.alt = `Project Image ${imageIndex}`;
      cell.appendChild(img);
      container.appendChild(cell);
      imageIndex++;
    }
  });
}
populateGallery();
```
Each of the 8 `.images` gets **6** `.img` cells → **48 images total**, numbered sequentially `img1…img48` (the cycle-back at 50 never triggers with these counts). Adjust the `/img${i}.jpg` path to wherever your fixtures live.

### 1) Side progress bar
```js
ScrollTrigger.create({
  trigger: "body",
  start: "top top",
  end: "bottom bottom",
  onUpdate: (self) => gsap.set(".progress-bar", { scaleY: self.progress }),
});
```
The 8px right bar's `scaleY` tracks whole-page scroll progress 0→1 (fills downward from `transform-origin:top`). Per-frame `gsap.set`, no ease.

### 2) Bottom-right preview image follows the centered gallery image
For **every** `.img img` create a trigger keyed to the viewport's vertical center:
```js
const previewImg = document.querySelector(".preview-img img");
document.querySelectorAll(".img img").forEach((img) => {
  ScrollTrigger.create({
    trigger: img,
    start: "top 50%",
    end: "bottom 50%",
    onEnter:     () => (previewImg.src = img.src),
    onEnterBack: () => (previewImg.src = img.src),
  });
});
```
Whenever a gallery image crosses the 50%-height line (scrolling either direction) the floating preview swaps its `src` to that image. No tween — an instant source swap.

### 3) Name-list indicator hop + active name
```js
const indicator = document.querySelector(".indicator");
const indicatorStep = 18;                       // px per row (= .name height)
const names = gsap.utils.toArray(".name");
gsap.set(".indicator", { top: "0px" });
const projects = gsap.utils.toArray(".project");

projects.forEach((project, index) => {
  ScrollTrigger.create({
    trigger: project,
    start: "top 50%",
    end: "bottom 50%",
    onEnter: () => {
      gsap.to(indicator, { top: Math.max(0, index * indicatorStep) + "px", duration: 0.3, ease: "power2.out" });
      names.forEach((n, i) => n.classList.toggle("active", i === index));
    },
    onLeaveBack: () => {
      const target = index - 1 < 0 ? 0 : (index - 1) * indicatorStep;
      gsap.to(indicator, { top: target + "px", duration: 0.3, ease: "power2.out" });
      names.forEach((n, i) => n.classList.toggle("active", i === (index - 1 < 0 ? 0 : index - 1)));
    },
  });
});
```
As each project's top crosses viewport-center the triangle **slides to row `index` (18px per row) over 0.3s `power2.out`** and that name row becomes black/active; scrolling back up returns it to the previous row.

### 4) The star: fixed-then-peel masked number (per-frame, velocity-aware)
For every project, cache its four layers, reset transforms, and build one big `onUpdate` trigger:
```js
projects.forEach((project, i) => {
  const mask = project.querySelector(".mask");
  const digitWrapper = project.querySelector(".digit-wrapper");
  const firstDigit = project.querySelector(".first");
  const secondDigit = project.querySelector(".second");

  gsap.set([mask, digitWrapper, firstDigit, secondDigit], { y: 0 });
  gsap.set(mask, { position: "absolute", top: 0 });

  ScrollTrigger.create({
    trigger: project,
    start: "top bottom",
    end: "bottom top",
    anticipatePin: 1,
    fastScrollEnd: true,
    preventOverlaps: true,
    onUpdate: (self) => {
      const projectRect = project.getBoundingClientRect();
      const windowCenter = window.innerHeight / 2;
      const nextProject = projects[i + 1];
      const velocityAdjustment = Math.min(scrollVelocity * 0.1, 100);
      const pushPoint = window.innerHeight * (0.85 + velocityAdjustment / window.innerHeight);

      if (projectRect.top <= windowCenter) {
        // (a) once this project reaches center, pin its number to screen-center
        if (!mask.isFixed) {
          mask.isFixed = true;
          gsap.set(mask, { position: "fixed", top: "50vh" });
        }
        // (b) when the NEXT project climbs to the push point, peel this number up
        if (nextProject) {
          const nextRect = nextProject.getBoundingClientRect();
          if (nextRect.top <= pushPoint && activeIndex !== i + 1) {
            gsap.killTweensOf([mask, digitWrapper, firstDigit, secondDigit]);
            activeIndex = i + 1;
            gsap.to(mask,        { y: -80, duration: 0.3,  ease: "power2.out", overwrite: true });
            gsap.to(digitWrapper,{ y: -80, duration: 0.5,  delay: 0.5, ease: "power2.out", overwrite: true });
            gsap.to(firstDigit,  { y: -80, duration: 0.75, ease: "power2.out", overwrite: true });
            gsap.to(secondDigit, { y: -80, duration: 0.75, delay: 0.1, ease: "power2.out", overwrite: true });
          }
        }
      } else {
        // above center → keep number in its normal absolute spot
        mask.isFixed = false;
        gsap.set(mask, { position: "absolute", top: 0 });
      }

      // (c) scrolling UP and this project's top is back below center → restore the PREVIOUS number
      if (self.direction === -1 && projectRect.top > windowCenter) {
        mask.isFixed = false;
        gsap.set(mask, { position: "absolute", top: 0 });
        if (i > 0 && activeIndex === i) {
          const prev = projects[i - 1];
          const pMask = prev.querySelector(".mask");
          const pWrap = prev.querySelector(".digit-wrapper");
          const pFirst = prev.querySelector(".first");
          const pSecond = prev.querySelector(".second");
          gsap.killTweensOf([pMask, pWrap, pFirst, pSecond]);
          activeIndex = i - 1;
          gsap.to([pMask, pWrap], { y: 0, duration: 0.3,  ease: "power2.out", overwrite: true });
          gsap.to(pFirst,         { y: 0, duration: 0.75, ease: "power2.out", overwrite: true });
          gsap.to(pSecond,        { y: 0, duration: 0.75, delay: 0.1, ease: "power2.out", overwrite: true });
        }
      }
    },
    onEnter: () => { if (i === 0) activeIndex = 0; },
  });
});
```
Precise behavior to reproduce:
- **Pin at center:** while `projectRect.top <= innerHeight/2`, the number's `.mask` flips to `position:fixed; top:50vh` (its `left:2em` is untouched, so it hangs at the middle-left of the screen). A per-element boolean flag `mask.isFixed` guards the one-time flip. Above center it flips back to `position:absolute; top:0`.
- **Push point is velocity-aware:** `pushPoint = innerHeight * (0.85 + velAdj/innerHeight)` where `velAdj = min(scrollVelocity*0.1, 100)`. At rest the next project must climb to **85% of viewport height** to trigger the peel; faster scrolling nudges that threshold lower (later), capped by the +100px clamp. `scrollVelocity` comes from the scroll listener below.
- **The peel (layered stagger, all `y:0 → y:-80`, all `power2.out`, all `overwrite:true`):**
  - `.mask` — **duration 0.3**, no delay (the clipping window itself lifts first/fastest).
  - `.first` (first digit) — **duration 0.75**, no delay.
  - `.second` (second digit) — **duration 0.75, delay 0.1** (trails the first digit by 0.1s).
  - `.digit-wrapper` — **duration 0.5, delay 0.5** (starts latest).
  These four independent tweens on nested layers compound (each layer's -80 adds to its ancestor's) and, because they have different speeds/delays, the number appears to **peel apart and slide up out of the 70px mask in staggered layers** rather than as one rigid block. An `activeIndex` latch (`activeIndex !== i + 1`) plus `killTweensOf` ensures the peel fires exactly once per boundary.
- **Reverse on scroll-up:** when scrolling up (`self.direction === -1`) and the current project falls back below center, the **previous** project's number is animated **back to `y:0`** (mask+wrapper 0.3s, first 0.75s, second 0.75s/delay 0.1s) and `activeIndex` steps back to `i-1`, so numbers reassemble as you scroll upward.

### 5) Scroll-velocity tracker (feeds the push point)
```js
window.addEventListener("scroll", () => {
  const st = window.pageYOffset;
  scrollVelocity = Math.abs(st - lastScrollTop);
  lastScrollTop = st;
}, { passive: true });
```
`scrollVelocity` = absolute px delta per scroll event; larger deltas raise `velocityAdjustment` (clamped 0–100) which shifts the peel's `pushPoint`.

## Assets / images
A single **editorial photography set** of **50 images** (`img1.jpg … img50.jpg`); the layout consumes the first **48** (6 per project × 8 projects), plus `img1.jpg` seeds the preview panel. All are shown with `object-fit:cover`, so exact source aspect ratios don't matter — provide a mix (the set includes tall **2:3 portraits**, near-**1:1** frames, and wide **~16:9 / 3:2 landscapes**). Mood: quiet, natural-light, film-grain editorial — a **weathered older man's face in soft daylight**, a **freckled red-haired woman close-up**, a **hands-on-piano-keys low-key interior**, a **studio side-profile against a saturated red backdrop**, and similar character portraits, tight close-ups, and atmospheric lifestyle/environmental shots. Neutral palette overall (skin tones, warm daylight, occasional bold single-color studio backdrop). No logos, no text, no brand marks. If you have fewer than 48 unique photos, repeat them to fill the grid.

## Behavior notes
- **Trigger: scroll only** (Lenis-smoothed). Nothing autoplays; every effect is scroll-position driven via `ScrollTrigger` (progress bar and preview are per-frame/threshold; the number peel is discrete tweens fired at boundaries).
- The two `.whitespace` spacers (50vh top, 45vh bottom) plus the 20em inter-project gaps give the first and last numbers room to reach and leave screen-center.
- `getBoundingClientRect()`, `window.innerHeight`, and `window.pageYOffset` are read live each frame, so the layout stays correct on resize/viewport changes without recomputing offsets.
- Desktop-oriented full-viewport composition; there is **no reduced-motion guard** and no separate mobile breakpoint in the original — the same triggers run at all sizes.
