# BarbaJS Page Transitions — White Curtain Wipe + Masked Heading Reveal Between Routes

## Goal
Build a minimal dark portfolio shell with three "pages" (**Home / About / Contact**) and a top nav. The star effect is the **page transition** fired by clicking a nav link: a full-screen **white panel wipes up from the bottom** to blanket the viewport (`scaleY` grow from `transform-origin: bottom`), the route content is swapped underneath while it's hidden, then the same panel **wipes off the top** (`scaleY` shrink from `transform-origin: top`). Concurrently, the incoming page's giant `<h1>` **slides up from behind a solid mask** (a classic masked text-reveal — the heading sits pushed below its window and lifts into view). The whole thing reproduces Barba.js's `sync: true` lifecycle (leave + enter run together, old container dropped mid-transition) with a tiny hand-rolled in-page router — no real multi-page fetch, no framework.

## Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
- **`gsap`** (3.x) only. **No GSAP plugins, no ScrollTrigger, no SplitText, no Lenis, no Three.js.**

```js
import gsap from "gsap";
```
No `gsap.registerPlugin(...)` — nothing to register. The original site used `@barba/core`; here you **emulate** Barba's sync lifecycle in ~40 lines of plain JS (details in the GSAP/router section). Single entry: one `<script type="module" src="./script.js">`.

## Layout / HTML
One `.transition` curtain overlay, a `.wrapper` holding the `.nav`, and a single `data-barba="container"` `<main>` that the router rebuilds on each navigation. The `data-barba-*` attributes and class names are load-bearing.

```html
<body data-barba="wrapper">
  <div class="transition"></div>
  <div class="wrapper">
    <div class="nav">
      <a href="index.html">Home</a>
      <a href="about.html">About</a>
      <a href="contact.html">Contact</a>
    </div>

    <main data-barba="container" data-barba-namespace="home" class="container">
      <div class="header">
        <h1>Homebase</h1>
        <div class="header-revealer"></div>
      </div>
      <div class="footer">(01)</div>
    </main>
  </div>
  <script type="module" src="./script.js"></script>
</body>
```

Notes on structure:
- The three routes share the same container markup; only the `<h1>` text and the `.footer` number differ. Map: **home → "Homebase" / "(01)"**, **about → "About Us" / "(02)"**, **contact → "Contact" / "(03)"**. The `href`s (`index.html` / `about.html` / `contact.html`) are used only as keys the router maps to a namespace; clicks are intercepted (`preventDefault`) — nothing actually navigates.
- `.header` wraps **both** the `<h1>` and an empty `.header-revealer` div. The revealer's `::after` pseudo-element is the dark mask panel that hides the heading (see Styling — this is the reveal mechanism).
- Labels are neutral demo text (Home / About / Contact, "Homebase"). No real brands.

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`. `html, body { width:100%; height:100%; }`.

**Font:** `font-family: "Neue Montreal", sans-serif;` on `body`. There is **no** `@font-face`/`@import` in the source — any clean neutral sans fallback (Inter / Helvetica) is fine.

**Palette (exact hex):**
```
#0f0f0f  near-black — body background (and the reveal mask color — must match the bg)
#ffffff  pure white — the .transition curtain panel
#ffffff  white      — nav links and the h1 text
#5f5f5f  mid grey   — the footer number
rgba(255,255,255,0.1)  faint white — the 1px nav bottom border
```

**Load-bearing CSS (get these exact — the curtain + mask depend on them):**
- `body { height:100vh; background:#0f0f0f; }`. `.wrapper { width:100%; height:100%; }`.
- `.nav { width:100%; display:flex; gap:2em; padding:2em 2.5em; border-bottom:1px solid rgba(255,255,255,0.1); }`. `a { text-decoration:none; color:#fff; }`.
- `.footer { position:absolute; bottom:0; padding:4em 2em; color:#5f5f5f; }`.
- `.container { padding:2em; }`.
- **The curtain — critical:**
  ```css
  .transition {
    position: absolute;
    z-index: 2;               /* above the content */
    width: 100%;
    height: 100vh;
    top: 0; left: 0;
    pointer-events: none;     /* never blocks clicks */
    background: #ffffff;
    transform: scaleY(0);     /* rest state: collapsed to nothing */
  }
  ```
- **The masked-heading reveal — critical geometry:**
  ```css
  .header {
    position: relative;
    width: max-content;
    height: max-content;      /* box height ≈ one line of h1 = 120px */
  }
  h1 {
    position: relative;
    line-height: 1;
    font-size: 120px;
    font-weight: 400;
    color: #fff;
    top: 120px;               /* heading pushed DOWN one full line, out of its window */
  }
  .header-revealer {
    position: absolute;
    top: 0;
    width: 100%;
    height: 100%;             /* same box as .header (the h1's "window") */
  }
  .header-revealer:after {
    content: "";
    position: absolute;
    top: 120px;               /* starts exactly where the shifted h1 sits */
    left: 0;
    width: 110%;
    height: 110%;
    background: #0f0f0f;      /* SAME as body bg → invisibly paints the h1 out */
  }
  ```
  How it works: the `<h1>` is `position:relative; top:120px`, so it renders one full line-height **below** its layout box (the `.header` window). The `.header-revealer` sits over the header at `top:0`; its `::after` is a solid `#0f0f0f` rectangle placed at `top:120px` (right on top of the shifted heading) that covers it completely. Because `.header-revealer` comes **after** `<h1>` in the DOM (both positioned), it paints on top and masks the text. Animating the `h1`'s `top` from `120px → 0` lifts the heading up out from behind the mask into the clear window — a masked reveal, no `overflow:hidden` and no SplitText needed.

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

Everything is two tiny GSAP timelines plus a fake router. **No plugins, no ScrollTrigger, no SplitText, no CustomEase, no lerp/rAF loop.**

### 1. `pageTransition()` — the white curtain (two phases)
```js
function pageTransition() {
  const tl = gsap.timeline();

  tl.to(".transition", {
    duration: 1,
    scaleY: 1,
    transformOrigin: "bottom",
    ease: "power4.inOut",
  });

  tl.to(".transition", {
    duration: 1,
    scaleY: 0,
    transformOrigin: "top",
    ease: "power4.inOut",
    delay: 0.2,
  });
}
```
- **Phase A (cover):** `.transition` `scaleY 0 → 1` with `transformOrigin:"bottom"` → the white panel grows **upward from the bottom edge** and fills the viewport. `duration:1`, `ease:"power4.inOut"`.
- **Phase B (uncover):** `scaleY 1 → 0` with `transformOrigin:"top"` → the panel collapses **toward the top edge** (wipes off upward). `duration:1`, `ease:"power4.inOut"`, `delay:0.2`.
- These are appended sequentially on the same timeline, so Phase B starts at `t = 1 + 0.2 = 1.2s`. Total curtain span = `1 + 0.2 + 1 = 2.2s`. Both phases share the sharp `power4.inOut` curve.

### 2. `contentAnimation()` — the masked heading reveal
```js
function contentAnimation() {
  const tl = gsap.timeline();
  tl.to("h1", {
    top: 0,
    duration: 1,
    ease: "power3.inOut",
    delay: 0.75,
  });
}
```
- Animates the CSS `top` of **every** `<h1>` on the page from its resting `120px → 0`, sliding the heading up out from behind the dark mask. `duration:1`, `ease:"power3.inOut"`, `delay:0.75`.
- The selector is the **global** `"h1"` (not scoped). This is intentional Barba-sync behavior: during a transition the outgoing and incoming containers **both** exist in the DOM, so `"h1"` matches both — the incoming (fresh, at `top:120px`) reveals; the outgoing (already at `top:0` from its own load) re-tweens to `0` harmlessly before it's removed.

### 3. `delay(n)` helper
```js
function delay(n = 0) {
  return new Promise((done) => setTimeout(done, n));
}
```

### 4. The fake router (emulates Barba `sync: true`)
The original was a multi-page Barba.js site that fetched sibling HTML and swapped `[data-barba="container"]`. Reproduce that lifecycle in-page: keep a map of page data, rebuild the container in JS, and run `leave` + `enter` **concurrently**, dropping the outgoing container partway through.

```js
const pages = {
  home:    { title: "Homebase", footer: "(01)" },
  about:   { title: "About Us", footer: "(02)" },
  contact: { title: "Contact",  footer: "(03)" },
};
const hrefToNamespace = {
  "index.html": "home",
  "about.html": "about",
  "contact.html": "contact",
};

let current = document.querySelector('[data-barba="container"]');
let currentNamespace = current.dataset.barbaNamespace;
let isTransitioning = false;

function buildContainer(namespace) {
  const page = pages[namespace];
  const main = document.createElement("main");
  main.className = "container";
  main.setAttribute("data-barba", "container");
  main.setAttribute("data-barba-namespace", namespace);
  main.innerHTML = `
        <div class="header">
          <h1>${page.title}</h1>
          <div class="header-revealer"></div>
        </div>
        <div class="footer">${page.footer}</div>`;
  return main;
}

async function navigate(namespace) {
  if (isTransitioning || namespace === currentNamespace) return;  // guard: mid-transition or no-op click
  isTransitioning = true;

  // Barba appends the incoming container next to the current one, so BOTH
  // exist during a sync transition (the global "h1" selector matches both).
  const next = buildContainer(namespace);
  current.after(next);

  // leave() and enter() run at the same time, exactly like Barba sync:true.
  const leave = (async () => {
    pageTransition();     // curtain (2.2s), but we only await the first second
    await delay(1000);
  })();

  contentAnimation();     // enter: reveal the incoming h1

  await leave;

  // afterLeave: drop the outgoing container once the curtain fully covers.
  current.remove();
  current = next;
  currentNamespace = namespace;
  isTransitioning = false;
}

document.querySelectorAll(".nav a").forEach((link) => {
  link.addEventListener("click", (event) => {
    event.preventDefault();
    const namespace = hrefToNamespace[link.getAttribute("href")];
    if (namespace) navigate(namespace);
  });
});

// Barba `once`: run the reveal on the very first load.
contentAnimation();
```

**Timeline of one navigation (t in seconds):**
- `t=0` — `pageTransition()` starts (curtain covering, Phase A) **and** `contentAnimation()` starts (h1 tween is armed with its `0.75s` delay).
- `t=0.75 → 1.75` — incoming `<h1>` slides `top 120→0` (masked reveal), `power3.inOut`.
- `t=1.0` — `await delay(1000)` resolves: the **outgoing container is removed** (this is the moment the white curtain has fully covered the screen, so the swap is invisible). Guard released.
- `t=1.2 → 2.2` — curtain Phase B wipes off the top, uncovering the freshly-revealed new page.

So the swap happens hidden behind the curtain at its peak coverage, and the new heading finishes rising just as the curtain clears. On **first page load** there is no curtain — only `contentAnimation()` runs, revealing "Homebase" (delay `0.75s`, over `1s`).

## Assets / images
**None.** The entire component is CSS type + one solid-color curtain panel + one solid-color mask. Do not add any images.

## Behavior notes
- **Trigger:** click on a nav link only. No scroll, no hover, no autoplay, no infinite loops. Clicking the current page is a no-op; clicks during a transition are ignored (`isTransitioning` guard).
- The curtain is `pointer-events:none` and rests at `scaleY(0)`, so it never obstructs the page between transitions.
- The reveal mask color (`#0f0f0f`) **must** equal the body background, or the trick shows a seam. Same for the `120px` values on `h1 { top }`, `.header-revealer:after { top }`, and the heading `font-size` — they're coupled (one line-height of vertical travel).
- Works on desktop and mobile; no `prefers-reduced-motion` branch and no min-width gate in the source.
