# Mosaic Flip Hover Effect — 3D Cube-Tile Image Reveal

## Goal
Build a dark, full-viewport "spotlight" screen showing a single image rendered as a **12×9 mosaic of 3D cubes** (108 tiles). At rest every tile **gently floats back and forth on the z-axis** in an endless, individually-randomized "breathe" loop, so the mosaic shimmers with depth. A small project menu sits in the bottom-right corner. **Hovering a project name flips the entire mosaic**: all 108 tiles rotate 180° on `rotateY` with a **center-out grid stagger**, and the hovered project's image is revealed on the cubes' previously-hidden back faces. Hovering another name flips again to that image; leaving the menu flips back to the default image. The star effect is the coordinated center-out cube-flip that swaps a full sliced image across the whole grid.

## Tech
Vanilla HTML/CSS/JS with ES-module imports. Use **`gsap`** (npm) only — **no GSAP plugins, no ScrollTrigger, no Lenis, no Three.js**. GSAP drives both the per-tile z-float loop and the grid-staggered `rotateY` flip. Ship one `index.html` (`<link rel="stylesheet" href="./styles.css">` and `<script type="module" src="./script.js">`), one `styles.css`, one ES-module `script.js`. Must run in a fresh Vite + npm project.

## Layout / HTML
The mosaic tiles and their faces are **built entirely in JS** — the HTML only ships the empty preview container and the menu.

```html
<section class="spotlight">
  <div class="project-preview"></div>

  <nav class="project-list">
    <a data-index="1">NX-09</a>
    <a data-index="2">1997 Hallway Tape</a>
    <a data-index="3">Deep Space</a>
    <a data-index="4">Sleep Phase Anomaly</a>
    <a data-index="5">Still-life.mov</a>
    <a data-index="6">Monoform™</a>
  </nav>
</section>
<script type="module" src="./script.js"></script>
```

- `.project-preview` is the empty grid host; JS injects 108 `.tile` elements into it.
- The menu has **6 anchors**, each with a **`data-index`** of 1–6 that maps to one project image. Labels are neutral fictional demo names (as above) — no real brands.

## Styling

**Font** (Google Fonts): **`"DM Mono"`** weights 400 & 500 (used for the menu).

**Palette**
- Section background: `#171717` (near-black).
- Cube top/bottom faces: solid `#222` (dark grey — the only non-image faces).
- Menu text: `#ffffff`, rest opacity `0.5`, hover/active opacity `1`.

**Reset**: `* { margin:0; padding:0; box-sizing:border-box; }`.

**Section (3D stage)** — the perspective root:
```css
.spotlight{
  position:relative; width:100%; height:100svh; overflow:hidden;
  display:flex; align-items:center; justify-content:center;
  background:#171717;
  perspective:800px;             /* the camera */
  transform-style:preserve-3d;
}
```

**Preview grid** — a fixed 12×9 CSS grid of 60px cells (total **720×540 px**, a **4:3** frame), centered by the flex parent:
```css
.project-preview{
  display:grid;
  grid-template-columns:repeat(12, 60px);
  grid-template-rows:repeat(9, 60px);
  transform-style:preserve-3d;
}
```

**Tile (the cube)** — one per grid cell:
```css
.tile{ width:60px; height:60px; position:relative; transform-style:preserve-3d; will-change:transform; }
```

**Cube faces** — six per tile, each a 60×60 image slice; `backface-visibility:hidden` is essential so a face is invisible while it points away from the camera:
```css
.tile-face{ position:absolute; width:60px; height:60px; background-size:cover; background-position:center; backface-visibility:hidden; }
.face-front  { transform: translateZ(30px); }
.face-rear   { transform: rotateY(180deg) translateZ(30px); }
.face-right  { transform: rotateY(90deg)  translateZ(30px); }
.face-left   { transform: rotateY(-90deg) translateZ(30px); }
.face-top    { transform: rotateX(90deg)  translateZ(30px); }
.face-bottom { transform: rotateX(-90deg) translateZ(30px); }
```
(Half-depth of 30px on every face → a **60px cube**. `background-size`/`background-position` are overwritten inline by JS to place each tile's image slice.)

**Menu** — pinned bottom-right, vertical, right-aligned:
```css
.project-list{ position:absolute; bottom:3rem; right:3rem; display:flex; flex-direction:column; z-index:10; }
.project-list a{
  color:#fff; text-transform:uppercase; font-family:"DM Mono",sans-serif; font-size:1rem;
  text-decoration:none; padding:0.125rem 0; cursor:pointer; opacity:0.5;
  transition:opacity 0.3s; text-align:right;
}
.project-list a:hover, .project-list a.active{ opacity:1; }
```

## GSAP effect (be exhaustive)

### Constants & data
```js
import gsap from "gsap";

const TILES_X = 12, TILES_Y = 9, TILE_SIZE = 60;
const PREVIEW_WIDTH  = TILES_X * TILE_SIZE;   // 720
const PREVIEW_HEIGHT = TILES_Y * TILE_SIZE;   // 540

const TILE_FACES = ["face-front","face-rear","face-right","face-left","face-top","face-bottom"];

// index 0 = default/rest image; indices 1..6 = the six projects (data-index)
const PROJECT_IMAGES = [ "<default.jpg>", "<img1>", "<img2>", "<img3>", "<img4>", "<img5>", "<img6>" ];
```

### Build the mosaic (nested loop, row-major)
For `row` 0..8, `col` 0..11: create `div.tile`, then create the six `div.tile-face` children (`class = "tile-face " + side`), append, and store a tile record `{ element, faces{side→el}, row, col }`. Append each tile to `.project-preview` and push to a `tiles[]` array (108 records).

### Image slicing — `setTileImage(tile, side, imagePath)`
Each face shows only **its** 60×60 window of the full image, so the assembled grid reconstructs one picture:
```js
face.style.backgroundImage    = `url(${imagePath})`;
face.style.backgroundSize      = `${PREVIEW_WIDTH}px ${PREVIEW_HEIGHT}px`;   // 720×540 — the whole image
face.style.backgroundPosition  = `${-(tile.col*TILE_SIZE)}px ${-(tile.row*TILE_SIZE)}px`;  // shift to this tile's slice
```

**Initial faces:** for every tile call `setTileImage` with `PROJECT_IMAGES[0]` on `face-front`, `face-rear`, `face-right`, `face-left`; set `face-top` and `face-bottom` backgrounds to solid `#222`. (So at rest you see the default image on the front faces; the grey top/bottom flash only during a flip.)

### Endless z-float — `breathe(tileElement)`
A self-recursive tween per tile makes each cube drift toward/away from the camera forever, each with its own random target and speed:
```js
function breathe(el){
  gsap.to(el, {
    z: gsap.utils.random(-40, 40),          // translateZ, random each cycle
    duration: gsap.utils.random(0.6, 1.4),  // random each cycle
    ease: "sine.inOut",
    onComplete: () => breathe(el),           // loop forever
  });
}
tiles.forEach((tile, i) => gsap.delayedCall(i * 0.015, () => breathe(tile.element)));
```
- Kickoff is **staggered by `i * 0.015s`** (row-major index), so the tiles don't pulse in unison — the mosaic ripples.
- `z` (translateZ) and `rotateY` (the flip) are independent transform channels on the same `.tile`, so floating and flipping run simultaneously without conflict.

### Reveal state
```js
let activeProject = 0;   // currently-shown image index
let revealCount   = 0;   // how many flips have happened (parity picks the hidden face)
let isRevealing   = false;
let nextProject   = null; // queued request while a flip is mid-flight
let hoverDelay    = null; // setTimeout handle (hover debounce)

const getHiddenFace = () => (revealCount % 2 === 0 ? "face-rear" : "face-front");
```
The mosaic alternates which face points at the camera every flip: after an even number of flips the **front** is showing (so **rear** is hidden), after an odd number the **rear** is showing (so **front** is hidden). `getHiddenFace()` returns the one currently facing away — that's where we paint the incoming image.

### `revealProject(projectIndex)` — the flip
Guards (in order):
1. `if (projectIndex === activeProject && !isRevealing) return;`
2. `if (isRevealing){ nextProject = projectIndex; return; }`  ← queue, don't interrupt a flip in progress.
3. `if (projectIndex === activeProject) return;`

Then:
```js
isRevealing = true; nextProject = null;
const hiddenFace = getHiddenFace();

// paint the incoming image onto the hidden face of every tile...
tiles.forEach(t => setTileImage(t, hiddenFace, PROJECT_IMAGES[projectIndex]));
// ...and reset the side faces to the default image (they're briefly visible mid-rotation)
tiles.forEach(t => { setTileImage(t,"face-right",PROJECT_IMAGES[0]); setTileImage(t,"face-left",PROJECT_IMAGES[0]); });

revealCount++;
activeProject = projectIndex;

gsap.to(".tile", {
  rotateY: revealCount * 180,     // absolute target — accumulates 180° each flip (0→180→360→…)
  duration: 0.5,
  ease: "power3.inOut",
  stagger: { each: 0.05, from: "center", grid: [TILES_Y, TILES_X] },  // [9,12] — radiates from grid center
  onComplete: () => {
    isRevealing = false;
    if (nextProject !== null && nextProject !== activeProject) revealProject(nextProject);
  },
});
```
Key points:
- **Absolute accumulating target:** `rotateY: revealCount*180` (not `"+=180"`). Because the hidden (back) face was just repainted, rotating the cube a half-turn brings that fresh image to the camera while `backface-visibility:hidden` hides the outgoing one.
- **Center-out stagger:** `stagger.from:"center"` with `grid:[9,12]` and `each:0.05` — tiles nearest the grid center flip first, the wave expanding outward; total spread ≈ half the grid's diagonal × 0.05s.
- **Flip:** `duration:0.5`, `ease:"power3.inOut"` (snappy ease-in-out half-turn).
- **Queue drain:** if the user hovered another name mid-flip, the `onComplete` immediately fires the queued `nextProject`.

### Menu wiring (hover, debounced 50ms)
```js
const links = document.querySelectorAll(".project-list a");
links.forEach(link => {
  link.addEventListener("mouseenter", () => {
    links.forEach(l => l.classList.remove("active"));
    link.classList.add("active");
    const idx = parseInt(link.dataset.index);
    clearTimeout(hoverDelay);
    hoverDelay = setTimeout(() => revealProject(idx), 50);
  });
});
document.querySelector(".project-list").addEventListener("mouseleave", () => {
  links.forEach(l => l.classList.remove("active"));
  clearTimeout(hoverDelay);
  hoverDelay = setTimeout(() => revealProject(0), 50);   // flip back to the default image
});
```
- Each `mouseenter` marks its link `.active` (opacity → 1) and schedules a reveal **50ms later**; the `clearTimeout` debounce means sweeping the cursor across several names only triggers the one you land on.
- `mouseleave` on the whole list clears the active state and flips the mosaic **back to `PROJECT_IMAGES[0]`** (project index 0).

### Timing / easing summary
| Action | property | from → to | duration | ease | stagger / delay |
|---|---|---|---|---|---|
| z-float (per tile, looped) | `z` (translateZ) | current → random −40..40px | random 0.6–1.4s | `sine.inOut` | kickoff `i×0.015s`; loops via `onComplete` |
| grid flip | `rotateY` | `(n−1)×180°` → `n×180°` | 0.5 | `power3.inOut` | `each:0.05`, `from:"center"`, `grid:[9,12]` |
| hover debounce | — | — | — | — | `setTimeout` 50ms |
| menu opacity | `opacity` (CSS) | 0.5 → 1 | 0.3s (CSS transition) | — | — |

## Assets / images
**6 full-bleed images, each a 4:3 landscape** (rendered at 720×540 and sliced across the 12×9 grid): one default/rest image plus five project images. Mixed editorial look, ranging from monochrome to saturated. No real brands. By role:
- **Default / rest image** (index 0, shown at rest and between flips): soft monochrome-grey macro of a smooth, rounded white pebble/capsule form nested in a shallow dimple of a matte light-grey surface. Almost colorless — pale greys and gentle shadows.
- **Project 1:** high-contrast black-and-white fashion shot — a woman mid-motion in a flowing white satin slip dress and a long black fringed opera glove, against a dark charcoal-grey studio background.
- **Project 2:** cyberpunk night-city scene — two helmeted figures in silhouette flanking a tall glowing red-orange neon monolith, wrapped in purple-magenta haze over a wet, reflective street, with dark skyscrapers behind. Dominant colors: deep purple, magenta, and hot red-orange.
- **Project 3:** cosmic deep-space nebula — billowing orange and blue gas clouds strewn with stars and a few floating planets/moons. Dominant colors: warm amber-orange against navy and steel blue.
- **Project 4:** a person shot from behind in silhouette, hands raised to the back of the head, rim-lit against a vivid electric-blue backdrop. Near-black subject on saturated cobalt/royal blue.
- **Project 5:** dark, moody still life of three purple bearded irises (yellow beards, green stems) against a deep oxblood-red tiled wall. Dominant colors: violet-purple and rich burgundy red.

Any 4:3 images work; the effect reads best with bold, high-contrast subjects since each is diced into 108 cube faces.

## Behavior notes
- **Desktop / pointer-driven:** the reveal is entirely `mouseenter`/`mouseleave`-based (no scroll, click, or touch path).
- **Race-safe:** a flip mid-flight never gets interrupted — new requests queue into `nextProject` and fire on `onComplete`; the 50ms `setTimeout` debounce plus `clearTimeout` prevents flicker while the cursor crosses names.
- **Two independent transform channels:** the looping `z` float and the discrete `rotateY` flip coexist on every `.tile` without fighting.
- **Fixed grid size:** the 12×9 × 60px layout and 720×540 slice math are interdependent — keep them in sync if resized (the mosaic is not fluid/responsive in the original).
- No reduced-motion branch in the original; the z-float `onComplete` recursion runs forever.
