# Infinite-Scroll Contact Page — Center-Crossing Gap "Breathing" + Icon Cycling

## Goal
Build a full-screen, **endlessly looping** contact page. A tall list of contact rows scrolls
vertically forever (seamless infinite loop). As each row passes through the vertical center of
the viewport, the horizontal **gap between its label and value expands then snaps back**, giving a
"breathing" pulse to whichever row is centered. Simultaneously, a single **3D icon fixed at the
exact center of the screen swaps to the next shape** every time a new row locks onto center. That
center-crossing gap pulse synced with the icon swap is the star effect.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Build as a fresh Vite project (`npm create vite`,
vanilla template). Install and import from npm:
- `gsap` and the GSAP plugin **`ScrollTrigger`** (`import { ScrollTrigger } from "gsap/ScrollTrigger"`), registered via `gsap.registerPlugin(ScrollTrigger)`.
- **`lenis`** for smooth scrolling, used in **infinite mode** (`new Lenis({ infinite: true })`).

No other plugins (no SplitText, no CustomEase, no Three.js). All row animation is driven by
`ScrollTrigger.onUpdate` callbacks that write inline styles — no gsap tweens on the rows.

## Layout / HTML
Two top-level sections inside `<body>`:

1. `<section class="contact-visual">` — the fixed centered stage. Contains a single
   `<div class="contact-icon">` wrapping one `<img>` (the cycling icon; `src` points to the first
   icon asset, `alt=""`).
2. `<section class="contact-info">` — the scrolling content. It holds **8** rows, each a
   `<div class="contact-info-row">` containing exactly two `<p>` elements: a right-aligned label and
   a muted value. Sample content (fictional demo studio — keep as-is, no real brands):
   - `Address` / `19 Great Street`
   - `Current Time` / `20:40:30 (GMT)`
   - `General Inquiries` / `hello@deadspacelabs.com`
   - `New Business Inquiries` / `business@deadspacelabs.com`
   - `Collaborations` / `Selective Connections`
   - `Job Inquiries` / `hiring@deadspacelabs.com`
   - `Telephone` / `+44 09 9842 2235`
   - `Social Media` / `@Deadspace`

The script clones the `.contact-info` section to create the tall loop (see effect section).

## Styling
- **Font:** import `Stylish` from Google Fonts (`@import url("https://fonts.googleapis.com/css2?family=Stylish&display=swap")`); `body { font-family: "Stylish", sans-serif }`.
- **Palette (CSS vars):** `--base-100: #fff` (text), `--base-200: #4f4f4f` (muted value text),
  `--base-300: #0f0f0f` (page background). Body background = `--base-300`, color = `--base-100`.
- **Reset:** `* { margin:0; padding:0; box-sizing:border-box }`. Hide the scrollbar entirely
  (`::-webkit-scrollbar { display:none }`).
- **Images:** `img { width:100%; height:100%; object-fit:cover }`.
- **Paragraphs:** `font-size: 1.4rem; font-weight: 400; line-height: 0.95; letter-spacing: -0.025rem`.
- **`.contact-visual`:** `position: fixed; top:0; left:0; width:100%; height:100svh;` flexbox
  centered (`justify-content:center; align-items:center`); `overflow:hidden`. It sits on top of the
  scrolling content and never moves.
- **`.contact-icon`:** `position: relative; width: 8rem; height: 8rem;` (the icon is 128×128).
- **`.contact-info`:** `position: relative; width:100%; height:100svh;` flex **column**,
  `justify-content:center; gap:1.5rem; overflow:hidden`. Each cloned copy is exactly one viewport
  tall so rows are evenly distributed down the page.
- **`.contact-info-row`:** `display:flex; justify-content:center; gap:1rem; will-change:gap;`
  (the `gap` is what animates — hint it for perf).
- **Row paragraphs:** `.contact-info-row p { flex: 1 }`; first `<p>` (`:nth-child(1)`) is
  `text-align:right`; second `<p>` (`:nth-child(2)`) uses `color: var(--base-200)`.
- **Responsive `@media (max-width:1000px)`:** `p { font-size: 1rem }` and
  `.contact-icon { width:4rem; height:4rem }`.

## GSAP effect (be precise — this is the core)

### 0. Smooth-scroll + infinite loop plumbing
```
const lenis = new Lenis({ infinite: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
```
`infinite: true` makes the page wrap seamlessly — scrolling past the end returns to the start with
no visible seam, so the row/icon animation runs forever in both directions.

### 1. Clone the content into a tall column
- Read `const contactRowMaxGap = window.innerWidth < 1000 ? 5 : 10;` (max gap in **rem**; 10 on
  desktop, 5 on mobile).
- Take the single `.contact-info` section and **clone it 10 times**, appending each deep clone
  (`cloneNode(true)`) to its parent (`<body>`). Result: **11** identical `.contact-info` sections
  stacked (1 original + 10 clones), i.e. **88** total `.contact-info-row` elements. This gives the
  infinite scroll enough length to loop smoothly.
- **After** cloning, query all rows: `const contactRows = document.querySelectorAll(".contact-info-row")`.

### 2. Per-row gap pulse (two scrubbed ScrollTriggers each — the breathing effect)
Compute the vertical center of the fixed stage with a helper re-read on every refresh:
```
function getVisualCenter() {
  return contactVisual.offsetTop + contactVisual.offsetHeight / 2;
}
```
(`.contact-visual` is fixed at top:0, so this ≈ half the viewport height.)

For **each** of the 88 rows, create **two** `ScrollTrigger`s (no tweens — pure `onUpdate`
callbacks writing `row.style.gap`). Both use **functional** start/end so they recompute on
`refresh`, `scrub: true`, and the scroller-position keyword `center` (the viewport's vertical
middle):

**Trigger A — EXPAND (gap 1rem → maxGap):**
- `trigger: row`
- `start: () => \`top+=${getVisualCenter() - 550} center\``
- `end:   () => \`top+=${getVisualCenter() - 450} center\``
- `scrub: true`
- `onUpdate: (self) => { const gap = 1 + (contactRowMaxGap - 1) * self.progress; row.style.gap = \`${gap}rem\`; }`
- Effect: over a **100px** scroll band as the row rises toward center, gap interpolates linearly
  from `1rem` up to `maxGap` (10rem desktop / 5rem mobile).

**Trigger B — CONTRACT (gap maxGap → 1rem):**
- `trigger: row`
- `start: () => \`top+=${getVisualCenter() - 400} center\``
- `end:   () => \`top+=${getVisualCenter() - 300} center\``
- `scrub: true`
- `onUpdate: (self) => { const gap = contactRowMaxGap - (contactRowMaxGap - 1) * self.progress; row.style.gap = \`${gap}rem\`; }`
- Effect: over the **next 100px** band, gap interpolates linearly from `maxGap` back down to `1rem`.

Notes on the band geometry: the four hardcoded pixel offsets are `-550, -450, -400, -300` relative
to `getVisualCenter()`. So relative to the moment the row reaches center there is: a 100px **expand**
window, a ~50px **hold** at max gap (between the `-450` end of A and the `-400` start of B), then a
100px **contract** window. The net feel: each row's two columns fan apart as the row nears the
screen center and slam back together just after — a rhythmic pulse row after row. Because it's
scrubbed, it's fully reversible and tied 1:1 to scroll position/velocity (no ease, linear by
`self.progress`).

### 3. Center-locked icon cycling (swap the fixed 3D icon)
Target `const contactIcon = document.querySelector(".contact-icon img")`. Keep two state vars:
`let currentIconIndex = 1;` and `let lastCenteredRow = null;`.

On **`lenis.on("scroll", ...)`** (a second scroll listener), each frame:
```
const viewportCenter = window.innerHeight / 2;
let closestRow = null, minDistance = Infinity;
contactRows.forEach((row) => {
  const rect = row.getBoundingClientRect();
  const rowCenter = rect.top + rect.height / 2;
  const distance = Math.abs(rowCenter - viewportCenter);
  if (distance < minDistance && distance < 25) { minDistance = distance; closestRow = row; }
});
if (closestRow && closestRow !== lastCenteredRow) {
  lastCenteredRow = closestRow;
  currentIconIndex = (currentIconIndex % 7) + 1;      // cycle 1→2→…→7→1
  contactIcon.src = `/path/to/icon_${currentIconIndex}.png`;
}
```
So: whenever a **new** row comes within **25px** of the exact viewport center, advance the icon to
the next of **7** shapes (wrapping 7→1) and set the `<img>` src. The icon change is a hard image
swap (no crossfade), tightly synchronized with each row hitting center — which is also the moment
that row's gap pulse peaks.

## Assets / images
**7** files (`icon_1.png` … `icon_7.png`), all **PNG on a fully transparent background**, **square
1:1** aspect ratio, displayed at 128×128 (8rem). They are glossy **3D-rendered "Y2K / chrome"
glyphs**, each a distinct figurative sculpture with strong specular highlights and iridescent
edge reflections. The set holds **6 unique subjects** (`icon_7.png` is a duplicate of `icon_1.png`):
1. **`icon_1.png` / `icon_7.png`** — a chrome/liquid-metal **robotic hand and forearm** reaching
   up, mirror-silver body with hot pink, red and violet iridescent streaks. This is the one shown
   on load.
2. **`icon_2.png`** — a **furry/fuzzy hollow diamond (rhombus) outline**, dominant vivid orange
   fur with lighter yellow-orange highlights.
3. **`icon_3.png`** — a glossy chrome **smiley-face outline** (two oval eyes + a smile), purple and
   magenta metallic finish with pink and blue reflections on black.
4. **`icon_4.png`** — an inflated, balloon-like glossy **bubble letter "W"**, deep electric blue /
   indigo chrome with white specular highlights.
5. **`icon_5.png`** — a translucent **wireframe globe / gridded sphere** (latitude + longitude
   lines), iridescent green-to-teal with faint pink chromatic fringing.
6. **`icon_6.png`** — a multi-pointed **3D star pierced by an orbiting ring**, purple-to-magenta
   chrome with blue highlights.
No brand marks; the figurative shapes are decorative studio glyphs. Reference each as
`icon_${n}.png` from your assets path.

## Behavior notes
- **Desktop + mobile:** the only responsive change is `contactRowMaxGap` (10 vs 5 rem, chosen once
  at load by `window.innerWidth < 1000`) plus the CSS font-size/icon-size media query.
- The effect is **scroll-driven and infinite** — it never idles; with Lenis `infinite: true` the
  content wraps forever, so both the gap pulse and icon cycling repeat indefinitely as you scroll
  either direction.
- Order matters: **clone first, then query `.contact-info-row`, then create the ScrollTriggers** so
  every cloned row is animated.
- The `.contact-visual` icon layer is `position: fixed` and stays dead-center; only its `<img>` src
  changes.
