# Nuvoro Page Transitions — View Transitions API + GSAP revealer + masked SplitText

## Goal

Build a minimal editorial four-view single-page site (home, work, studio, contact) for a fictional studio called "nuvoro". The star effect: clicking a nav link swaps views through the native **View Transitions API** — the incoming page is unmasked by a full-screen `clip-path` polygon that expands from a thin horizontal slit at 75% viewport height to the whole screen over 2s — while a dark GSAP "revealer" overlay wipes away vertically and the new view's heading reveals through masked SplitText chars/words/lines.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins **SplitText** and **CustomEase**, and **`lenis`** (npm) for smooth scrolling. Register the plugins with `gsap.registerPlugin(SplitText, CustomEase)`. No frameworks, no build-specific code beyond ES imports (assume a Vite dev server).

## Layout / HTML

- Fixed top **nav** (`.nav`) spanning the viewport width with two columns:
  - `.col` 1 (flex: 1): `.nav-logo` containing `<a href="/" data-path="/">nuvoro</a>`.
  - `.col` 2 (flex: 2, `display:flex; justify-content: space-between`): `.nav-items` with three `.nav-item` divs — `work`, `studio`, `contact` — each wrapping an `<a href="/work" data-path="/work">`-style anchor, plus a `.nav-copy` with `<p>toronto, ca</p>`. **`.nav-items` has NO layout rule of its own — do NOT make it a horizontal flex row.** Since the anchors are `display:block` (~0.85rem), the three `.nav-item` divs stack **vertically** as a compact column list sitting at the left edge of this second column, while the column's `justify-content: space-between` pushes `.nav-copy` ("toronto, ca") to the right edge.
- `<main id="views">` containing four `<section class="view" data-path="...">` elements. Only the home view (`data-path="/"`) is visible initially; the other three carry the `hidden` attribute. **Every view's first child is an empty `<div class="revealer"></div>`** (the dark wipe overlay).
  - **Home** (`data-path="/"`): `.home` wrapper with `.header > h1` reading `nuvoro` (huge, centered) and `.hero-img > img` (full-width image anchored to the bottom of the viewport).
  - **Work** (`data-path="/work"`): `.work` wrapper with `<h1>selected work</h1>` and `.projects` containing 4 `<img>` stacked vertically.
  - **Studio** (`data-path="/studio"`): `.studio` wrapper with two `.col`s — first holds `<h2 class="studio-header">Our Story</h2>`, second holds a long `<h2>` paragraph (4–5 lines of studio-manifesto copy about creativity, bold ideas and crafted storytelling) followed by `.about-img > img`.
  - **Contact** (`data-path="/contact"`): `.contact` wrapper with two `.col`s — first holds `<h2>Contact Us</h2>`, second holds two `.contact-copy` blocks (each two `<h2>`s: a label like "Collaborations"/"Inquiries" and an email like `studio@nuvoro.com` / `support@nuvoro.com`) plus a `.socials` row of three `<p>`s (Instagram, Twitter, LinkedIn) pinned to the bottom of the viewport.

## Styling

- CSS variables: `--bg: #fff`, `--fg: #0a0a0a`. Global reset (`* { margin:0; padding:0; box-sizing:border-box }`).
- Font: **Inter variable** from Google Fonts. The import MUST include the optical-size (`opsz`) axis — use exactly:
  ```
  @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
  ```
  then `body { font-family: "Inter"; color: var(--fg) }`. **Why `opsz` is load-bearing:** the home wordmark is set at `font-size: 30.5vw` (~439px on a 1440px viewport). With the `opsz` axis present, `font-optical-sizing: auto` snaps Inter to its narrower display metrics and "nuvoro" fits 100vw on a single line. Without it, the word measures wider than the viewport — and because SplitText's mask wraps every char in an `inline-block` (each one a legal line-break point), the final "o" wraps onto a second line and the header (positioned `top:25%` with `translate(-50%,-50%)`) gets cropped at the top of the viewport.
- All `img`: `width:100%; height:100%; object-fit:cover`.
- Type scale: `h1` 4.25rem / weight 600 / letter-spacing -0.1rem / line-height 1. `h2` 2rem / weight 700 / letter-spacing -0.04rem / line-height 1.125 / antialiased. `a, p` display block, no underline, 0.85rem / weight 600, color `--fg`.
- `.nav`: `position: fixed; top:0; left:0; width:100vw; padding:1em; display:flex; gap:1em; z-index:2`. `.nav .col:nth-child(1) { flex: 1 }`; `.nav .col:nth-child(2) { flex: 2; display:flex; justify-content: space-between }`. There are **no CSS rules for `.nav-items` or `.nav-item`** — leave them default block so the three nav links render as a vertical column list (do NOT add `display:flex` or `gap` to `.nav-items`).
- `.home`: `width:100%; height:100svh; overflow:hidden; background: var(--bg)`. `.home .header`: absolute, `top:25%; left:50%; transform:translate(-50%,-50%); width:100%`; its `h1` is `font-size: 30.5vw; text-align:center` (the wordmark fills the viewport width). `.home .hero-img`: absolute, `left:50%; bottom:0; transform:translateX(-50%); width:95%; height:50vh; overflow:hidden`.
- `.work`: `text-align:center; padding:15em 1em; display:flex; flex-direction:column; gap:2em; background:var(--bg)`. `.work .projects`: `width:32%; margin:0 auto; column flex; gap:4em`; project images `aspect-ratio:4/5`.
- `.studio, .contact`: `padding:15em 1em; display:flex; gap:1em; background:var(--bg)`; first `.col` flex 1, second `.col` flex 2 as a column with `gap:2em`. `.studio .about-img`: `aspect-ratio:5/7`. `.contact`: `width:100vw; min-height:100svh`. `.contact .socials`: absolute, `bottom:1.5em; display:flex; gap:1em`.
- SplitText helper classes: `.letter, .word, .line { position:relative; display:inline-block; will-change:transform }`.
- `.revealer`: `position:fixed; top:0; left:0; width:100vw; height:100svh; transform-origin:center top; background-color:var(--fg); pointer-events:none; z-index:2`. It starts covering the whole viewport in dark and is scaled away by GSAP.
- **View Transitions CSS (required for the clip-path effect to work):**
  ```
  ::view-transition-old(root), ::view-transition-new(root) { animation: none !important; }
  ::view-transition-group(root) { z-index: auto !important; }
  ::view-transition-image-pair(root) { isolation: isolate; will-change: clip-path; z-index: 1; }
  ::view-transition-new(root) { z-index: 10000; animation: none !important; }
  ::view-transition-old(root) { z-index: 1; animation: none !important; }
  ```
  (Default snapshot cross-fades are disabled; the new snapshot sits on top at z-index 10000 so the custom clip-path animation controls the whole reveal.)
- Responsive at `max-width: 900px`: `.work .projects` becomes `width:90%`; `.studio` and `.contact` switch to `flex-direction: column`.

## GSAP effect (be precise)

### Setup

- `CustomEase.create("hop", "0.9, 0, 0.1, 1")` — this "hop" ease drives the revealer wipe. The same bezier is reused as `cubic-bezier(0.9, 0, 0.1, 1)` in the WAAPI clip-path animation.
- Start Lenis smooth scroll globally: `const lenis = new Lenis()` plus the standard `requestAnimationFrame` loop calling `lenis.raf(time)`.
- Build a `Map` of `data-path → section.view` and track `currentPath` (starts at `"/"`). Also keep a `Map` of view → SplitText instance so a revisit can `revert()` the previous split before re-splitting.

### 1. Revealer wipe (per view entrance)

For the entering view's `.revealer`:
- `gsap.set(revealer, { scaleY: 1 })` (fully covering the viewport in `--fg` dark).
- `gsap.to(revealer, { scaleY: 0, duration: 1.25, delay: 1, ease: "hop" })` — because `transform-origin` is `center top`, the dark panel collapses upward, unveiling the page beneath it after a 1s hold.

### 2. Masked SplitText heading reveal (per view entrance)

Each view splits its heading(s) with `SplitText.create(elements, { type, <class option>, mask })` where `mask` equals the split type — this wraps each fragment in an overflow-clipping mask element so fragments slide in from below their own line box. Then:
- `gsap.set(fragments, { y: "110%" })` (hidden below the mask).
- `gsap.to(fragments, { y: "0%", duration: 1.5, stagger, delay, ease: "power4.out" })`.

Per-view configuration table:

| view path  | target | type    | class option              | mask    | animated set | stagger | delay |
|------------|--------|---------|---------------------------|---------|--------------|---------|-------|
| `/`        | `h1`   | `chars` | `charsClass: "letter"`    | `chars` | `.chars`     | 0.1     | 1.25  |
| `/work`    | `h1`   | `words` | `wordsClass: "word"`      | `words` | `.words`     | 0.25    | 1.75  |
| `/studio`  | `h2`   | `lines` | `linesClass: "line"`      | `lines` | `.lines`     | 0.1     | 1.5   |
| `/contact` | `h2`   | `lines` | `linesClass: "line"`      | `lines` | `.lines`     | 0.1     | 1.75  |

Notes: the studio and contact views select ALL `h2`s in the view (headers, paragraph, emails), splitting them into lines together. Before splitting a view that was split previously, call `revert()` on its stored SplitText instance.

`playView(view)` = run the revealer wipe (1) + the SplitText reveal (2) together.

### 3. Clip-path page transition (nav click)

Each nav `<a data-path>` gets a click handler that `preventDefault()`s and calls `navigate(path)`:
- If `path === currentPath`, do nothing.
- **DOM swap function**: hide the current view (`hidden = true`), unhide the target, `gsap.set(targetView.querySelector(".revealer"), { scaleY: 1 })` so the incoming snapshot is captured with the dark overlay covering the viewport, then reset scroll with `window.scrollTo(0, 0)` and `lenis.scrollTo(0, { immediate: true })`.
- If `document.startViewTransition` is unavailable, just swap, update `currentPath`, and `playView(target)` (graceful fallback).
- Otherwise: `const transition = document.startViewTransition(() => { swap; currentPath = path; })`, then in `transition.ready.then(...)` run BOTH:
  1. The WAAPI clip-path animation on the new snapshot:
     ```
     document.documentElement.animate(
       [
         { clipPath: "polygon(25% 75%, 75% 75%, 75% 75%, 25% 75%)" },
         { clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)" }
       ],
       { duration: 2000, easing: "cubic-bezier(0.9, 0, 0.1, 1)", pseudoElement: "::view-transition-new(root)" }
     );
     ```
     The new page appears as a zero-height horizontal slit centered at 75% of the viewport height (from x=25% to x=75%) and expands to cover the full viewport.
  2. `playView(targetView)` — so the revealer wipe (dark cover collapsing upward after its 1s delay) and the masked text reveal play *inside* the expanding clip.

### 4. Initial load

On `document.fonts.ready`, run `playView` on the home view: the dark revealer holds 1s, collapses upward over 1.25s with the "hop" ease, and the giant `nuvoro` wordmark letters rise out of their character masks (chars, stagger 0.1, delay 1.25, power4.out).

## Assets / images

6 monochrome/black-and-white art-direction images (abstract sculptural photography works well — spheres, distorted portraits, smoke, rock textures):

1. **Hero** — 1 wide image, displayed full-bleed at 95% viewport width × 50vh, anchored to the bottom of the home view (e.g. a grainy grey gradient orb on a near-white ground).
2. **Projects** — 4 portrait images at **4:5** aspect ratio, stacked vertically in the work view (surreal B/W portrait, glossy stone spheres, rocket engines firing through fog, cluster of floating grey spheres — any cohesive monochrome set).
3. **Studio** — 1 portrait image at **5:7** aspect ratio in the studio view's second column (e.g. a cracked metallic rock splitting apart).

## Acceptance checks

- **Wordmark:** the home `h1` "nuvoro" must render on a **SINGLE line** spanning roughly the full viewport width (~95–98vw), fully visible below the nav. If it wraps to a second line or overflows the viewport, the Inter font import is wrong (missing the `opsz` axis) — fix the `@import`, not the font-size.
- **Nav:** the three nav links (work / studio / contact) stack vertically at the start of the nav's second column, with "toronto, ca" alone at the right edge. If they render in a horizontal row, a stray `display:flex` was added to `.nav-items`.

## Behavior notes

- The whole document participates in the view transition (root snapshots); the CSS pseudo-element rules above are mandatory or the clip-path animation will not be visible.
- Clicking the currently-active nav link is a no-op.
- Scroll position resets to the top on every navigation (both native and Lenis, immediate).
- Lenis smooth scrolling applies to the taller inner views (work, studio, contact).
- In browsers without the View Transitions API, navigation still works: instant swap + revealer wipe + text reveal, no clip-path.
- Below 900px the project gallery widens to 90% and studio/contact stack into a single column.
