# Adaline-Style Scroll Animation (Canvas Image-Sequence Hero)

## Goal
Build a cinematic landing hero where a **207-frame image sequence plays back frame-by-frame on a full-viewport `<canvas>`, driven entirely by scroll**. The hero section is **pinned for 7 viewport-heights** of scrolling; as the user scrolls, a single ScrollTrigger's `onUpdate` maps progress to (1) the current video frame drawn on canvas, (2) a fade-out of the fixed nav, (3) the headline block being **pushed away on the Z axis** (`translateZ` 0 → −500px) while fading, and (4) a product-dashboard mockup that **flies in from deep 3D space** (`translateZ(1000px)` → `0`) and lands centered as the sequence ends. A plain outro section follows. Scroll is smoothed with Lenis.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** with the **`ScrollTrigger`** plugin, plus **`lenis`** for smooth scrolling:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
```
Everything runs inside a `DOMContentLoaded` listener. Register ScrollTrigger, then wire Lenis the standard way:
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
```
No timelines and no tweens — the whole effect is **one `ScrollTrigger.create()` whose `onUpdate` writes absolute states with `gsap.set`** plus manual canvas drawing.

## Layout / HTML
```html
<body>
  <nav>
    <div class="nav-links">
      <a href="#">Overview</a><a href="#">Solutions</a><a href="#">Resources</a>
    </div>
    <div class="logo"><a href="#"><img src="(logo)" alt=""> Byewind</a></div>
    <div class="nav-buttons">
      <div class="btn primary"><a href="#">Live Demo</a></div>
      <div class="btn secondary"><a href="#">Get Started</a></div>
    </div>
  </nav>

  <section class="hero">
    <canvas></canvas>
    <div class="hero-content">
      <div class="header">
        <h1>One unified workspace to build, test, and ship AI faster</h1>
        <p>Trusted by</p>
        <div class="client-logos">
          <div class="client-logo"><img src="(client-logo-1)" alt=""></div>
          <div class="client-logo"><img src="(client-logo-2)" alt=""></div>
          <div class="client-logo"><img src="(client-logo-3)" alt=""></div>
          <div class="client-logo"><img src="(client-logo-4)" alt=""></div>
        </div>
      </div>
    </div>
    <div class="hero-img-container">
      <div class="hero-img"><img src="(dashboard)" alt=""></div>
    </div>
  </section>

  <section class="outro">
    <h1>Join teams building faster with Byewind.</h1>
  </section>
  <script type="module" src="./script.js"></script>
</body>
```
The class names `.hero`, `.header`, `.hero-img`, plus the bare `nav` and `canvas` elements are what the JS queries — keep them exact.

## Styling
Google Fonts: **"DM Mono"** (300/400/500 + italics) and **"Host Grotesk"** (variable 300–800). Palette via CSS variables: `--fg: #241910` (near-black warm brown), `--bg: #fefbf4` (warm off-white cream).

- Global reset `* { margin:0; padding:0; box-sizing:border-box; }`. `body { font-family:"DM Mono", monospace; }`. `img { width:100%; height:100%; object-fit:cover; }`.
- `h1 { font-family:"Host Grotesk"; font-size:3rem; font-weight:400; line-height:1.1; }`.
- `p` and `a`: `text-transform:uppercase; font-size:0.8rem; font-weight:500;` — links `color:var(--fg); text-decoration:none;`.
- `.btn { padding:0.75rem 1.5rem; border-radius:0.25rem; }` — `.primary` has `background:var(--bg)` with `--fg` text; `.secondary` is inverted (`background:var(--fg)`, `--bg` text).
- `nav`: `position:fixed; width:100vw; padding:1.5rem 2rem; display:flex; align-items:center; gap:2rem; z-index:2; will-change:opacity;` — each of its three direct children gets `flex:1` so the logo sits dead-center. `.nav-links { display:flex; gap:3rem; }`; `.logo` centers its content, logo img `width:2rem`, logo text is Host Grotesk `1.5rem` with `text-transform:none`; `.nav-buttons { display:flex; gap:1.5rem; justify-content:flex-end; }`.
- Every `section`: `position:relative; width:100vw; height:100svh; overflow:hidden;`.
- `.outro`: flex-centered text, `padding:2rem`, `background:var(--bg)`, `color:var(--fg)`.
- `canvas { width:100%; height:100%; object-fit:cover; }` — fills the hero.
- `.hero-content`: `position:absolute; top:25%; left:50%; transform:translateX(-50%); transform-style:preserve-3d; perspective:1000px; padding:0.5rem 0;` — **this `perspective` is the camera for the headline's Z push; do not omit it.**
- `.header`: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100vw;` flex column, centered, `gap:1.5rem; text-align:center; color:var(--fg); transform-origin:center; will-change:transform,opacity;`. Its `h1` is `width:50%; margin-bottom:0.5rem;`; its `p` has `opacity:0.35`.
- `.client-logos`: `width:30%; display:flex; gap:0.5rem;` — each `.client-logo` is `flex:1`, its img `object-fit:contain`.
- `.hero-img-container`: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; transform-style:preserve-3d; perspective:1000px;` — the camera for the mockup fly-in.
- `.hero-img`: `position:relative; width:100%; height:100%;` with **initial state in CSS: `transform:translateZ(1000px); opacity:0;`** and `will-change:transform,opacity;` — it must start invisible and deep behind the camera even before JS runs.
- `@media (max-width:1000px)`: `h1 { font-size:2rem; }`, hide `.nav-links` and `.nav-buttons`, and give `.header h1`, `.client-logos`, `.hero-img-container` a width of `calc(100% - 4rem)`.

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

### 1. Hi-DPI canvas setup
```js
const canvas = document.querySelector("canvas");
const context = canvas.getContext("2d");
const setCanvasSize = () => {
  const pixelRatio = window.devicePixelRatio || 1;
  canvas.width = window.innerWidth * pixelRatio;
  canvas.height = window.innerHeight * pixelRatio;
  canvas.style.width = window.innerWidth + "px";
  canvas.style.height = window.innerHeight + "px";
  context.scale(pixelRatio, pixelRatio);
};
setCanvasSize();
```

### 2. Frame preloading (gate the ScrollTrigger on it)
- `const frameCount = 207;` frames live in a `frames/` folder named `frame_0001.jpg` … `frame_0207.jpg` (index + 1, zero-padded to 4 digits).
- Create all 207 `Image` objects up front, push them into an `images` array, and count them down with a shared handler bound to **both `onload` and `onerror`**. When the counter hits zero, call `render()` once and only then create the ScrollTrigger — **no scroll animation exists until every frame has settled**.
- Keep the current frame in an object: `let videoFrames = { frame: 0 };`.

### 3. `render()` — manual cover-fit draw
Each call clears the canvas (`clearRect` over `innerWidth × innerHeight` — CSS pixels, since the context is scaled) and draws `images[videoFrames.frame]` (only if `img.complete && img.naturalWidth > 0`) with **aspect-ratio cover math**:
- `imageAspect = naturalWidth / naturalHeight`, `canvasAspect = innerWidth / innerHeight`.
- If `imageAspect > canvasAspect`: `drawHeight = canvasHeight; drawWidth = drawHeight * imageAspect; drawX = (canvasWidth - drawWidth) / 2; drawY = 0;`
- Else: `drawWidth = canvasWidth; drawHeight = drawWidth / imageAspect; drawX = 0; drawY = (canvasHeight - drawHeight) / 2;`
- `context.drawImage(img, drawX, drawY, drawWidth, drawHeight)`.

### 4. The single ScrollTrigger
```js
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+=${window.innerHeight * 7}px`,  // 7 viewport-heights of pinned scroll
  pin: true,
  pinSpacing: true,
  scrub: 1,                               // 1s catch-up smoothing
  onUpdate: (self) => { /* everything below */ },
});
```
All animation lives in `onUpdate`, reading `progress = self.progress` (0 → 1 across the whole pinned distance) and writing **absolute states with `gsap.set`** — there are no eases or durations anywhere; every ramp is a linear function of progress, and the softness comes from `scrub: 1` + Lenis inertia.

**Phase A — video scrub (0 → 0.9):**
```js
const animationProgress = Math.min(progress / 0.9, 1);
const targetFrame = Math.round(animationProgress * (frameCount - 1));
videoFrames.frame = targetFrame;
render();
```
The full 207-frame sequence completes at 90% of the pin; the last 10% of scroll holds the final frame.

**Phase B — nav fade (0 → 0.1):** while `progress <= 0.1`, `opacity = 1 - progress / 0.1` (linear 1 → 0 across the first tenth); past 0.1, hard-set `gsap.set(nav, { opacity: 0 })`.

**Phase C — headline Z push + fade (0 → 0.25):** while `progress <= 0.25`:
- `translateZ = (progress / 0.25) * -500` — the header recedes linearly from 0 to **−500px**.
- Opacity stays `1` until `progress = 0.2`, then fades linearly 1 → 0 over **0.2 → 0.25** (`opacity = 1 - (progress - 0.2) / 0.05`, clamped).
- Apply via a full transform string so the centering survives: `gsap.set(header, { transform: \`translate(-50%, -50%) translateZ(${translateZ}px)\`, opacity })`.
- Past 0.25, just `gsap.set(header, { opacity: 0 })`.

**Phase D — dashboard fly-in (0.6 → 0.9):** three branches on `.hero-img`:
- `progress < 0.6`: hold the rest state `transform: "translateZ(1000px)", opacity: 0`.
- `0.6 ≤ progress ≤ 0.9`: `imgProgress = (progress - 0.6) / 0.3`; `translateZ = 1000 - imgProgress * 1000` (1000px → 0, linear). Opacity ramps 0 → 1 **faster than the Z travel**: `opacity = (progress - 0.6) / 0.2` while `progress ≤ 0.8`, then `1` from 0.8 to 0.9 (so the mockup is fully opaque for the last third of its flight). Set `transform: \`translateZ(${translateZ}px)\`` and the opacity.
- `progress > 0.9`: lock `transform: "translateZ(0px)", opacity: 1`.

Because the parent containers carry `perspective: 1000px`, the mockup starting at `translateZ(1000px)` sits exactly at the camera plane — it materializes huge/at-lens and settles back to its natural 50%-width size, a "flying in from your face" landing.

### 5. Resize
```js
window.addEventListener("resize", () => { setCanvasSize(); render(); ScrollTrigger.refresh(); });
```

## Assets / images
- **207-frame JPG image sequence, 16:9 landscape (e.g. 3840×2160)** in `frames/frame_0001.jpg` … `frame_0207.jpg` — consecutive frames of one slow, continuous cinematic camera move (the original is a gentle aerial drift over rolling desert sand dunes under a pale hazy sky: warm sand tones that harmonize with the cream/brown palette). Any smooth ambient footage exported as sequential JPGs works; the key is that adjacent frames differ only slightly so the scrub reads as video.
- **1 product-dashboard screenshot, landscape ≈3:2** — a clean SaaS analytics/dashboard UI mockup; it is the element that flies in from deep Z.
- **1 small square logo mark** (~2rem display size) for the nav brand.
- **4 monochrome client wordmark logos, wide landscape, transparent background** — the "Trusted by" row, each `object-fit:contain` in an equal flex column.

Use the neutral brand name "Byewind" — no real company names.

## Behavior notes
- Total scroll distance for the hero is `7 × innerHeight` of pin spacing, then the outro section scrolls in normally.
- The nav is `position:fixed` and only reappears if the user scrolls back up (its opacity is progress-driven, fully reversible — as is everything, since the whole effect is scrub-based `gsap.set` writes).
- Frames only advance after all 207 images finish loading; before that the canvas is blank and the ScrollTrigger doesn't exist yet.
- `scrub: 1` plus Lenis gives the floaty, damped feel — don't use `scrub: true`.
- Mobile (≤1000px): nav links/buttons hidden, headline/logos/mockup widen to `calc(100% - 4rem)`; the effect itself runs unchanged. Sections use `100svh` so mobile browser chrome doesn't clip.
- No SplitText, no CustomEase, no Three.js, no reduced-motion branch in the original.
