# Scroll-Revealed ASCII Footer

## Goal
Build a long-scroll page whose **fixed, full-viewport footer is revealed from behind the page** as you reach the bottom. The star effect is the reveal choreography that fires when the footer scrolls into view: the giant two-word heading (`Blank` / `Canvas`) **slides its letters up from a masked baseline, staggered from the center outward**; the footer nav links and paragraph **un-mask line by line**; and two big **hand images slide in from the left and right edges**. Those hands are not shown as photos — each is **rasterized live into orange ASCII art on a `<canvas>`**, and hovering the hands **lights up random clusters of characters** in bright orange while a lerped **mouse-parallax** drifts and scales both hands. Smooth scroll via Lenis.

## Tech
Vanilla HTML/CSS/JS with ES module imports (`<script type="module">`). Use `gsap` (npm) plus the GSAP plugins **`ScrollTrigger`** and **`SplitText`**, and **`lenis`** for smooth scroll. Register once: `gsap.registerPlugin(ScrollTrigger, SplitText)`. No framework. Runs in a fresh Vite project. The ASCII rendering, hover highlight, and parallax are all **hand-written Canvas 2D + `requestAnimationFrame`** — GSAP only drives the reveal/hide of the heading, lines, and the hands' slide-in offset.

## Layout / HTML
Three full-height opaque spacer sections, then a **spacer `div.footer-revealer`** (this is the ScrollTrigger target — it does not render anything itself), then the fixed `<footer>`:

```html
<section class="one"><h1>One</h1></section>
<section class="two"><h1>Two</h1></section>
<section class="three"><h1>Three</h1></section>

<div class="footer-revealer"></div>

<footer>
  <div class="footer-images">
    <div class="footer-hand-img">
      <img class="ascii-hand" src="/images/hand-left.jpg" alt="" />
    </div>
    <div class="footer-hand-img">
      <img class="ascii-hand" src="/images/hand-right.jpg" alt="" />
    </div>
  </div>

  <div class="footer-content">
    <nav class="footer-links">
      <a href="#">Work</a>
      <a href="#">About</a>
      <a href="#">Journal</a>
      <a href="#">Contact</a>
    </nav>
    <div class="footer-text">
      <p>A multidisciplinary studio working across direction, design and
         motion. We build considered digital experiences for brands that
         care about the details.</p>
    </div>
  </div>

  <div class="footer-header">
    <h1>Blank</h1>
    <h1>Canvas</h1>
  </div>
</footer>

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

Classes the JS/CSS depend on: `.footer-revealer` (scroll trigger), `.footer-images`, `.footer-hand-img` (the two wrappers that get the parallax/slide transform), `img.ascii-hand` (each hidden source image + injected `<canvas>` sibling), `.footer-content`, `.footer-links a`, `.footer-text p` (line-split), `.footer-header h1` (char-split). The two `<canvas>` elements are **created in JS** and appended into each `.footer-hand-img`.

## Styling

**Font** — Google Fonts `Instrument Sans` (italic + weight 400..700). `body { font-family: "Instrument Sans" }`. Global reset `* { margin:0; padding:0; box-sizing:border-box }`.

**Color tokens**
- Section bg `#1b1b1b`, section text `#ff6a00` (orange).
- Footer bg `#0f0f0f` (near-black), footer text `#fff`.
- ASCII base char color `#803500` (dark burnt orange), hover highlight fill `#ff6a00` (bright orange), highlighted char color `#0f0f0f`.

**Sections** — `position: relative; z-index: 1; width: 100%; height: 100svh; background: #1b1b1b; color: #ff6a00; padding: 2rem; display: flex; justify-content: center; align-items: center; overflow: hidden`. `section h1 { font-size: clamp(2rem,5vw,8rem); font-weight: 500; letter-spacing: -2% }`. The `z-index:1` + opaque bg is what makes the sections **cover the fixed footer** while scrolling.

**.footer-revealer** — `position: relative; width: 100%; height: 100svh`. A transparent full-height spacer; scrolling it into view exposes the fixed footer sitting behind it.

**footer** — `position: fixed; top: 0; left: 0; width: 100%; height: 100svh; background: #0f0f0f; overflow: hidden; z-index: 0`. Fixed and behind everything; revealed as the sections/revealer scroll past.

**.footer-images** — `position: absolute; inset: 0; display: flex; justify-content: space-between; align-items: center` (pins one hand to each edge).

**.footer-hand-img** — `position: relative; width: 40%; min-width: 200px; will-change: transform`. This is the element JS transforms every frame.
- `.footer-hand-img img { display: block; width: 100%; opacity: 0 }` — the source photo is **never visible**; it only feeds pixels to the canvas and defines the wrapper's height.
- `.footer-hand-img canvas { position: absolute; inset: 0; width: 100%; height: 100% }` — the ASCII output, overlaid on the (invisible) img.

**.footer-content** — `position: absolute; top: 0; left: 0; width: 100%; padding: 2rem; display: flex; justify-content: space-between; gap: 2rem; color: #fff`.
- `.footer-links { display: flex; flex-direction: column; gap: 0.25rem }`, `.footer-links a { color: #fff; text-decoration: none; font-size: 1.1rem }`.
- `.footer-text { max-width: 28rem }`, `.footer-text p { font-size: 1.1rem; line-height: 1.4 }`.

**.footer-header** — `position: absolute; bottom: 0; left: 0; width: 100%; padding: 2rem; display: flex; justify-content: space-between; align-items: flex-end; color: #fff`.
- `.footer-header h1 { font-size: clamp(5rem,15vw,15rem); font-weight: 500; line-height: 1; letter-spacing: -2%; overflow: hidden }`. The `overflow: hidden` is the **mask** the char slide-up plays inside.

**Responsive** `@media (max-width:1000px)`: `.footer-content { flex-direction: column }`; `.footer-text { max-width: 100% }`; `.footer-header h1 { font-size: 3rem }`.

## The effect (be exhaustive)

### 1. Lenis smooth scroll wiring
```
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
```

### 2. Split the text (initial hidden states)
- **Heading chars.** `SplitText.create` each `.footer-header h1` with `{ type: "chars", charsClass: "char" }`; collect all `.chars`. Then `gsap.set(chars, { position: "relative", yPercent: 125 })` — every letter starts pushed **125% below** its line, clipped by the `h1`'s `overflow:hidden`.
- **Content lines.** `SplitText.create` each `.footer-links a` and `.footer-text p` with `{ type: "lines", mask: "lines", linesClass: "line" }` (the `mask:"lines"` wraps each line in its own overflow-hidden clip). Collect all `.lines`, then `gsap.set(lines, { yPercent: 100 })` — each line starts one full line-height **below** its mask.

### 3. ASCII canvas rendering (per hand)
Exact constants:
```
ASCII_CHARS   = "........:::=+xX#0369"   // 20-glyph ramp: 8 dots, then :::=+xX#0369
FONT_SIZE     = 18       // px, monospace
CELL_SIZE     = 20       // px per grid cell (canvas-logical)
ASCII_COLUMNS = 80       // fixed grid width in cells
DPR           = 2        // fixed device-pixel-ratio multiplier
CHAR_COLOR      = "#803500"
HOVER_COLOR     = "#ff6a00"
HOVER_CHAR_COLOR= "#0f0f0f"
backgroundCharIndex = ASCII_CHARS.lastIndexOf(".")   // = 7
```

**Sampling → cells.** For each hand image:
- `rows = Math.round(ASCII_COLUMNS / (naturalWidth / naturalHeight))` (square 1:1 sources → 80 rows).
- Draw the image into an offscreen canvas sized `ASCII_COLUMNS × rows` (**one pixel per cell**, extreme downscale), read `getImageData(0,0,ASCII_COLUMNS,rows).data`.
- For every `col,row`, perceptual brightness of that pixel: `(R*0.299 + G*0.587 + B*0.114) / 255`. Char index = `Math.min(ASCII_CHARS.length-1, Math.floor((1 - brightness) * ASCII_CHARS.length))` — note **inverted** brightness, so dark pixels map to the dense end of the ramp and bright pixels to dots.
- **Skip** any cell whose `charIndex <= backgroundCharIndex` (7): the near-white background of the source is discarded, only the darker hand renders. Store surviving cells in a `Map` keyed `"col,row"` → `{ col, row, char: ASCII_CHARS[charIndex], highlightEndTime: 0 }`.

**Canvas setup.** Append a `<canvas>` into the hand wrapper. `canvas.width = ASCII_COLUMNS*CELL_SIZE*DPR` (3200), `canvas.height = rows*CELL_SIZE*DPR`. `ctx.setTransform(DPR,0,0,DPR,0,0)`. `ctx.font = "18px monospace"`, `textAlign:"center"`, `textBaseline:"alphabetic"`. Compute a vertical baseline offset from `measureText("X")` so glyphs sit centered in their cell: `baselineOffset = CELL_SIZE/2 + glyphHeight/2 - actualBoundingBoxDescent` where `glyphHeight = actualBoundingBoxAscent + actualBoundingBoxDescent`.

**Render loop (`requestAnimationFrame`, per hand, forever).** Clear the canvas. For each cell: `x = col*CELL_SIZE`, `y = row*CELL_SIZE`. If `highlightEndTime > Date.now()` → fill a `CELL_SIZE×CELL_SIZE` `HOVER_COLOR` rect at `(x,y)` and draw the char in `HOVER_CHAR_COLOR`; otherwise draw the char in `CHAR_COLOR`. Draw at `(x + CELL_SIZE/2, y + baselineOffset)`.

Start each hand once its image is decoded (`img.complete && img.naturalWidth ? start() : img.addEventListener("load", start)`).

### 4. Hover cluster highlight (mousemove → random walk)
Constants: `HOVER_RADIUS = 8` (cells), `CLUSTER_SIZE = 10`, `HIGHLIGHT_LIFETIME = 300` (ms).
On `window` `mousemove`, for each hand: map the cursor into grid space via the canvas `getBoundingClientRect()` (`mouseCol = (clientX-rect.left)/rect.width * ASCII_COLUMNS`, `mouseRow = (clientY-rect.top)/rect.height * rows`), find the nearest surviving cell by Euclidean distance. If that distance `<= HOVER_RADIUS`, ignite a cluster:
- Seed cell: `highlightEndTime = now + HIGHLIGHT_LIFETIME`.
- Take a **random-length walk** of `steps = floor(random()*CLUSTER_SIZE)+1` (1..10). At each step, gather the up-to-8 neighbours (dx,dy ∈ {-1,0,1}, excluding self and already-lit), pick one at random, set its `highlightEndTime = now + HIGHLIGHT_LIFETIME + step*10` (each hop lingers slightly longer), advance to it. Stop early if no unlit neighbour exists.

Net feel: sweeping the cursor over a hand paints short branching streaks of bright-orange lit glyphs that fade out ~300 ms later.

### 5. Mouse parallax (lerped, per-hand, `requestAnimationFrame`)
Constants: `PARALLAX_STRENGTH = 20`, `PARALLAX_EASE = 0.05`, `parallaxScale = 1 + (PARALLAX_STRENGTH*2)/200` (= **1.2**).
- `reveal = { left: -125, right: 125 }` — the two hands' slide-in offset **in %** (GSAP animates these; see §6).
- On `window` `mousemove`, set a `pointer` target from the footer's rect: `pointer.x = ((clientX-rect.left)/rect.width - 0.5) * PARALLAX_STRENGTH*2` (range ≈ ±20px), same for `pointer.y`.
- A rAF loop eases a `drift` toward `pointer`: `drift.x += (pointer.x - drift.x) * PARALLAX_EASE` (0.05), same for y.
- Each frame, for wrapper `i` (0 = left hand, 1 = right hand): `direction = i===0 ? 1 : -1`, `revealX = i===0 ? reveal.left : reveal.right`, `x = drift.x * direction`, `y = -drift.y`. Write `wrapper.style.transform = translate(calc(${x}px + ${revealX}%), ${y}px) scale(1.2)`. So the two hands drift in **opposite** horizontal directions with the mouse, share an inverted vertical drift, sit at 1.2× scale, and their `revealX` (`±125% → 0`) is what slides them on/off screen.

### 6. The reveal — ScrollTrigger callbacks (the star)
No scrub, no pin — two plain ScrollTriggers on `.footer-revealer` fire the in/out timelines via callbacks:
```
ScrollTrigger.create({ trigger: ".footer-revealer", start: "top 50%", onEnter: animateIn });
ScrollTrigger.create({ trigger: ".footer-revealer", start: "top 85%", onLeaveBack: animateOut });
```

**`animateIn`** (all fired together, all `overwrite: true`):
- `gsap.to(reveal, { left: 0, right: 0, duration: 1, ease: "power3.out" })` — hands slide in from `±125%` to flush at the edges (the parallax loop reads `reveal` live).
- `gsap.to(headingChars, { yPercent: 0, duration: 1, ease: "power3.out", stagger: { each: 0.04, from: "center" } })` — letters rise from the masked baseline, rippling out from the **center** of each word.
- `gsap.to(contentLines, { yPercent: 0, duration: 1, ease: "power3.out", stagger: 0.08 })` — nav links + paragraph lines un-mask upward in sequence.

**`animateOut`** (mirror, faster, `power2.in`, all `overwrite: true`):
- `gsap.to(reveal, { left: -125, right: 125, duration: 0.4, ease: "power2.in" })`.
- `gsap.to(headingChars, { yPercent: 125, duration: 0.4, ease: "power2.in", stagger: { each: 0.01, from: "center" } })`.
- `gsap.to(contentLines, { yPercent: 100, duration: 0.4, ease: "power2.in", stagger: 0.02 })`.

So scrolling down past the revealer's `top 50%` plays the full reveal; scrolling back up through `top 85%` snaps it all back out (hands off-screen, letters/lines re-masked).

## Assets / images
- **2 distinct hand photographs, square 1:1** (source ~1440×1440), one per edge — not a single mirrored image. Both are the same bare human hand shot in **grayscale on a near-white, softly-graded pale-grey studio background**, the hand rendered in **light-to-mid greys** (skin, no jewelry) with the darkest values in the finger creases, knuckles and cast shadow. The two poses differ:
  - **`hand-left.jpg` (left edge)** — back of an open hand seen from above, filling the upper-right of the frame, the four fingers extended roughly **horizontally toward the right** with the thumb angling down; wrist exits the left side.
  - **`hand-right.jpg` (right edge)** — the same hand viewed nearly **edge-on / in profile**, a slim horizontal sliver of fingers pointing **left** with the thumb dropping below, wrist exiting the right side; more empty background than the left image.
- Both are loaded but rendered fully transparent (`opacity:0`) — each exists only as a **brightness source** the script rasterizes into the orange ASCII canvas, so the load-bearing property is the contrast between the light/mid-grey hand and the brighter background (the background samples out to dots and is discarded, leaving only the hand as characters). No brand imagery; any neutral grayscale hand-on-light-background pair works — one hand reaching in from the left, a second from the right.

## Behavior notes
- The reveal is **callback-triggered, not scrubbed** — it plays as a fixed-duration timeline once the threshold is crossed, and reverses on leave-back; re-entering replays it.
- The ASCII render loops, hover highlight, and parallax run **continuously** from load (no reduced-motion guard in the original). The hover/parallax read from `window` mousemove, so they are pointer-only; on touch the hands still render and slide in but sit static.
- `DPR` is hard-coded to 2 (not `devicePixelRatio`) and the grid is a fixed 80 columns, so the ASCII resolution is constant regardless of screen; the CSS scales the canvas to fit the wrapper.
- The three opaque full-height sections + the transparent revealer are what sell the "footer emerges from behind the page" illusion, combined with the hands sliding in and the heading/lines un-masking.
