# Cosmic Scroll Story — Pinned Section with Clip-Path Reveals, Deep Zoom & Flip-Open Headline

## Goal
Build a long-scroll "scrollytelling" page whose centerpiece is a single section pinned for **4 viewport heights** while every stage is scrubbed by scroll. As you scroll through the pinned section: the letters of two intro paragraphs **flicker in** one-by-one in a random order; a small centered rectangle **expands via an animated `clip-path`** into a fullscreen image (its photo scaling up at the same time); then a third image **opens outward from a central seam** while it is held at a heavy 3× zoom, and finally **zooms back out** to natural scale; and as a closing beat a headline that starts folded away at `rotateY(-75deg)` **swings flat to camera** to reveal the last line of copy. All of it is driven by GSAP `ScrollTrigger` (one pin + many scrubbed tweens) with Lenis smooth scroll.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin **`ScrollTrigger`**, and **`lenis`** for smooth scroll. Register with `gsap.registerPlugin(ScrollTrigger)`. Run everything inside a `DOMContentLoaded` handler. No SplitText, no CustomEase, no Three.js — the letter split is done by hand.

## Layout / HTML
Single `.container` wrapping five full-viewport sections in order. Class names are load-bearing (JS/CSS query them):

```
<div class="container">
  <section class="hero">
    <h1>Genesis</h1>
    <p>At the nexus of infinite realities, the Aethoria Spire rises: a beacon of limitless potential in the cosmic tapestry.</p>
  </section>

  <section class="about">
    <div class="about-img"><img src="..." alt="" /></div>
    <div class="about-copy"><h1>The Omniversal Fulcrum</h1></div>
  </section>

  <section class="sticky">
    <div class="intro">
      <div class="intro-col">
        <p>Veiled depths of eternity</p>
        <p>Currents of cosmic wisdom</p>
      </div>
      <div class="intro-col">
        <p>Nexus of all existence</p>
      </div>
    </div>
    <div class="img-1"><img src="..." alt="" /></div>
    <div class="img-2"><img src="..." alt="" /></div>
    <div class="img-3"><img src="..." alt="" /></div>
    <div class="copy">
      <h1>The cosmic keystone within Aethoria unlocks the gates to multiversal transformation</h1>
    </div>
  </section>

  <section class="footer">
    <h1>Infinite Realms <br /> Beckon Beyond</h1>
  </section>
</div>
```

All copy is fictional cosmic flavor text — keep it neutral, no brand names.

## Styling
Fonts: body/paragraphs use **Roboto Mono** (Google Fonts, weights 100–700). Every `h1` uses a **heavy condensed uppercase display face** — declare `font-family: "FK Screamer"` with a robust fallback (e.g. a bold grotesque like `"Anton", "Archivo Black", sans-serif`); it must render as a massive, tightly-set, all-caps headline.

Global reset & base:
- `* { margin:0; padding:0; box-sizing:border-box }`.
- `html, body { width:100%; height:700vh !important; font-family:"Roboto Mono", monospace }` — the **700vh body height is essential**: it is the total scroll runway that makes the pin + all scrubbed ranges reachable.
- `img { width:100%; height:100%; object-fit:cover }`.
- `h1 { text-transform:uppercase; font-size:15vw; font-weight:600; line-height:90%; letter-spacing:2px; color:#fff }`.
- `p { text-transform:uppercase; font-size:14px; font-weight:400; color:#fff }`.
- `section { width:100vw; height:100vh }`.

Sections:
- `.hero`: `padding:4em; display:flex; flex-direction:column; justify-content:space-between; align-items:center`; full-bleed background image `background: url(...) no-repeat 50% 50%; background-size:cover`. `.hero p { width:50%; text-align:center }`.
- `.about`: `padding:4em 12em; display:flex; justify-content:center; align-items:center; gap:10em; background-color:#667e74` (muted sage green). `.about-img, .about-copy { flex:1 }`. `.about-img { height:75%; border:2px solid #000 }`. `.about-copy h1 { text-align:center; font-size:10vw; color:#263a30 }` (dark green, overrides the white).
- `.footer`: `padding:2em; display:flex; justify-content:center; align-items:center; text-align:center; background-color:#1d2944` (dark navy).
- `.sticky`: `position:relative; perspective:1000px` — the **`perspective:1000px` is required** for the closing headline's 3D `rotateY` swing to read as depth.

Sticky-section children (all layered inside `.sticky`):
- `.intro { position:absolute; top:50%; transform:translateY(-50%); width:100%; padding:1em; display:flex; z-index:2 }` — sits above the images. `.intro-col { flex:1; display:flex }`, `.intro-col p { flex:1 }`, `.intro-col p span { display:inline-block }`. Second column right-aligned: `.intro-col:nth-child(2) p { text-align:right }`.
- `.img-1, .img-2, .img-3 { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden }` — three stacked full-cover layers (img-1 bottom, img-3 top).
- `.img-2` initial `clip-path: polygon(40% 25%, 60% 25%, 60% 75%, 40% 75%)` — a small centered rectangle (20% wide × 50% tall).
- `.img-3` initial `clip-path: polygon(50% 25%, 50% 25%, 50% 75%, 50% 75%)` — a zero-width vertical seam down the center. `.img-3 img { transform-origin:top right; transform:scale(3) }` — the top image starts zoomed to 3×, anchored to its top-right.
- `.copy { width:50%; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) rotateY(-75deg) scale(0.5); transform-origin:bottom left; display:none }` — the closing headline starts hidden, folded 75° away and half-scale. `.copy h1 { font-size:5vw; text-align:center }`.

Responsive (`max-width:900px`): `h1` → `font-size:20vw`; `.hero p` → `width:100%`; `.about` stacks column (`flex-direction:column; gap:4em; padding:4em 2em`), `.about-copy { flex:0.5 }`, `.about-img { margin-top:4em; height:100% }`; `.intro` and `.intro-col` become `flex-direction:column`, `.intro-col p { text-align:center !important }`; `.copy { width:90% }`, `.copy h1 { font-size:10vw }`.

## GSAP effect (be exact)

### Lenis + ScrollTrigger wiring
```js
const stickySection = document.querySelector(".sticky");
const totalStickyHeight = window.innerHeight * 4;

const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
```
Lenis drives scroll; GSAP's ticker drives Lenis (seconds → ms via `* 1000`). `totalStickyHeight` (4 × viewport) is the pin length and the reference for every absolute scroll marker below.

### 1. Manual letter split (no SplitText)
For each `.intro-col p`, take its `textContent`, split on whitespace (keep the spaces), and for every non-space chunk wrap **each character** in `<span style="opacity:0; display:inline-block;">…</span>`. Reassemble as `innerHTML`. Result: every letter is an individually-addressable, initially-invisible inline-block span; spaces stay as plain text.

### 2. Flicker-in / flicker-out of the intro letters
A reusable helper animates all letter spans to a target opacity with a **random-order flicker**:
```js
function flickerAnimation(targets, toOpacity) {
  gsap.to(targets, {
    opacity: toOpacity,
    duration: 0.05,
    stagger: { amount: 0.3, from: "random" },
  });
}
```
- `duration:0.05` per letter (near-instant snap), `stagger.amount:0.3` spreads the whole batch across 0.3s total, `from:"random"` scatters the order → a flickering, non-linear appearance.

Fire it from a **callback-only** ScrollTrigger (not scrubbed):
```js
ScrollTrigger.create({
  trigger: stickySection,
  start: "top top",
  end: () => `${window.innerHeight * 3}`,   // absolute scroll position (px), NOT "+="
  onEnter:     () => flickerAnimation(".intro-col p span", 1),
  onLeave:     () => flickerAnimation(".intro-col p span", 0),
  onEnterBack: () => flickerAnimation(".intro-col p span", 1),
  onLeaveBack: () => flickerAnimation(".intro-col p span", 0),
});
```
So the letters flicker **on** when the pin begins and flicker **off** near the end of the third viewport of scroll; scrolling back reverses it. (Because `end` is a bare number string it is treated as an absolute page-scroll position of `3 × innerHeight`px, not a relative offset.)

### 3. Pin the sticky section
```js
ScrollTrigger.create({
  trigger: stickySection,
  start: "top top",
  end: () => `+=${totalStickyHeight}`,   // +=4×innerHeight
  pin: true,
  pinSpacing: true,
});
```
The section locks to the viewport for 4 viewport-heights of scrolling; `pinSpacing:true` inserts that runway so the footer follows afterward.

### 4. img-2 reveal — clip-path expand + photo scale (first viewport, scroll 0 → 1×vh)
Two scrubbed tweens over `start:"top top"`, `end:() => "+="+window.innerHeight`, `scrub:true`, `ease:"none"`:

**(a) clip-path** driven manually in `onUpdate` by interpolating the small centered rectangle out to the full frame:
```js
scrollTrigger: {
  trigger: stickySection, start: "top top",
  end: () => `+=${window.innerHeight}`, scrub: true,
  onUpdate: (self) => {
    const p = self.progress;
    gsap.set(".img-2", { clipPath: `polygon(
      ${gsap.utils.interpolate(40, 0, p)}% ${gsap.utils.interpolate(25, 0, p)}%,
      ${gsap.utils.interpolate(60, 100, p)}% ${gsap.utils.interpolate(25, 0, p)}%,
      ${gsap.utils.interpolate(60, 100, p)}% ${gsap.utils.interpolate(75, 100, p)}%,
      ${gsap.utils.interpolate(40, 0, p)}% ${gsap.utils.interpolate(75, 100, p)}%
    )` });
  },
}
```
Corner journeys: left x 40→0, right x 60→100, top y 25→0, bottom y 75→100. The centered rectangle grows outward on all four edges into `polygon(0 0,100 0,100 100,0 100)` — a rectangular iris opening to fullscreen. (The tween's own `clipPath` target is that full polygon; the `onUpdate` overrides each frame so the interpolation is explicit.)

**(b) `.img-2 img` scale** `→ 1.125` over the same range (a slow inward push while the frame opens).

### 5. img-3 open + hold-zoom (fourth viewport, scroll 3×vh → 4×vh)
These use **absolute** scroll markers `start:() => window.innerHeight*3`, `end:() => window.innerHeight*4`, `scrub:true`, `ease:"none"`.

**(a) clip-path** opens from the center outward, again interpolated in `onUpdate`:
```js
onUpdate: (self) => {
  const p = self.progress;
  gsap.set(".img-3", { clipPath: `polygon(
    ${gsap.utils.interpolate(50, 0, p)}% ${gsap.utils.interpolate(50, 0, p)}%,
    ${gsap.utils.interpolate(50, 100, p)}% ${gsap.utils.interpolate(50, 0, p)}%,
    ${gsap.utils.interpolate(50, 100, p)}% ${gsap.utils.interpolate(50, 100, p)}%,
    ${gsap.utils.interpolate(50, 0, p)}% ${gsap.utils.interpolate(50, 100, p)}%
  )` });
}
```
All four corners start at the center point 50% 50% and expand to the frame corners → the top image irises open from the middle to fullscreen. (Target `clipPath` is the full polygon.)

**(b) `.img-2 img` continues scaling** `fromTo(1.125 → 1.25)` over this same 3×→4× range (keeps the middle layer creeping in).

**(c) `.img-3 img` scale** `to: 2.9` over 3×→4× — it is essentially held at its heavy 3× zoom (CSS start scale is `3`, animating to `2.9`), so as img-3 opens you see a deep, tightly-cropped detail.

### 6. img-3 zoom-out (scroll 4×vh → 6×vh)
```js
gsap.fromTo(".img-3 img", { scale: 2.9 }, {
  scale: 1, ease: "none",
  scrollTrigger: {
    trigger: stickySection,
    start: () => `${window.innerHeight * 4}`,
    end:   () => `${window.innerHeight * 6}`,
    scrub: true,
  },
});
```
Over the next two viewports the top image pulls back from 2.9× to its natural 1× — the "reveal the whole picture" beat. (This range extends past the 4×vh pin release, so the zoom-out finishes as the section scrolls away.)

### 7. Closing headline flip-open (scroll 4.5×vh → 5.5×vh)
```js
const tl = gsap.timeline({
  scrollTrigger: {
    trigger: stickySection,
    start: () => `${window.innerHeight * 4.5}`,
    end:   () => `${window.innerHeight * 5.5}`,
    scrub: true,
    toggleActions: "play reverse play reverse",
  },
});
tl.to(".copy", { display: "block", rotateY: 0, scale: 1, duration: 1 });
```
`.copy` (hidden, `rotateY(-75deg) scale(0.5)`, `transform-origin:bottom left`) swings flat to `rotateY(0) scale(1)` and becomes visible — with the parent `perspective:1000px`, it reads as a panel hinging open toward the viewer to present the final line. Scrubbed and reversible.

### Timing summary (in units of `window.innerHeight` of scroll)
- `0 → 1`: intro letters flickered in (from pin start); img-2 clip-path iris opens to fullscreen; img-2 photo scales 1→1.125.
- `1 → 3`: pinned, mostly holding (letters still on).
- `~3`: intro letters flicker out (flicker trigger ends at `3`).
- `3 → 4`: img-3 clip-path irises open from center; img-3 photo held ~3× (3→2.9); img-2 photo continues 1.125→1.25.
- `4 → 6`: img-3 photo zooms out 2.9 → 1 (pin releases at 4).
- `4.5 → 5.5`: closing `.copy` headline flips from `rotateY(-75deg) scale(0.5)` to flat.

All scrubbed tweens use `ease:"none"` so motion is linearly tied to scroll; the only non-scrubbed animation is the flicker (its own short easing/stagger).

## Assets / images
Five images, all photographic and atmospheric — a matched cinematic/cosmic set (nebulae, celestial landscapes, monolithic sci-fi structures, deep-space vistas), no logos or text:
- **1 hero background** — full-bleed landscape (≈16:9), the opening cosmic backdrop behind the "Genesis" title.
- **1 about image** — portrait-ish framed photo (roughly 3:4), sits inside a 2px black border beside the green section's heading.
- **3 fullscreen sticky images** — each fills a 100vw × 100vh layer with `object-fit:cover`, so use large landscape-or-square images (≈16:9). img-1 is the bottom backdrop; img-2 is the rectangle that irises open; img-3 is the top layer that opens from the seam and holds a deep zoom, so it benefits from an image with strong central detail that rewards the 3× crop.

## Behavior notes
- The page **hijacks scroll** via Lenis and pins a full section for 4 viewport-heights; it is a page-level, immersive experience (desktop-first but the `max-width:900px` query reflows it for mobile).
- Everything except the letter flicker is **fully scrubbed and reversible** — scrolling back plays every reveal, zoom and flip backwards.
- The 700vh body height, the pin length (`4×innerHeight`), and the absolute scroll markers (`3×`, `4×`, `4.5×`, `5.5×`, `6×` innerHeight) must line up — they're what sequence the beats; recompute them from `window.innerHeight` (the demo reads it at load).
- Light performance cost (no canvas/WebGL); the clip-path morphs are the heaviest part.
