# Draggable Pill Menu — Drag-Anywhere Drawer + Snap-Back Drop Zone + Expanding Hamburger

## Goal
Build a small **pill-shaped floating menu drawer** docked in the top-left corner. Two things make it: (1) the whole pill is **draggable anywhere in the viewport** with GSAP `Draggable`, and when you drag it back near its origin a **dashed "drop-zone" outline fades in** and the pill **springs back into its corner** on release; (2) clicking the hamburger toggler **expands the pill** — the nav-items row grows from `width: 0` to its full width while each link **fades + scales in with a staggered pop**, and the toggler bars **morph into an X**. Closing reverses it, with the links popping out from the end first. It is entirely click- and drag-driven — no scroll, no autoplay.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the single GSAP plugin **`Draggable`**. No ScrollTrigger, no smooth-scroll library. Import as:
```js
import gsap from "gsap";
import { Draggable } from "gsap/all";
gsap.registerPlugin(Draggable);
```

## Layout / HTML
```
.menu-drop-zone                      (fixed dashed outline behind the drawer — the snap target ghost)

.menu-drawer                         (fixed pill container — this whole element is the drag handle)
  .menu-logo
    img                              (small square logo mark)
  .menu-items                        (the collapsible links row — animated width)
    .menu-item > a  "Work"
    .menu-item > a  "Manifesto"
    .menu-item > a  "Contact"
  .menu-toggler                      (hamburger button — click target to expand/collapse)
    span
    span
```
Use neutral, fictional link labels — **Work, Manifesto, Contact**. No real brand names.

## Styling
Font (Google Fonts): **Google Sans** (variable, weights 400–700, italic + optical-size axes). `body { font-family: "Google Sans", sans-serif; }`.

Color tokens:
- `--base-100: #fafafa` (menu-item pill fill — near white)
- `--base-200: #e8e6e7` (drawer background — light grey)
- `--base-300: #d3d2d2` (toggler background — mid grey)
- `--base-400: #0f0f0f` (text + hamburger bars — near black)

Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`. Everything is `rem`-based (assume `1rem = 16px`). The JS reads pixel widths off the DOM, so keep these dimensions:

Structural / load-bearing CSS:
- `.menu-drop-zone`: `position: fixed; top: 2rem; left: 2rem; height: calc(3.5rem + 0.7rem);` `border: 0.075rem dashed rgba(0,0,0,0.5); border-radius: 4rem;` `transition: opacity 0.2s ease-out;` `pointer-events: none; opacity: 0;`. (Its **width is set by JS**, not CSS — it is a dashed ghost the exact size of the pill, sitting under the drawer's home position, invisible until you drag near home.)
- `.menu-drawer`: `position: fixed; top: 2rem; left: 2rem; padding: 0.35rem;` `background: var(--base-200); border-radius: 4rem;` `display: flex; align-items: center;`. This element is the GSAP Draggable target.
- `.menu-logo`: `width: 6rem; height: 3.5rem; padding-left: 0.5rem; border-radius: 4rem;` flex-centered, `flex-shrink: 0;`. `.menu-logo img { width: 3.5rem; }`.
- `.menu-items`: `display: flex; gap: 0.35rem;` (this is the element whose `width`/`marginRight` are animated; it starts at width 0 so its links are clipped away).
- `.menu-items .menu-item`: `width: max-content; height: 3.5rem;` `background: var(--base-100); border-radius: 4rem;` flex-centered, `flex-shrink: 0; opacity: 0;`. `.menu-item a`: `text-decoration:none; color: var(--base-400); font-weight: 450; letter-spacing: -0.01rem; padding: 0 1.5rem; user-select: none;`.
- `.menu-toggler`: `position: relative; width: 3.5rem; height: 3.5rem; padding: 1.125rem;` `background: var(--base-300); border-radius: 4rem;` `display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 0.2rem;` `flex-shrink: 0; cursor: pointer;`.
- `.menu-toggler span`: `position: relative; width: 100%; height: 0.125rem; background: var(--base-400);` `transition: transform 0.3s ease; transform-origin: center; will-change: transform;` (two thin black bars = the hamburger).

**Hamburger→X morph is pure CSS** (a `transition`, not GSAP) driven by an `.close` class the JS toggles on `.menu-toggler`:
```css
.menu-toggler.close span:nth-child(1) {
  transform: rotate(45deg)  translateX(0.125rem) translateY(0.1rem)  scaleX(0.9);
}
.menu-toggler.close span:nth-child(2) {
  transform: rotate(-45deg) translateX(0.125rem) translateY(-0.1rem) scaleX(0.9);
}
```

## GSAP effect (exhaustive)

### 0. Measure the pill widths in JS
Grab refs: `.menu-drop-zone`, `.menu-drawer`, `.menu-logo`, `.menu-items`, all `.menu-item`s, `.menu-toggler`. Track `let isMenuOpen = false;`.

Read live pixel dimensions and derive the two pill widths (padding and gap are the CSS `0.35rem` = `0.35 * 16 = 5.6px`):
```js
let menuItemsFullWidth = menuItems.offsetWidth;   // natural width of the links row
const drawerGap     = 0.35 * 16;
const drawerPadding = 0.35 * 16;
const logoWidth     = menuLogo.offsetWidth;        // 6rem box
const togglerWidth  = menuToggler.offsetWidth;     // 3.5rem box

const closedMenuWidth = drawerPadding + logoWidth + drawerGap + togglerWidth + drawerPadding;
let   openMenuWidth   = drawerPadding + logoWidth + drawerGap + menuItemsFullWidth + drawerGap + togglerWidth + drawerPadding;
```

### 1. Initial (closed) states — set immediately
```js
gsap.set(menuItems,        { width: 0, marginRight: 0 });   // collapse the links row
gsap.set(menuItemElements, { opacity: 0, scale: 0.85 });    // links hidden + slightly shrunk
gsap.set(menuDropZone,     { width: closedMenuWidth });     // ghost matches the closed pill
```

### 2. Re-measure after the web font loads
"Google Sans" arrives from the CDN after first layout and changes the measured text width, so re-measure `menuItemsFullWidth` once fonts are ready (only if the menu is still closed):
```js
document.fonts.ready.then(() => {
  if (isMenuOpen) return;
  gsap.set(menuItems, { width: "auto" });
  menuItemsFullWidth = menuItems.offsetWidth;
  gsap.set(menuItems, { width: 0 });
  openMenuWidth = drawerPadding + logoWidth + drawerGap + menuItemsFullWidth + drawerGap + togglerWidth + drawerPadding;
});
```

### 3. Toggle wiring
`menuToggler` `click` → `toggleMenu()`: if `isMenuOpen` call `closeMenu()` else `openMenu()`, then flip `isMenuOpen`.

**openMenu()** — add class `close` to the toggler (CSS morphs bars → X), then:
```js
gsap.to(menuItems, {
  width: menuItemsFullWidth,
  marginRight: drawerGap,
  duration: 0.5,
  ease: "power3.inOut",
  onStart: () => {
    gsap.to(menuItemElements, {
      opacity: 1, scale: 1,
      duration: 0.3,
      stagger: 0.05,
      delay: 0.2,
      ease: "power3.out",
    });
  },
});
```
So the links row expands `width: 0 → menuItemsFullWidth` (and `marginRight 0 → 5.6px`) over **0.5s `power3.inOut`**; `onStart` kicks off a link-pop tween that (after a **0.2s delay**) fades each `.menu-item` `opacity 0→1` and `scale 0.85→1` over **0.3s `power3.out`** with a **0.05s forward stagger** (Work, then Manifesto, then Contact).

**closeMenu()** — remove class `close`, then:
```js
gsap.to(menuItems, {
  width: 0,
  marginRight: 0,
  duration: 0.5,
  ease: "power3.inOut",
  onStart: () => {
    gsap.to(menuItemElements, {
      opacity: 0, scale: 0.85,
      duration: 0.3,
      ease: "power3.out",
      stagger: { each: 0.05, from: "end" },
    });
  },
});
```
Same 0.5s `power3.inOut` collapse of the row; links fade/scale back out over 0.3s `power3.out` but with **`stagger: { each: 0.05, from: "end" }`** so Contact pops out first, then Manifesto, then Work (no `delay` this time). The toggler X un-morphs back to a hamburger via the CSS transition.

### 4. Draggable + snap-back drop zone
```js
const snapThreshold = 200;   // px

Draggable.create(menuDrawer, {
  type: "x,y",
  bounds: window,
  cursor: "grab",
  activeCursor: "grabbing",

  onDragStart: function () {
    // size the ghost to whichever pill state is current
    const activeMenuWidth = isMenuOpen ? openMenuWidth : closedMenuWidth;
    gsap.set(menuDropZone, { width: activeMenuWidth });
  },

  onDrag: function () {
    const withinSnap = Math.abs(this.x) < snapThreshold && Math.abs(this.y) < snapThreshold;
    gsap.to(menuDropZone, { opacity: withinSnap ? 1 : 0, duration: 0.1 });
  },

  onDragEnd: function () {
    gsap.to(menuDropZone, { opacity: 0, duration: 0.1 });
    const withinSnap = Math.abs(this.x) < snapThreshold && Math.abs(this.y) < snapThreshold;
    if (withinSnap) {
      gsap.to(menuDrawer, { x: 0, y: 0, duration: 0.3, ease: "power2.out" });
    }
  },
});
```
- **type `"x,y"`** — free 2D drag; **`bounds: window`** keeps the pill inside the viewport; cursor is `grab` at rest, `grabbing` while dragging.
- `this.x` / `this.y` are the Draggable transform offsets **relative to the home (top-left) position**, so `|x| < 200 && |y| < 200` means "within 200px of home."
- **onDragStart** sets the ghost's width to match the currently open or closed pill (so the outline is the right size to snap into).
- **onDrag** fades the dashed `.menu-drop-zone` ghost to `opacity 1` when inside the 200px snap radius, `0` when outside — each a fast **0.1s** tween. (The CSS also has a `0.2s` opacity transition, but the GSAP tween drives it here.)
- **onDragEnd** hides the ghost (0.1s), and if released inside the snap radius **springs the drawer home** with `{ x: 0, y: 0, duration: 0.3, ease: "power2.out" }`. Released outside the radius, the pill just stays where it was dropped.

## Assets / images
**1 image**, role = *logo mark inside the drawer pill*. A single small square/wordmark logo on a **transparent background** (SVG or PNG), displayed at `3.5rem` (≈56px) wide inside a `6rem` logo box. A bold, dark, geometric mark reads best against the light-grey pill. Any roughly 1:1 mark works; no real brand.

## Behavior notes
- Entirely **click + drag driven** — no scroll, no ScrollTrigger, no loops, no autoplay.
- The `.menu-drop-zone` ghost is `pointer-events: none` and `opacity: 0` at rest; it only appears mid-drag inside the 200px snap radius, and its **width is always assigned by JS**, never CSS.
- The pill can be dragged while **open or closed**; `onDragStart` re-sizes the ghost to the matching state, and the width re-measure on `document.fonts.ready` bails out if the menu is already open (to avoid clobbering an in-progress open width).
- No re-entrancy guard — GSAP overwrites in-flight tweens naturally if the toggler is clicked mid-animation.
