# Magnetic Cards — Cursor-Reactive Physics Fan

## Goal
Build a single full-viewport dark section holding a **fanned stack of four square image cards centered on screen**. The star effect: the cards react **magnetically to cursor velocity**. When the mouse moves quickly near the stack, the cards nearest the pointer are shoved and tilted in the direction of the swipe; slower/farther motion barely nudges them. Neighboring cards get dragged along a little, and when the cursor stops or leaves, every card springs back to its resting fan layout with an elastic, slightly-bouncy settle. There is **no GSAP tween or timeline** — the whole thing is a hand-rolled spring-and-friction physics integrator run every frame by `gsap.ticker`, with `gsap.set` writing the transforms.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm) only** — **no GSAP plugins, no Lenis, no Three.js, no smooth scroll**. GSAP is used purely for `gsap.set()` (write transforms) and `gsap.ticker.add()` (the per-frame loop). 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
```html
<section class="spotlight">
  <div class="cards">
    <div class="card"><img src="<card image 1>" alt="" /></div>
    <div class="card"><img src="<card image 2>" alt="" /></div>
    <div class="card"><img src="<card image 3>" alt="" /></div>
    <div class="card"><img src="<card image 4>" alt="" /></div>
  </div>
</section>
```
- One `.spotlight` section is the interaction surface (mouse events bind here).
- One `.cards` wrapper is the geometric center reference — it is centered in the section and every card is positioned **relative to this wrapper's center** (not the viewport).
- Exactly **four `.card` elements**, each wrapping one `<img>`. Card order in the DOM (1→4) is left→right in the fan and also determines `zIndex` (later cards render on top).

## Styling
- Reset: `* { margin:0; padding:0; box-sizing:border-box; }`
- `img { width:100%; height:100%; object-fit:cover; }`
- `.spotlight { position:relative; width:100%; height:100svh; background-color:#141414; overflow:hidden; }` — near-black background; **`overflow:hidden` matters** so cards that get flung outward are clipped by the section.
- `.cards { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }` — a zero-size anchor pinned to the exact center of the section. (Note: JS overwrites the cards' own transforms via `gsap.set`, but this wrapper keeps its centering transform.)
- `.card { position:absolute; width:250px; height:250px; border-radius:1rem; overflow:hidden; }` — **fixed 250×250 (1:1) squares**, rounded corners, image clipped to the rounded box. All four cards are absolutely positioned and stacked at the wrapper origin, then offset by JS.

No web fonts, no text — the section is purely the four image cards on the dark field.

## GSAP effect (be exhaustive)

### Imports & element handles
```js
import gsap from "gsap";

const cards = document.querySelectorAll(".card");
const spotlight = document.querySelector(".spotlight");
const cardsContainer = document.querySelector(".cards");
```

### Tuning constants (exact values)
```js
const PROXIMITY_RADIUS  = 500;   // px — cursor must be within this of a card to push it
const PUSH_FORCE        = 10;    // multiplier on cursor velocity → push strength
const TILT_AMOUNT       = 0.1;   // how much horizontal push force converts to rotation
const NEIGHBOR_INFLUENCE= 0.2;   // per-step falloff of force bleeding to neighbor cards
const SPRING_STIFFNESS  = 0.05;  // spring constant pulling a card back toward its target
const BOUNCE_FRICTION   = 0.85;  // velocity retained each frame (damping)
const CURSOR_SMOOTHING  = 0.75;  // low-pass on cursor velocity (0.75 old / 0.25 new)
```

### Resting fan layout (per card, index 0→3)
```js
const layout = {
  rotation: [5, -5, 7.5, -10],   // degrees
  x:        [-275, -100, 100, 275], // px offset from the .cards center
  y:        [10, -10, 25, -10],     // px offset from the .cards center
};
```
So at rest the four squares fan across the center: card 0 far-left rotated +5°, card 1 left rotated −5°, card 2 right rotated +7.5°, card 3 far-right rotated −10°, with small vertical jitter. They overlap because each is 250px wide but only ~175–200px apart.

### Cursor state & per-card physics state
```js
const cursor = { x: 0, y: 0, vx: 0, vy: 0 };
let prevCursorX = 0;
let prevCursorY = 0;
```
Initialize each card with `gsap.set` and build a physics object:
```js
const cardPhysics = [...cards].map((el, i) => {
  gsap.set(el, {
    x: layout.x[i],
    y: layout.y[i],
    rotation: layout.rotation[i],
    zIndex: i,          // DOM order stacking
    xPercent: -50,      // center each card on the .cards origin
    yPercent: -50,
  });
  return {
    el,
    restX: layout.x[i], restY: layout.y[i], restR: layout.rotation[i], // targets
    x: layout.x[i], y: layout.y[i], r: layout.rotation[i],             // current
    vx: 0, vy: 0, vr: 0,                                               // velocities
  };
});
```
Note `xPercent:-50, yPercent:-50` — cards are centered on the wrapper origin, then translated by the layout `x`/`y`.

### Cursor velocity tracking (`mousemove` on `.spotlight`)
On every `mousemove`, compute a **smoothed cursor velocity** with an exponential low-pass filter, then store the raw position:
```js
spotlight.addEventListener("mousemove", (e) => {
  cursor.vx = cursor.vx * CURSOR_SMOOTHING + (e.clientX - prevCursorX) * (1 - CURSOR_SMOOTHING);
  cursor.vy = cursor.vy * CURSOR_SMOOTHING + (e.clientY - prevCursorY) * (1 - CURSOR_SMOOTHING);
  prevCursorX = cursor.x = e.clientX;
  prevCursorY = cursor.y = e.clientY;
});
```
So `vx/vy` are 75% previous velocity + 25% of the latest per-event pixel delta — a heavily smoothed, momentum-ish swipe velocity in screen pixels.

On `mouseleave`, zero the velocity so the push force decays to nothing and the cards spring home:
```js
spotlight.addEventListener("mouseleave", () => { cursor.vx = cursor.vy = 0; });
```

### Per-card push force (`calculatePushForce(card)`)
For each card, compute the force the cursor exerts this frame:
1. `speed = Math.sqrt(cursor.vx**2 + cursor.vy**2)`. **If `speed < 0.5`, return `{fx:0, fy:0}`** — a still (or barely moving) cursor exerts no force, no matter how close.
2. Get the live center of the stack from the DOM: `rect = cardsContainer.getBoundingClientRect()`, then the card's resting center in screen space is `cx = rect.left + rect.width/2 + card.restX`, `cy = rect.top + rect.height/2 + card.restY`.
3. `dist = Math.sqrt((cursor.x - cx)**2 + (cursor.y - cy)**2)`. **If `dist > PROXIMITY_RADIUS` (500px), return `{fx:0, fy:0}`.**
4. Proximity weight with a **cubic falloff**: `weight = (1 - dist / PROXIMITY_RADIUS) ** 3` — 1 at the card's center, easing sharply toward 0 at 500px.
5. Force = smoothed cursor velocity × push constant × weight:
   ```js
   return { fx: cursor.vx * PUSH_FORCE * weight, fy: cursor.vy * PUSH_FORCE * weight };
   ```
The push is **in the direction the cursor is moving** (velocity vector), scaled by how fast and how close.

### Neighbor influence (`applyNeighborInfluence(forces, index)`)
Each card also feels a fraction of every *other* card's force, so a shove propagates through the fan:
```js
function applyNeighborInfluence(forces, index) {
  let fx = forces[index].fx;
  let fy = forces[index].fy;
  forces.forEach((f, j) => {
    if (j === index) return;
    const falloff = NEIGHBOR_INFLUENCE ** Math.abs(j - index); // 0.2^distance
    fx += f.fx * falloff;
    fy += f.fy * falloff * 0.6;   // vertical bleed is dampened to 60%
  });
  return { fx, fy };
}
```
`0.2 ** |j-index|` means an adjacent card contributes 20% of its force, a two-away card 4%, etc. Vertical influence is further multiplied by 0.6 so neighbors drag more horizontally than vertically.

### The per-frame integrator (`gsap.ticker.add`)
The whole simulation runs inside one ticker callback (≈60fps), **not a tween**:
```js
gsap.ticker.add(() => {
  const forces = cardPhysics.map(calculatePushForce);   // raw force per card this frame

  cardPhysics.forEach((card, i) => {
    const { fx, fy } = applyNeighborInfluence(forces, i); // + neighbor bleed

    // Damped spring toward (rest + force). Velocity is integrated then scaled by friction.
    card.vx = (card.vx + (card.restX + fx - card.x) * SPRING_STIFFNESS) * BOUNCE_FRICTION;
    card.vy = (card.vy + (card.restY + fy - card.y) * SPRING_STIFFNESS) * BOUNCE_FRICTION;
    card.vr = (card.vr + (card.restR + fx * TILT_AMOUNT - card.r) * SPRING_STIFFNESS) * BOUNCE_FRICTION;

    card.x += card.vx;
    card.y += card.vy;
    card.r += card.vr;

    gsap.set(card.el, { x: card.x, y: card.y, rotation: card.r });
  });
});
```
Read the spring line carefully — it is the heart of the effect:
- The **target** for each card is `restX + fx` (its home position displaced by the current push). Rotation targets `restR + fx * TILT_AMOUNT`, so a **horizontal** push also tilts the card (leftward shove rotates one way, rightward the other), by 10% of the horizontal force.
- `(target - current) * SPRING_STIFFNESS` (0.05) is the spring pull added to the existing velocity → an under-damped spring that overshoots and oscillates.
- Multiplying the whole velocity by `BOUNCE_FRICTION` (0.85) each frame bleeds off 15% per frame, so oscillations decay and the card settles — giving the springy, slightly-bouncy return. Higher friction toward 1 = looser/bouncier; this 0.85 gives a quick but visibly elastic settle.
- Because the force term (`fx`,`fy`) drops to 0 the instant the cursor slows below `speed 0.5` / leaves / moves beyond 500px, the target collapses back to `rest*`, and the same spring carries every card home.

### Feel summary
- Fast swipe **through** the stack: nearby cards fling in the swipe direction and tilt, neighbors trail, then all bounce back.
- Slow drift: almost no motion (velocity < 0.5 or cubic falloff kills it).
- Cursor leaves the section: velocity zeroed, cards spring home.
- No easing functions, no durations, no timeline — spring stiffness (0.05) and friction (0.85) fully define the motion, integrated every ticker frame.

## Assets / images
**Four square (1:1) card images**, each filling a 250×250 rounded box (`object-fit:cover`). Use **flat, graphic, single-figure character illustrations** in a muted vector/editorial poster style — one hooded or costumed figure per card, waist-up or full-body, centered on a flat, softly-textured single-color background, with subtle grain and simple shading (no photos, no text, no client or third-party branding). The four are distinct, earthy-toned action characters, each on its own muted field:
- **Card 1 (leftmost):** a hooded figure in an olive-green tunic gripping a long-handled scythe, on a pale warm grey-green (sage/tan) background.
- **Card 2:** a man in a tan trench coat, white shirt, dark tie and black glasses, shown in profile pointing to the right, on a desaturated dark teal/slate-green background.
- **Card 3:** a black-clad ninja crouching with a dagger, on a muted steel-blue background.
- **Card 4 (rightmost):** a grey-hooded, cloaked archer drawing a bow with arrows on the back, on a warm off-white/cream background.

Card 1 is the leftmost in the fan, card 4 the rightmost. Any four distinct, high-contrast single-figure illustrations of this kind read well — the specific characters and muted backgrounds are interchangeable so long as each card is one bold figure on a flat, low-saturation field.

## Behavior notes
- **Desktop / pointer-driven only** — the entire effect is `mousemove` velocity on the `.spotlight` section; there is no touch, click, scroll, or load animation, and no reduced-motion branch (the ticker always runs).
- **Live-anchored math**: the stack center is re-read from `getBoundingClientRect()` every frame, so resizing the window keeps the physics correctly centered without extra code.
- The cards are a fixed 250px fan; the `x`/`y`/`rotation` layout arrays and the seven tuning constants define the exact look and response — keep them as given.
