# Products Lightbox Gallery

## Goal
Build an **explorable product wall**: a huge fixed-size canvas (a 12×12 grid of 144 product tiles) that the user **pans around by click-dragging with the mouse**, where the drag follows the cursor with an eased, slightly inertial lag. **Clicking a single tile** (as opposed to dragging) **fades in a full-screen lightbox modal** that shows that product's image, name and info plus a "users also bought" panel. The two star effects are (1) the **GSAP-smoothed drag-to-pan** of the oversized grid and (2) the **opacity cross-fade of the lightbox** on open/close, with a precise **drag-vs-click disambiguation** so panning never accidentally opens a product.

## Tech
Vanilla HTML/CSS/JS with ES module imports, bundled by Vite. Use `gsap` (npm) **only** — **no GSAP plugins**, no ScrollTrigger, no SplitText, no CustomEase, no smooth-scroll library, no `requestAnimationFrame`/lerp loop, no Three.js. Every motion is a plain `gsap.to()` / `gsap.set()` / `gsap.getProperty()` call driven by native mouse events. Import:
```js
import gsap from "gsap";
```
The modal close button uses the **Ionicons** web component (`<ion-icon name="close-outline">`), loaded via its ESM script from a CDN (`ionicons@7.1.0`, `type="module"` + `nomodule` fallback pair in `<head>`). If you'd rather not add Ionicons, put a plain "×" glyph inside the close button — the animation only needs a clickable circle.

Wrap all JS in `document.addEventListener("DOMContentLoaded", () => { … })`.

## Layout / HTML
Two independent pieces at the top level of `<body>`: an **empty `#container`** (the grid is generated entirely in JS) and a **`.modal`** authored statically in HTML (populated on click).

```html
<div id="container"></div>

<div class="modal">
  <div class="col product-view">
    <div class="close-btn">
      <div class="close"><ion-icon name="close-outline"></ion-icon></div>
    </div>
    <div class="product-img">
      <div class="product-img-container">
        <img src="/img/img1.png" alt="" />
      </div>
    </div>
    <div class="product-name">
      <div class="name">
        <h1>White | Phantom</h1>
        <p>The Volley Advantage</p>
      </div>
      <div class="cart-btn"><button>Add to cart</button></div>
    </div>
  </div>

  <div class="col product-info">
    <div>
      <p>Neutral placeholder paragraph, one or two sentences of product copy.</p>
      <p>A second, longer placeholder paragraph describing the product in three or four sentences.</p>
    </div>
    <div class="suggestions">
      <p>Users also bought</p>
      <div class="box"><img class="img" src="/img/img4.png"><p>The Volley Clubhouse</p><h1>White | Flint</h1></div>
      <div class="box"><img class="img" src="/img/img2.png"><p>The Volley Advantage</p><h1>White | Sand</h1></div>
    </div>
  </div>
</div>

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

- `#container` starts **empty**; JS creates 144 `.box` tiles inside it, each tile = one `<img class="img">` + one `.content` wrapper holding a `<p>` (product line) and an `<h1>` (colorway name).
- `.modal` is a two-column flex row: **left `.col.product-view`** (the big product view, `flex: 3`) and **right `.col.product-info`** (copy + suggestions, `flex: 2`).
- Keep all copy **neutral/fictional** — no real brand or client names anywhere. Invent a fictional shoe line; use product lines like `"The Volley Advantage"`, `"The Volley Clubhouse"`, `"The Volley Centercourt"` and colorway names like `"White | Sand"`, `"White | Rust"`, `"White | Phantom"`.

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box }`. `html, body { height:100%; overflow:hidden; font-family:"Neue Montreal" }` (any clean neutral sans-serif fallback is fine). **The page never scrolls** — panning is done by dragging the oversized canvas, so `overflow:hidden` is load-bearing.

Base typography:
- `img { width:100%; height:80%; object-fit:contain }` (images are letterboxed inside their tile, not cropped).
- `h1 { margin:5px 0; font-size:26px; font-weight:500 }`
- `p { font-size:10px; text-transform:uppercase; font-weight:500; margin-top:-15px; color:gray }`
- `ion-icon { position:relative; top:1.5px; font-size:18px }`

**The grid canvas (the thing you drag):**
- `#container`: `width:2080px; height:2880px; background:#ccc; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%)`. **JS overrides the width** to `columns * boxSize = 12 * 240 = 2880px`, so the live canvas is **2880×2880**, a solid `#ccc` square centered in the viewport and far larger than the screen (only a portion is ever visible → you drag to explore).
- `.box`: `width:240px; height:240px; background:#f4efef; border:1px solid #dadada; float:left; padding:10px` (border-box, so 240 includes border+padding → exactly **12 tiles per row, 12 rows** = 144 tiles perfectly tiling the 2880² canvas). Each tile shows its contained product image on top and its `.content` (uppercase gray line + 26px name) below.
- `.suggestions .box p { margin:-15px 0 0 0 }` (tightens the label above the name inside suggestion cards).

**The lightbox modal:**
- `.modal`: `position:fixed; width:100vw; height:100vh; top:0; left:0; background:#f4efef; z-index:2; display:none`. It is **hidden by default** and switched to `display:flex` (a two-column row) by JS on open. Note there is **no `opacity` declared in CSS**, so its resting opacity is `1` (see the fade quirk in the GSAP section).
- `.col { height:100%; padding:2em }`.
- `.product-view { position:relative; flex:3; display:flex; flex-direction:column }`.
- `.product-info { flex:2; display:flex; flex-direction:column; justify-content:space-between; padding-bottom:5em }`.
- `.close-btn { position:absolute; right:0; width:40px; height:40px; display:flex; justify-content:center; align-items:center; background:#000; color:#fff; border-radius:100%; cursor:pointer }` (a black circle with a white close icon, pinned top-right of the product view).
- `.product-img { flex:5; padding-top:60px }` (the dominant hero image area).
- `.product-name { width:100%; height:100%; flex:3; display:flex; justify-content:space-between; align-items:center; padding:0 20px }`.
  - `.product-name h1 { font-size:60px; letter-spacing:-2px }` (large product title).
  - `.product-name p { margin:10px 0; font-size:15px }`.
- `.cart-btn button { background:#000; color:#fff; border:none; outline:none; padding:15px 40px; border-radius:4px; font-size:12px; text-transform:uppercase }` (a black "Add to cart" pill).
- `.product-info p { margin-top:0; margin-bottom:20px; font-size:20px; text-transform:none; color:#555555; line-height:100%; font-weight:400 }` (the body copy overrides the tiny uppercase gray `p` default).
- `.suggestions p { text-transform:uppercase; font-size:12px; font-weight:500; color:#7a7a7a }` ("Users also bought" label).
- Inside `.suggestions`, the two `.box` cards inherit the same 240×240 tile look and `float:left` side by side, each with a contained image + line + name.

## GSAP effect (exhaustive)

There is **no timeline and no load animation** — nothing moves until the user interacts. Three independent interaction handlers drive everything. Precise spec:

### 1. Grid generation (JS, no animation)
Constants: `boxCount = 12 * 12` (144), `boxSize = 240`, `totalImages = 5`, `columns = 12`. Set `container.style.width = columns * boxSize + "px"` (2880px). Define a `products` array of **15** objects `{ info, name }` (the fictional lines + colorways). Then loop `for (let i = 0; i < boxCount; i++)`:
- Create `.box`; create `img.img` with `src = "/img/img" + ((i % totalImages) + 1) + ".png"` → the **5 images cycle** (img1…img5, img1…) across the whole wall.
- `product = products[i % products.length]` → the **15 products cycle** independently, so image↔product pairing repeats every 15 tiles.
- Create `<p>` = `product.info`, `<h1>` = `product.name`, wrap both in `.content`, append `img` + `content` to the box, append box to `#container`.
- Attach the three per-tile listeners below.

### 2. Drag-to-pan the whole canvas (the smoothed inertial pan)
State: `isContainerDragging = false`, `startCoords = {x,y}`, `startTranslate = {x,y}`. Listeners on `#container`: `mousedown → onDragStart`, `mouseup → onDragEnd`, `mouseleave → onDragEnd`, `mousemove → onDrag`.

- **`onDragStart(e)`**: `isContainerDragging = true`; record `startCoords = {x:e.clientX, y:e.clientY}`; read the **current transform** with `startTranslate.x = gsap.getProperty(container, "x")` and `startTranslate.y = gsap.getProperty(container, "y")`; then `gsap.set(container, { cursor:"grabbing" })` and `gsap.set(container, { userSelect:"none" })`.
- **`onDrag(e)`**: if not dragging, return. `e.preventDefault()`. Compute `deltaX = e.clientX - startCoords.x`, `deltaY = e.clientY - startCoords.y`, then `translateX = startTranslate.x + deltaX`, `translateY = startTranslate.y + deltaY`, and animate:
  ```js
  gsap.to(container, { x: translateX, y: translateY, duration: 0.5, ease: "power1.out" });
  ```
  **This is the whole "inertia" trick:** every `mousemove` fires a fresh `0.5s power1.out` tween toward the latest cursor-derived target. Because a new tween continuously overrides the previous one before it finishes, the canvas **eases toward the cursor with a soft trailing lag** rather than snapping 1:1 — and when the user stops moving, the last tween runs out its 0.5s, giving a gentle glide-to-rest. (No lerp loop or Draggable plugin — the smoothing is purely the overlapping short tweens.)
- **`onDragEnd()`**: if not dragging, return; `isContainerDragging = false`; `gsap.set(container, { cursor:"grab" })`; `gsap.set(container, { userSelect:"auto" })`.

### 3. Tile click → open lightbox (with drag-vs-click disambiguation)
Each `.box` carries its own `isDragging` / `isClicking` flags:
- `box.mousedown → isDragging = false; isClicking = true`.
- `box.mousemove → isDragging = true; isClicking = false` (any movement over the tile marks it a drag, not a click).
- `box.click`: **only if `!isDragging && isClicking`** (i.e. a clean click with no intervening move) do:
  ```js
  gsap.set(modal, { display: "flex" });
  gsap.to(modal, { opacity: 1, duration: 0.4 });
  productImg.src = img.src;                       // copy this tile's image into the hero
  modalProductName.textContent = product.name;    // .modal .product-name h1
  modalProductInfo.textContent = product.info;    // .modal .product-name p
  ```
  So the modal is un-hidden (`display:flex`), its opacity is tweened toward `1` over **0.4s**, and the clicked product's image + name + line are injected into the product view.

### 4. Close button → fade out & hide
`.close-btn` `click`:
```js
gsap.to(modal, {
  opacity: 0,
  duration: 0.4,
  onComplete: () => gsap.set(modal, { display: "none" }),
});
```
The modal fades `opacity 1 → 0` over **0.4s**, then `onComplete` sets `display:none` to fully remove it from layout.

**Opacity fade quirk (reproduce as-is):** because CSS never sets an initial `opacity`, the modal's resting opacity is `1`. On the **very first** open, `gsap.to(opacity:1)` is a no-op (already 1) so it appears instantly; the **close** tween takes it to `0`; every **subsequent** open then animates `0 → 1`, a visible 0.4s fade. This is the original behavior. (Optional polish: add `opacity:0` to `.modal` in CSS so even the first open fades in — but the reference does not.)

**Eases/durations summary:** pan = `duration:0.5, ease:"power1.out"` (re-fired per mousemove); modal open fade = `duration:0.4` (default ease); modal close fade = `duration:0.4` (default ease) + `display` toggle on complete. No stagger, no delay, no scrub, no pin anywhere.

## Assets / images
**5 wide, full-color landscape images (~2:1, ~1024×510)**, referenced as `/img/img1.png … /img/img5.png` and **cycled** across all 144 tiles (and reused as the modal hero + the two suggestion thumbnails). They are `object-fit: contain`, so each sits letterboxed inside a light `#f4efef` tile — transparent or plain-background product shots read best. It is a **mixed product / editorial set** sharing a clean, minimal, catalog mood; the exact subjects don't matter, only that there are 5 wide full-color images. A representative set (describe by role, no brands):
- **img1** — studio close-up of a tan suede ankle boot with a stacked wooden block heel and squared toe, on a pale seamless backdrop.
- **img2** — warm sepia-toned macro beauty shot of a brow and closed eye (skin/hair texture), full-bleed.
- **img3** — black-and-white studio shot of an open hinged twin-mirror compact / silver case on a black ground.
- **img4** — a cream-and-rust high-top athletic sneaker floating amid red and white daisies against a soft pink backdrop.
- **img5** — overhead flat-lay of purple-themed streetwear (lilac backpack, folded white tee, chain, beanie, windbreaker and a pair of white/purple high-tops) on a deep-purple ground.

## Behavior notes
- **Desktop / mouse only.** All interaction is mouse events (`mousedown`/`mousemove`/`mouseup`/`mouseleave`/`click`); there is no touch or pointer-event handling in the original, so it is not mobile-safe as written.
- **Nothing animates on load or scroll.** The page is a fixed, non-scrolling screen; the only motion is the drag-pan and the modal fades.
- The disambiguation flags mean a click that included *any* mouse movement over the tile is treated as a pan and will **not** open the modal — only a clean, stationary click opens a product.
- Reduced-motion is **not** handled in the original; the 0.4s/0.5s tweens are short enough to be unobtrusive, but you may add a `prefers-reduced-motion` guard if desired.
- Light perf cost: pure CSS transforms (`x`/`y` on one element) + an opacity fade; no canvas, WebGL or physics.
