# Mask Reveal On Scroll (3×3 clip-path mosaic image reveal)

## Goal
Build a long editorial gallery page where **every image reveals itself as a 3×3 grid of clip-path tiles that unfold cell-by-cell in a diagonal wave when its row scrolls into view**. Each `.img` is layered with nine identical full-cover copies of its picture, each copy clipped to one cell of a 3×3 grid; a ScrollTrigger timeline animates the nine `clip-path` polygons from collapsed zero-area points (each pinned at its cell's top-left corner) out to full cells, cascading top-left → bottom-right along five anti-diagonal waves. The star effect is that per-image mosaic "tile-in" reveal. Trigger is scroll (each image row entering the viewport, one-shot). Lenis provides smooth scrolling synced to ScrollTrigger.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** with the **`ScrollTrigger`** plugin, plus **`lenis`** for smooth scroll:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
```
No SplitText, no CustomEase, no Three.js, no canvas. Wire Lenis to GSAP's ticker the standard way:
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
```

## Layout / HTML
A single scrolling document for a fictional dystopian fashion label — use the neutral brand name **"Wasteland Couture"** (no real brands). Structure top to bottom:
```
nav                         (absolute, top; brand link left, "Shop" link right)
  a "Wasteland Couture"
  a "Shop"

section.hero
  h1 "WASTELAND COUTURE"    (giant centered display heading)

section.info                (two short right-aligned body paragraphs)
  p … p …

section.hero-imgs
  .row
    .img.img-1
    .img.img-2

section.clients             (two columns)
  .col > p "Selected Clients"
  .col
    .clients-list           (~16 <p> fictional client names)
    .clients-list           (~16 more <p> names)

section.clients-imgs        (one large full-width image row, 700px tall)
  .row > .img.img-3

section.product-filters     (a filter label row with bottom border)
  .col > p "All" / "Lighting" / "Textiles" / "Furniture" / "Accessories" / "Surfaces"
  .col (empty)

section.products            (4 rows × 4 tiles; ~half populated, half left blank)
  .row  .img  .img.img-4  .img.img-5  .img
  .row  .img.img-6  .img  .img  .img.img-7
  .row  .img  .img.img-8  .img  .img.img-9
  .row  .img.img-10  .img  .img.img-11  .img.img-12

section.about               (two body paragraphs)
  p … p …

section.about-imgs
  .row > .img.img-13 + .img.img-14

section.outro
  .row > .img.img-15 + .img.img-16 + .img.img-17

footer                      (brand name left, "© 2026" right)
```
- The tiles/masks are **not in the HTML** — every `.img` starts empty; JS injects the nine `.mask` divs into each one. The class names `.row`, `.img`, `.img-1 … .img-17`, and (JS-created) `.mask`, `.m-1 … .m-9` are load-bearing.
- Note the products grid deliberately mixes populated tiles (`.img-N`) with plain `.img` cells that carry no background — those animate too but reveal nothing, creating a scattered, gappy editorial layout.

## Styling
- Global reset `* { margin:0; padding:0; box-sizing:border-box; }`. `html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; }` (a clean neutral grotesque; fall back to Helvetica Neue / Inter). Background stays default white; text is black.
- `a, p { text-decoration:none; font-size:16px; font-weight:500; color:#000; }`.
- `img { width:100%; height:100%; object-fit:cover; }`.
- `nav { position:absolute; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; }`.
- `footer { width:100%; padding:2em; display:flex; justify-content:space-between; align-items:center; margin-top:4em; }`.
- `section { width:100%; padding:2em; }`.
- `.row { width:100%; display:flex; gap:2em; }` and `.col { flex:1; display:flex; gap:1em; }`.
- `.hero h1 { margin-top:3em; text-align:center; text-transform:uppercase; font-size:8.75vw; font-weight:500; letter-spacing:-0.02vw; }` — huge, near-full-width headline.
- `.info { display:flex; justify-content:flex-end; gap:2em; }` and `.info p { width:25%; }` — two narrow paragraphs pushed to the right.
- `.hero-imgs { margin-top:10em; }`. `.clients { display:flex; }`, `.clients-list { flex:1; }`. `.clients-imgs { margin-top:4em; }` and **`.clients-imgs .row { height:700px; }`** (the one oversized hero-scale image). `.product-filters { padding-bottom:1em; display:flex; border-bottom:1px solid rgba(0,0,0,0.25); }`. `.products { display:flex; flex-direction:column; gap:2em; }`. `.about { display:flex; }` with `.about p { margin-top:8em; flex:1; }`. `.outro .row { margin-top:8em; }`.

### The tile / mask CSS (critical for the effect)
- `.img { position:relative; width:100%; height:100%; aspect-ratio:4/5; }` — each image cell is a 4:5 portrait box (except the 700px-tall clients row).
- `.mask { position:absolute; top:0; left:0; width:100%; height:100%; }` — every mask is a full-size overlay stacked on its `.img`.
- **Each populated `.img-N` sets the SAME full-cover background on all of its `.mask` children** (not per-cell slices — every mask holds the whole picture, `background: url(...) no-repeat 50% 50%; background-size: cover;`). The 3×3 tiling comes purely from clip-path; the imagery underneath is identical across the nine masks, so as each cell's polygon grows it uncovers its portion of one continuous photo.

### Image ↔ tile mapping (7 photos reused across 17 populated tiles)
There are only **7 distinct source images**; they repeat across the 17 `.img-N` classes. Wire each photo to the same tile classes so the reproduction matches:
- image-3 → `.img-1, .img-6, .img-12`
- image-4 → `.img-2, .img-7, .img-14`
- image-7 → `.img-3, .img-15`
- image-1 → `.img-4, .img-10`
- image-2 → `.img-5, .img-11, .img-16`
- image-5 → `.img-8, .img-17`
- image-6 → `.img-9, .img-13`

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

### 1. The two polygon tables (9 masks per image, 3×3 grid)
Cell coordinates snap to the thirds 0% / 33% / 66% (right/bottom edges land at 33.5% / 66.5% / 100%, the extra 0.5% overlap hiding seams). Two parallel arrays, index 0→8 mapping to cells row-major (mask `.m-1`=index 0 top-left … `.m-9`=index 8 bottom-right):

**`initialClipPaths` — collapsed zero-area points, each pinned at its cell's TOP-LEFT corner:**
```js
const initialClipPaths = [
  "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",        // cell 0  TL corner (0,0)
  "polygon(33% 0%, 33% 0%, 33% 0%, 33% 0%)",    // cell 1  (33,0)
  "polygon(66% 0%, 66% 0%, 66% 0%, 66% 0%)",    // cell 2  (66,0)
  "polygon(0% 33%, 0% 33%, 0% 33%, 0% 33%)",    // cell 3  (0,33)
  "polygon(33% 33%, 33% 33%, 33% 33%, 33% 33%)",// cell 4  (33,33) center
  "polygon(66% 33%, 66% 33%, 66% 33%, 66% 33%)",// cell 5  (66,33)
  "polygon(0% 66%, 0% 66%, 0% 66%, 0% 66%)",    // cell 6  (0,66)
  "polygon(33% 66%, 33% 66%, 33% 66%, 33% 66%)",// cell 7  (33,66)
  "polygon(66% 66%, 66% 66%, 66% 66%, 66% 66%)",// cell 8  (66,66)
];
```

**`finalClipPaths` — the full cell rectangles (with the 0.5% overlap seams):**
```js
const finalClipPaths = [
  "polygon(0% 0%, 33.5% 0%, 33.5% 33%, 0% 33.5%)",
  "polygon(33% 0%, 66.5% 0%, 66.5% 33%, 33% 33.5%)",
  "polygon(66% 0%, 100% 0%, 100% 33%, 66% 33.5%)",
  "polygon(0% 33%, 33.5% 33%, 33.5% 66%, 0% 66.5%)",
  "polygon(33% 33%, 66.5% 33%, 66.5% 66%, 33% 66.5%)",
  "polygon(66% 33%, 100% 33%, 100% 66%, 66% 66.5%)",
  "polygon(0% 66%, 33.5% 66%, 33.5% 100%, 0% 100%)",
  "polygon(33% 66%, 66.5% 66%, 66.5% 100%, 33% 100%)",
  "polygon(66% 66%, 100% 66%, 100% 100%, 66% 100%)",
];
```
Because each initial polygon is all four vertices stacked on the cell's top-left corner and the final polygon is that cell's four corners, **each tile grows/unfolds outward from its own top-left corner** to fill its ninth of the frame.

### 2. Inject the masks
```js
function createMasks() {
  document.querySelectorAll(".img").forEach((img) => {
    for (let i = 1; i <= 9; i++) {
      const mask = document.createElement("div");
      mask.classList.add("mask", `m-${i}`);
      img.appendChild(mask);
    }
  });
}
createMasks();
```
Every `.img` (populated or blank) receives nine masks `.m-1 … .m-9`, appended in order so their NodeList index equals the polygon index above.

### 3. Per-row ScrollTrigger timelines
Iterate rows, then each image in the row, then its masks. Set each mask to its collapsed initial state, then build a one-shot timeline triggered by the row:
```js
gsap.utils.toArray(".row").forEach((row) => {
  row.querySelectorAll(".img").forEach((img) => {
    const masks = img.querySelectorAll(".mask");
    masks.forEach((mask, index) => gsap.set(mask, { clipPath: initialClipPaths[index] }));

    const tl = gsap.timeline({
      scrollTrigger: { trigger: row, start: "top 75%" },
    });

    const animationOrder = [
      [".m-1"],                    // wave 0
      [".m-2", ".m-4"],            // wave 1
      [".m-3", ".m-5", ".m-7"],    // wave 2
      [".m-6", ".m-8"],            // wave 3
      [".m-9"],                    // wave 4
    ];

    animationOrder.forEach((targets, index) => {
      tl.to(
        targets.map((cls) => img.querySelector(cls)),
        {
          clipPath: (i, el) => finalClipPaths[Array.from(masks).indexOf(el)],
          duration: 0.5,
          ease: "power2.out",
          stagger: 0.1,
        },
        index * 0.125
      );
    });
  });
});
```

### 4. Exact motion spec
- **Trigger:** `ScrollTrigger { trigger: row, start: "top 75%" }` — **no `end`, no `scrub`, no `pin`.** The timeline plays **once, forward** the moment the row's top reaches 75% down the viewport (i.e. 25% into view from the bottom). It does not reverse on scroll-up. Every `.img` in a row shares the same row trigger, so all images in that row reveal simultaneously.
- **Waves (diagonal cascade):** the five `animationOrder` groups are anti-diagonals of the 3×3 grid. Each group's tween is placed at absolute timeline position **`index * 0.125`** (0, 0.125, 0.25, 0.375, 0.5 s), so waves start 0.125 s apart and overlap heavily — the reveal sweeps corner-to-corner from top-left (`.m-1`) to bottom-right (`.m-9`).
- **Per-tile tween:** `clip-path` from its `initialClipPaths[i]` (collapsed point) to `finalClipPaths[i]` (full cell), `duration: 0.5`, `ease: "power2.out"`.
- **Within-wave stagger:** `stagger: 0.1` — in the two-tile and three-tile waves the members fire 0.1 s apart (e.g. wave 2 `.m-3` → `.m-5` → `.m-7`).
- **Final-value lookup:** the `clipPath` target is a function `(i, el) => finalClipPaths[Array.from(masks).indexOf(el)]` — it resolves each element's real index within the image's mask NodeList, so `.m-3` maps to `finalClipPaths[2]`, `.m-5` to `[4]`, etc., regardless of the order it appears inside the wave array.
- Total per-image reveal wall-clock ≈ 0.5 (last wave offset) + 0.1 (stagger) + 0.5 (duration) ≈ 1.1 s.

## Assets / images
**7 distinct full-bleed images**, reused across the tiles per the mapping above. They are shown via `background-size: cover; background-position: 50% 50%`, so any orientation works — the mask box crops them. Aim for a **moody, cinematic, dystopian editorial mood**: dark, high-contrast, atmospheric scenes (weathered post-apocalyptic landscapes, glowing structures against night skies, desolate terrain, industrial/avant-garde textures) so the mosaic tiles read dramatically as they unfold. One of the seven (image-7 → `.img-3`) fills the oversized 700px-tall full-width row, so favour a wide, hero-scale composition there. No real brands, logos, or text in the imagery. Any 7 cohesive cinematic photos in this register work.

## Behavior notes
- Reveals are **one-shot and irreversible** (no scrub, no `toggleActions` reset) — once a row has tiled in, it stays revealed.
- No `prefers-reduced-motion` branch and no min-width gate in the original; it runs on desktop and mobile alike. Sections stack vertically and the effect is unchanged on narrow screens.
- Light performance cost: pure CSS `clip-path` tweens, no WebGL/canvas. Blank `.img` cells still get masks and still animate (invisibly) — that is intended.
- Lenis smooth scroll is synced to ScrollTrigger via `gsap.ticker`; keep `lagSmoothing(0)` so the tie-in stays frame-accurate.
