# WorkingStiff Animated Teams Section — Scroll-Scrubbed Rising Columns + Sliding Team Cards

## Goal
Build a full-screen scroll section that introduces a 3-person team. As the team block scrolls into view, three tall columns **rise up from below the viewport in a stagger**, each revealing a **giant yellow name-initial** that scales up from nothing. Then the section **pins for three viewport heights** and, still scrubbed by scroll, three **white team cards slide in from the right one after another** — each rotating from 20° back to upright and scaling from 0.75 up to 1 — landing centered to **cover the initials** with a portrait + role + name. Framed by a yellow-on-black hero heading above and a yellow-on-black outro heading below. Smooth scroll via Lenis. The two star effects are the **staggered rise + initial pop** and the **pinned staggered card slide-in**. Both are driven by **manual per-element progress math inside `ScrollTrigger.onUpdate` (not GSAP timelines)**. Desktop-only; disabled below 1000px.

## Tech
Vanilla HTML/CSS/JS with ES module imports, 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";
```
Register once: `gsap.registerPlugin(ScrollTrigger);`. Run everything inside `document.addEventListener("DOMContentLoaded", …)`. No other plugins, no SplitText/CustomEase, no Three.js.

## Layout / HTML
Four full-viewport `<section>`s in order: `hero`, `team`, `outro`. The `team` section holds exactly **3** `.team-member` columns, each containing a centered **name-initial** layer and a centered **card** layer. Class names are load-bearing (JS/CSS query them).

**Fonts must be loaded via `<link>` in the `<head>` of the HTML — NOT via `@import` in the CSS.** In practice the CSS `@import` fails to apply reliably and the whole display type falls back to an ugly wide serif (Times), which also makes the names overflow the cards. Put these three tags in `<head>` (before the stylesheet link) so the browser fetches the fonts up front:
```html
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100..900;1,100..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap" />
  <!-- your own <title> and stylesheet link go here too -->
</head>
```
After load, **Barlow Condensed must be visibly applied to all display type** — the hero/outro headings, the giant initials, and the card names. Barlow Condensed reads as **narrow, tall, geometric, sans-serif** letterforms with tight horizontal proportions. If any of that display type renders in a **wide serif (Times-like) with serifs on the strokes, the fonts did not load and the result is wrong** — the condensed proportions are exactly what lets the big sizes fit.

```html
<section class="hero">
  <h1>Faces Behind the Frame</h1>
</section>

<section class="team">
  <div class="team-member">
    <div class="team-member-name-initial"><h1>C</h1></div>
    <div class="team-member-card">
      <div class="team-member-img"><img src="…" alt="" /></div>
      <div class="team-member-info">
        <p>( Creative Director )</p>
        <h1>Caspian <span>Merlow</span></h1>
      </div>
    </div>
  </div>

  <div class="team-member">
    <div class="team-member-name-initial"><h1>E</h1></div>
    <div class="team-member-card">
      <div class="team-member-img"><img src="…" alt="" /></div>
      <div class="team-member-info">
        <p>( Executive Producer )</p>
        <h1>Evander <span>Coren</span></h1>
      </div>
    </div>
  </div>

  <div class="team-member">
    <div class="team-member-name-initial"><h1>L</h1></div>
    <div class="team-member-card">
      <div class="team-member-img"><img src="…" alt="" /></div>
      <div class="team-member-info">
        <p>( Head of Production )</p>
        <h1>Leopold <span>Draven</span></h1>
      </div>
    </div>
  </div>
</section>

<section class="outro">
  <h1>Where Vision Becomes Work</h1>
</section>

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

Notes:
- Each member's initial (`C`, `E`, `L`) is the first letter of its first name.
- Each name splits into first name (yellow) + a `<span>` last name (rendered dark — see styling).
- Use this neutral demo copy verbatim. "WorkingStiff" is the fictional demo brand — no real client names. Hero: "Faces Behind the Frame". Outro: "Where Vision Becomes Work". Roles: "( Creative Director )", "( Executive Producer )", "( Head of Production )".

## Styling
Fonts (Google Fonts): **Barlow Condensed** (full weight axis 100–900, italics too) for everything, and **DM Mono** (weights 300/400/500) for the small role labels. **Load them via the `<link>` tags in `<head>` shown above — do NOT use `@import` in the CSS** (it is unreliable and leaves the display type in a fallback serif).

**Every `font-family` declaration MUST include a safe fallback stack that stays narrow and sans-serif — NEVER let it fall back to a serif.** Barlow Condensed is much narrower than a default serif, so a serif fallback both looks wrong and makes the big card names overflow the card horizontally. Use:
- for Barlow Condensed usages: `font-family: "Barlow Condensed", "Oswald", "Arial Narrow", sans-serif;`
- for DM Mono usages (the role labels): `font-family: "DM Mono", ui-monospace, monospace;`

Palette (CSS variables — exact hex):
```css
--base-100: #171717;  /* near-black — page/body background */
--base-200: #f2f5ea;  /* warm off-white/cream — the team card background */
--base-300: #fcf34c;  /* bright lemon yellow — hero/outro headings, giant initials, first names */
```

Global / reset:
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `body { font-family:"Barlow Condensed","Oswald","Arial Narrow",sans-serif; background-color:var(--base-100); }` — condensed sans fallback stack; never a serif.
- `img { width:100%; height:100%; object-fit:cover; }`
- `h1 { text-transform:uppercase; font-size:12rem; font-weight:800; line-height:0.8; }` — inherits the Barlow Condensed stack from `body`; must render condensed & sans, never a wide serif.
- `p { text-transform:uppercase; font-family:"DM Mono",ui-monospace,monospace; font-size:0.9rem; font-weight:500; line-height:1; }`

Sections & framing:
- `section { position:relative; width:100%; height:100svh; overflow:hidden; padding:1rem; }`
- `.hero, .outro { display:flex; justify-content:center; align-items:center; text-align:center; }`
- `.hero h1, .outro h1 { width:75%; color:var(--base-300); }` (giant 12rem yellow heading, centered).

Team row & columns:
- `.team { display:flex; gap:1rem; }` — 3 columns side by side, filling the `100svh` section.
- `.team-member { flex:1; position:relative; width:100%; height:100%; border:2px dashed rgba(242,245,234,0.35); border-radius:1.5rem; will-change:transform; }` — a dashed-outline placeholder column.
- **Stacking (critical for the cards covering each other correctly):** `.team-member:nth-child(1){ z-index:2; }` `:nth-child(2){ z-index:1; }` `:nth-child(3){ z-index:0; }` — first member paints on top.

Initial layer (the giant letter behind each card):
- `.team-member-name-initial { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }`
- `.team-member-name-initial h1 { color:var(--base-300); font-size:20rem; will-change:transform; }` — a huge yellow letter centered in the column.

Card layer (the white card that slides in over the initial):
- `.team-member-card { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:calc(100% + 4px); height:calc(100% + 4px); padding:2rem; display:flex; flex-direction:column; align-items:center; gap:2rem; background-color:var(--base-200); border-radius:1.5rem; will-change:transform; }` — cream card, slightly oversized (`+4px`) so it fully covers the dashed border, centered via `translate(-50%,-50%)`.
- `.team-member-img { aspect-ratio:1; border-radius:1rem; margin-bottom:1rem; overflow:hidden; }` — square portrait at the top of the card.
- `.team-member-info { display:flex; flex-direction:column; align-items:center; text-align:center; gap:1rem; }`
- `.team-member-info h1 { font-size:6.5rem; color:var(--base-300); }` — the name, yellow…
- `.team-member-info h1 span, .team-member-info p { color:var(--base-100); }` — …except the last-name `<span>` and the role label, which are near-black.
- **The two-line name (first name in yellow + last-name `<span>` in near-black) is centered and MUST fit inside the card without overflowing.** This only works because Barlow Condensed is narrow: at 6.5rem the longest last name ("MERLOW") sits comfortably within the card's padding. If the fonts fall back to a wide serif the names blow past both edges of the card — that is the failure to avoid. Keep the condensed-sans fallback stack so it fits even before the web font finishes loading.

### Initial (pre-animation) transform states — CRITICAL
These CSS transforms ARE the "from" states the JS scrubs out of. Set them exactly:
- `.team-member { transform: translateY(125%); }` — every column starts **fully below** the viewport.
- `.team-member-name-initial h1 { transform: scale(0); }` — every giant initial starts **invisible (scaled to 0)**.
- The three cards start **off to the right, shrunk and tilted**, staggered by horizontal offset:
  - `.team-member:nth-child(1) .team-member-card { transform: translate(300%, -50%) scale(0.75) rotate(20deg); }`
  - `.team-member:nth-child(2) .team-member-card { transform: translate(200%, -50%) scale(0.75) rotate(20deg); }`
  - `.team-member:nth-child(3) .team-member-card { transform: translate(100%, -50%) scale(0.75) rotate(20deg); }`
  - i.e. X offsets of **300% / 200% / 100%**, all at `scale(0.75)` and `rotate(20deg)`, vertically centered at `-50%`. Their resting (animated-to) position is `translate(-50%, -50%) scale(1) rotate(0)`.

## 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);
```

### Element grab
```js
const teamSection    = document.querySelector(".team");
const teamMembers    = gsap.utils.toArray(".team-member");        // 3 columns
const teamMemberCards = gsap.utils.toArray(".team-member-card");  // 3 cards
```
Keep two module-scope handles so they can be killed/rebuilt on resize: `let cardPlaceholderEntrance = null;` and `let cardSlideInAnimation = null;`.

Everything below lives in an `initTeamAnimations()` function (called once at start, and again — after killing the old triggers — on debounced resize). **Both ScrollTriggers use `scrub:1` and drive elements exclusively through manual `gsap.set` calls inside `onUpdate` using `self.progress`; there are no tweens or timelines.** The per-element easing is purely the linear progress math below (plus scrub's 1s smoothing).

### ScrollTrigger #1 — staggered column rise + initial pop-in (`cardPlaceholderEntrance`)
```js
cardPlaceholderEntrance = ScrollTrigger.create({
  trigger: teamSection,
  start: "top bottom",   // begins when the team section's top hits the viewport bottom
  end:   "top top",      // ends when the team section's top reaches the viewport top
  scrub: 1,
  onUpdate: (self) => {
    const progress = self.progress; // 0 → 1 across that entrance window
    teamMembers.forEach((member, index) => {
      const entranceDelay    = 0.15;                 // per-column stagger
      const entranceDuration = 0.7;                  // each column's share of progress
      const entranceStart    = index * entranceDelay; // 0.00 / 0.15 / 0.30
      const entranceEnd      = entranceStart + entranceDuration; // 0.70 / 0.85 / 1.00

      if (progress >= entranceStart && progress <= entranceEnd) {
        const memberEntranceProgress = (progress - entranceStart) / entranceDuration; // 0→1
        // rise from translateY 125% up to 0%
        const entranceY = 125 - memberEntranceProgress * 125;
        gsap.set(member, { y: `${entranceY}%` });
        // giant initial scales 0→1, but only during the LAST 60% of this column's rise
        const initial = member.querySelector(".team-member-name-initial h1");
        const initialLetterScaleDelay = 0.4;
        const initialLetterScaleProgress = Math.max(
          0,
          (memberEntranceProgress - initialLetterScaleDelay) / (1 - initialLetterScaleDelay)
        );
        gsap.set(initial, { scale: initialLetterScaleProgress });
      } else if (progress > entranceEnd) {
        gsap.set(member, { y: `0%` });
        gsap.set(member.querySelector(".team-member-name-initial h1"), { scale: 1 });
      }
    });
  },
});
```
Effect: as you scroll the team block up into view, column 1 rises first, then column 2 (0.15 later), then column 3 (0.30 later), each translating `y:125% → 0%` over a 0.7-progress span. Each column's giant initial stays at `scale:0` for the first 40% of that column's rise, then scales `0 → 1` over the remaining 60%, so the letter "pops" in just as the column finishes settling.

### ScrollTrigger #2 — pinned, staggered card slide-in (`cardSlideInAnimation`)
```js
cardSlideInAnimation = ScrollTrigger.create({
  trigger: teamSection,
  start: "top top",
  end: `+=${window.innerHeight * 3}`, // pinned across 3 viewport heights of scroll
  pin: true,
  scrub: 1,
  onUpdate: (self) => {
    const progress = self.progress; // 0 → 1 across the 3vh pin
    teamMemberCards.forEach((card, index) => {
      // --- Phase A: slide X + un-rotate ---
      const slideInStagger  = 0.075;
      const xRotationDuration = 0.4;
      const xRotationStart = index * slideInStagger;      // 0.000 / 0.075 / 0.150
      const xRotationEnd   = xRotationStart + xRotationDuration; // 0.400 / 0.475 / 0.550

      if (progress >= xRotationStart && progress <= xRotationEnd) {
        const cardProgress = (progress - xRotationStart) / xRotationDuration; // 0→1
        const cardInitialX = 300 - index * 100;  // 300 / 200 / 100  (matches CSS start X)
        const cardTargetX  = -50;                 // centered resting X
        const cardSlideInX = cardInitialX + cardProgress * (cardTargetX - cardInitialX);
        const cardSlideInRotation = 20 - cardProgress * 20; // 20° → 0°
        gsap.set(card, { x: `${cardSlideInX}%`, rotation: cardSlideInRotation });
      } else if (progress > xRotationEnd) {
        gsap.set(card, { x: `-50%`, rotation: 0 });
      }

      // --- Phase B: scale 0.75 → 1 (independent stagger + window) ---
      const cardScaleStagger = 0.12;
      const cardScaleStart = 0.4 + index * cardScaleStagger; // 0.40 / 0.52 / 0.64
      const cardScaleEnd   = 1;

      if (progress >= cardScaleStart && progress <= cardScaleEnd) {
        const scaleProgress = (progress - cardScaleStart) / (cardScaleEnd - cardScaleStart);
        const scaleValue = 0.75 + scaleProgress * 0.25; // 0.75 → 1
        gsap.set(card, { scale: scaleValue });
      } else if (progress > cardScaleEnd) {
        gsap.set(card, { scale: 1 });
      }
    });
  },
});
```
Effect: the moment the team section top hits the viewport top, it **pins**. Across the next 3 viewport heights of scroll, each card runs two overlapping phases:
- **Phase A (X + rotation):** card slides horizontally from its CSS start X (`300% / 200% / 100%`) to the centered `-50%`, while its rotation eases from `20°` back to `0°`. Staggered start `0 / 0.075 / 0.15`, each lasting `0.4` of progress. Because GSAP only writes `x` and `rotation` (leaving `y` at the CSS `-50%` and scale untouched here), the cards travel in from the right and straighten.
- **Phase B (scale):** independently, each card scales `0.75 → 1`, starting later (`0.40 / 0.52 / 0.64`) and all finishing exactly at progress `1`. So a card first slides/straightens, then keeps growing to full size as the pin completes.

Combined with the `z-index` stacking (member 1 on top), the three cream cards land centered over the giant yellow initials, hiding them and revealing the portrait + role + name. The whole thing is scroll-scrubbed — scrolling up reverses it.

### Resize handling
Debounced 250ms resize: re-run `initTeamAnimations()` then `ScrollTrigger.refresh()`.
```js
let resizeTimer;
window.addEventListener("resize", () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => { initTeamAnimations(); ScrollTrigger.refresh(); }, 250);
});
initTeamAnimations();
```
At the top of `initTeamAnimations()`, always kill any existing `cardPlaceholderEntrance` / `cardSlideInAnimation` before rebuilding.

## Assets / images
Three **editorial fashion portraits**, one per team card. The source files are tall **4:5 portrait** JPEGs but they are displayed **square** inside `.team-member-img` (`aspect-ratio:1`, `object-fit:cover`), so each is cropped to a head-and-shoulders square. All three are shot in the same high-end, softly-lit studio style with saturated color blocking, so they read as one cohesive campaign. Actual subjects:
1. Creative Director — young man facing camera, dark brown curly/tousled hair, wearing an **olive/moss-green blazer** over a cream shirt with a matching green tie. Backdrop is a **painterly abstract wash** in warm peach/terracotta orange with cool teal accents.
2. Executive Producer — androgynous model facing camera with a short dark shag haircut and blunt bangs, wearing an open **bright crimson-red blazer** over bare chest with layered gold necklaces. Backdrop is a flat, richly saturated **teal/petrol-blue** studio wall.
3. Head of Production — woman in **side profile**, dark hair pulled back into a low bun with a gold hoop earring, wearing a sleeveless top in a **multicolor abstract brushstroke print** (blues, reds, yellows, cream). Backdrop is a flat, vivid **orange** studio wall.

The dominant palette across the set is warm oranges/reds against cool teals, tying into the yellow/black/cream section colors. No brands or logos. If you have fewer than three, repeat.

## Behavior notes
- **Desktop-only.** The whole effect runs **only when `window.innerWidth >= 1000`**. At the very top of `initTeamAnimations()`, if `window.innerWidth < 1000`: kill both ScrollTriggers, then `gsap.set(..., { clearProps: "all" })` on every `.team-member`, every `.team-member-name-initial h1`, and every `.team-member-card`, and `return` (no animation).
- **Mobile CSS fallback** (`@media (max-width:1000px)`): `h1 { font-size:4rem; }`; `.hero h1, .outro h1 { width:100%; }`; `.team { height:250svh; flex-direction:column; align-items:center; }`; `.team-member { max-width:400px; transform:translateY(0%) !important; }`; `.team-member-name-initial h1 { transform:scale(1); }`; `.team-member .team-member-card { transform:translate(-50%,-50%) scale(1) rotate(0deg) !important; }`; `.team-member-info h1 { font-size:5rem; }`. So on mobile everything sits in its final resting state, stacked vertically, no scroll animation.
- Heights use `svh` so mobile browser chrome doesn't break the full-screen layout.
- Both ScrollTriggers are **scrubbed (`scrub:1`)** — nothing autoplays; the sequence plays forward on scroll-down and reverses on scroll-up. `scrub:1` adds ~1s of catch-up smoothing.
- No reduced-motion guard in the original.
- Keep the `will-change:transform` hints on `.team-member`, the initial `h1`, and `.team-member-card`.
