# Minimal Push Overlay Menu

## Goal
Build a dark, minimal one-page site with a fixed menu bar whose star effect is a **fullscreen "push-over" overlay menu**: clicking the circular hamburger toggle runs a single GSAP timeline (custom "hop" ease) that simultaneously **pushes the entire page content down by 100svh**, **wipes the dark overlay open top-to-bottom via an animated `clip-path` polygon**, slides the overlay's inner content up from `-50%` to `0`, fades in a side media image, and reveals every menu link/tag/footer line with **SplitText line-masked reveals in reverse stagger**. Clicking again plays the mirrored closing timeline and the page slides back up.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins **`CustomEase`** and **`SplitText`**, and **`lenis`** for smooth scrolling. All logic in one `script.js` (`<script type="module">`), one `styles.css`, one `index.html`. Register plugins with `gsap.registerPlugin(CustomEase, SplitText)` and run everything inside a `DOMContentLoaded` listener.

## Layout / HTML
```html
<nav>
  <div class="menu-bar">
    <div class="menu-logo"><a href="#"><img src="<logo>" alt="" /></a></div>
    <div class="menu-toggle-btn">
      <div class="menu-toggle-label"><p>Menu</p></div>
      <div class="menu-hamburger-icon"><span></span><span></span></div>
    </div>
  </div>
  <div class="menu-overlay">
    <div class="menu-overlay-content">
      <div class="menu-media-wrapper"><img src="<menu image>" alt="" /></div>
      <div class="menu-content-wrapper">
        <div class="menu-content-main">
          <div class="menu-col">
            <!-- 5 large links, each: <div class="menu-link"><a href="#">…</a></div> -->
            <!-- Index · Portfolio · Studio · Journal · Connect -->
          </div>
          <div class="menu-col">
            <!-- 3 small tags, each: <div class="menu-tag"><a href="#">…</a></div> -->
            <!-- Web Animations · Interactive Media · Motion Craft -->
          </div>
        </div>
        <div class="menu-footer">
          <div class="menu-col"><p>Toronto, Canada</p></div>
          <div class="menu-col"><p>+1 437 555 0199</p><p>hello@nullspace.studio</p></div>
        </div>
      </div>
    </div>
  </div>
</nav>

<div class="container">
  <section class="hero"><h1>Modern design system made that looks timeless</h1></section>
  <section class="banner"><img src="<banner image>" alt="" /></section>
  <section class="outro"><h1>Let’s build something quietly iconic</h1></section>
</div>
```

## Styling

**Palette (CSS variables)**
- `--bg: #171717` (page background), `--fg: #fff`, `--menu-bg: #0f0f0f` (overlay background), `--menu-fg-secondary: #5f5f5f` (muted grey for tags/footer/menu-bar), `--hamburger-icon-border: rgba(255, 255, 255, 0.1)`.

**Typography**
- `body { font-family: "PP Neue Montreal", "Inter", sans-serif; }` — import PP Neue Montreal from `https://fonts.cdnfonts.com/css/pp-neue-montreal` and Inter from Google Fonts. Everything is weight 500.
- `h1`: `7.5rem`, `letter-spacing: -0.2rem`, `line-height: 1`.
- `p`: `0.95rem`. Base `a`: `1.5rem`, `color: var(--fg)`, no underline.
- `.menu-link a`: `3.5rem`, `line-height: 1.2`. `.menu-tag a` and `.menu-footer p`: color `var(--menu-fg-secondary)`.
- Global reset (`* { margin:0; padding:0; box-sizing:border-box; }`), `img { width:100%; height:100%; object-fit: cover; }`.

**Page**
- `.container`: `position: relative; transform: translateY(0svh); background: var(--bg); color: var(--fg);` — the initial `translateY(0svh)` matters because GSAP pushes it to `100svh`.
- `section`: `position: relative; width: 100vw; height: 100svh; padding: 2rem; display:flex; justify-content:center; align-items:center; overflow:hidden; z-index: -1;` (the negative z-index keeps sections under the fixed nav). `section h1 { width: 75%; }`, `section img { opacity: 0.5; }`.

**Fixed nav shell**
- `nav`: `position: fixed; inset-top-left; width:100vw; height:100svh; pointer-events: none; overflow: hidden; z-index: 2;` — the nav itself is click-through; only its children re-enable pointer events.
- `.menu-bar`: `position: fixed; top:0; left:0; width:100vw; padding:2rem; display:flex; justify-content: space-between; align-items:center; pointer-events: all; color: var(--menu-fg-secondary); z-index: 2;`
- `.menu-logo`: `2rem × 2rem`. `.menu-toggle-btn`: flex row, `gap: 1rem`, `cursor: pointer`.
- `.menu-toggle-label`: `overflow: hidden` (this clips the label when GSAP slides the `<p>` up). Its `p`: `position: relative; transform: translateY(0%); will-change: transform;`.

**Hamburger icon (pure CSS morph)**
- `.menu-hamburger-icon`: `position: relative; width:3rem; height:3rem;` circular (`border-radius: 100%`), `border: 1px solid var(--hamburger-icon-border)`, flex column centered.
- Each `span`: `position:absolute; width:15px; height:1.25px; background: var(--fg); transform-origin: center; will-change: transform;` and — key — `transition: all 0.75s cubic-bezier(0.87, 0, 0.13, 1);` (the same curve as the GSAP "hop" ease).
- Resting state: span 1 `translateY(-3px)`, span 2 `translateY(3px)` (two parallel lines).
- `.menu-hamburger-icon.active` state: span 1 `translateY(0) rotate(45deg) scaleX(1.05)`, span 2 `translateY(0) rotate(-45deg) scaleX(1.05)` — the lines merge into an X. JS only toggles the `active` class; CSS animates it.

**Overlay**
- `.menu-overlay` and `.menu-overlay-content`: both `position: fixed; top:0; left:0; width:100vw; height:100svh; color: var(--fg); overflow: hidden; z-index: 1;`.
- `.menu-overlay`: `background: var(--menu-bg);` with initial `clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)` (a zero-height strip pinned to the top edge — fully hidden) and `will-change: clip-path`.
- `.menu-overlay-content`: `display: flex; transform: translateY(-50%); will-change: transform; pointer-events: all;` — it starts shifted up half a screen; GSAP parses that as `yPercent: -50`.
- `.menu-media-wrapper`: `flex: 2; opacity: 0; will-change: opacity;` and its `img { opacity: 0.25; }` (a dim, moody panel).
- `.menu-content-wrapper`: `flex: 3; position: relative; display: flex;`.
- `.menu-content-main`: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);`. `.menu-footer`: `margin: 0 auto;`. Both: `width: 75%; padding: 2rem; display: flex; align-items: flex-end; gap: 2rem;`.
- `.menu-col`: flex column, `gap: 0.5rem`; inside `.menu-content-main` / `.menu-footer` the first col gets `flex: 3`, the second `flex: 2`.
- `.line` (the class SplitText assigns to each line): `position: relative; will-change: transform;`.

## GSAP effect (exhaustive)

**Setup**
1. `gsap.registerPlugin(CustomEase, SplitText)`; then `CustomEase.create("hop", ".87,0,.13,1")` — this bezier is the signature ease used by every structural tween.
2. Lenis smooth scroll: `const lenis = new Lenis()` driven by a `requestAnimationFrame` loop calling `lenis.raf(time)`.
3. **SplitText prep** — for every `.menu-col` container (there are 4: links col, tags col, and the 2 footer cols), select all its `a` and `p` elements and split **each element** with `SplitText.create(element, { type: "lines", mask: "lines", linesClass: "line" })` (the `mask: "lines"` option wraps every line in its own overflow-clipping mask wrapper). Keep the splits grouped **per container** in an array of arrays (`splitTextByContainer`). Immediately hide every line: `gsap.set(split.lines, { y: "-110%" })` — each line waits above its mask.

**State guards** — `isMenuOpen` and `isAnimating` booleans. The toggle click handler returns early while `isAnimating` is true, so the animation can never be interrupted mid-flight.

**OPEN timeline** (click while closed): set `isAnimating = true`, call `lenis.stop()` (page scroll is frozen while the menu is open), then build one `gsap.timeline()` where the first five tweens all start together at position `"<"` (time 0):
1. `.menu-toggle-label p` → `y: "-110%"`, `duration: 1`, `ease: "hop"` — the word "Menu" slides up out of its overflow-hidden wrapper and disappears.
2. `.container` → `y: "100svh"`, `duration: 1`, `ease: "hop"` — the whole page is pushed down one full viewport (the "push-over").
3. `.menu-overlay` → `clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)"`, `duration: 1`, `ease: "hop"` — the overlay's bottom edge sweeps from the top of the screen down to the bottom, revealing the dark menu in a top-anchored wipe that moves in lockstep with the page being pushed down.
4. `.menu-overlay-content` → `yPercent: 0`, `duration: 1`, `ease: "hop"` — the content rides up from its initial `translateY(-50%)` to its natural position (a parallax counter-movement against the wipe).
5. `.menu-media-wrapper` → `opacity: 1`, `duration: 0.75`, `ease: "power2.out"`, `delay: 0.5`, positioned at `"<"` — the side image fades in during the second half of the wipe (effective window ≈ 0.5s→1.25s).

**Line reveals** — after those five, loop over `splitTextByContainer`; for each container, flatten its splits into one array of line elements (`containerSplits.flatMap(split => split.lines)`) and add:
- `tl.to(copyLines, { y: "0%", duration: 2, ease: "hop", stagger: -0.075 }, -0.15)`
- Details that matter: `stagger: -0.075` is **negative**, so within each group the **last line starts first** and the reveal runs bottom-up, 0.075s apart. The position parameter `-0.15` means "insert 0.15s before the current end of the timeline" — and since each added tween extends the timeline's end (its span is `2 + 0.075 × (lines − 1)` seconds), the four container groups **chain into a cascade**, each group beginning 0.15s before the previous group's tween finishes: group 1 (5 link lines) starts at ≈1.1s, group 2 (3 tag lines) at ≈3.25s, group 3 (1 footer line) at ≈5.25s, group 4 (2 footer lines) at ≈7.1s. Do NOT insert them all at the same time — add them sequentially in a loop with position `-0.15` so this cascade emerges naturally.

Also on open: `hamburgerIcon.classList.add("active")` (CSS morphs the icon to an X over 0.75s), and finish with `tl.call(() => { isAnimating = false; })`. Set `isMenuOpen = true`.

**CLOSE timeline** (click while open): set `isAnimating = true`, remove the `active` class from the hamburger, then one timeline with five tweens all starting together (first at default position, the rest at `"<"`), all `duration: 1`, `ease: "hop"`:
1. `.container` → `y: "0svh"` — the page slides back up.
2. `.menu-overlay` → `clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)"` — the overlay collapses back to a zero-height strip at the top (bottom edge sweeps upward).
3. `.menu-overlay-content` → `yPercent: -50` — the content slides back up behind the wipe.
4. `.menu-toggle-label p` → `y: "0%"` — the word "Menu" slides back down into view.
5. All `.menu-col` containers → `opacity: 0.25` — the menu copy dims as it exits.

End the close timeline with a `tl.call()` that resets everything instantly for the next open: `gsap.set` all split lines back to `y: "-110%"`, `gsap.set` the `.menu-col` containers back to `opacity: 1`, `gsap.set(.menu-media-wrapper, { opacity: 0 })`, then `isAnimating = false` and `lenis.start()`. Set `isMenuOpen = false`.

## Assets / images
3 images, described by role:
1. **Logo** — a small square (1:1) abstract studio logo mark on transparent background (PNG), rendered at 2rem in the top-left of the menu bar.
2. **Menu overlay media** — one moody, dark editorial photograph (roughly portrait/full-height crop; it fills the left 2/5 of the overlay at full viewport height, `object-fit: cover`) shown at 25% opacity as an ambient panel inside the open menu.
3. **Page banner** — one large atmospheric photograph filling the middle full-screen section (`100vw × 100svh`, `object-fit: cover`) at 50% opacity behind the dark background.

No real brand names — use the neutral fictional studio contact shown in the HTML above.

## Behavior notes
- The open/close animations are click-triggered only; `isAnimating` locks out clicks mid-animation.
- Lenis smooth scroll runs on the page and is stopped while the menu is open, restarted on close.
- The page sections use `z-index: -1` so the fixed nav/overlay always sit above them; the nav shell is `pointer-events: none` with `pointer-events: all` re-enabled only on the menu bar and the overlay content.
- Responsive at `max-width: 1000px`: `h1` drops to `3rem` (letter-spacing `-0.05rem`, full width), the media wrapper is hidden (`display: none`), `.menu-content-main` and `.menu-footer` go full width, `.menu-content-main` becomes a column (`align-items: flex-start; gap: 5rem`), `.menu-link a` drops to `3rem` and `.menu-tag a` to `1.25rem`.
- Use `svh` units throughout (heights and the container push) for correct mobile viewport behavior.
