# Landing Page Reveal — Typographic Preloader with Split-Screen Unveil

## Goal
Build a full-screen landing intro that plays automatically once on page load (~7.5s). On a dark preloader, the studio name "Nullspace Studio" drops in character by character through masks while three small grey corner tags flip in; every character except the leading "N" then exits downward, a large "10" drops in beside it, the "N" and the "10" slide toward each other and morph into a logo lockup (the "N" shrinks and goes extra-bold, the "10" blows up to 14rem). Then the dark screen **splits in half along a horizontal seam**: the top half slides up, the bottom half slides down, and the page content (full-bleed hero image, nav, footer, and a white center card whose title rises per character) is revealed through an expanding clip-path letterbox. One GSAP timeline, one custom `hop` ease.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins **`CustomEase`** and **`SplitText`**. No smooth-scroll library. Register with `gsap.registerPlugin(CustomEase, SplitText)` and run everything inside `DOMContentLoaded`.

## Layout / HTML
Class names are load-bearing — the JS/CSS query them:

```
<div class="preloader">
  <div class="intro-title"><h1>Nullspace Studio</h1></div>
  <div class="outro-title"><h1>10</h1></div>
</div>

<div class="split-overlay">
  <div class="intro-title"><h1>Nullspace Studio</h1></div>
  <div class="outro-title"><h1>10</h1></div>
</div>

<div class="tags-overlay">
  <div class="tag tag-1"><p>Negative Space</p></div>
  <div class="tag tag-2"><p>Form & Void</p></div>
  <div class="tag tag-3"><p>Light Studies</p></div>
</div>

<div class="container">
  <nav>
    <p id="logo">N10</p>
    <p>Menu</p>
  </nav>
  <div class="hero-img"><img src="..." alt="" /></div>
  <div class="card"><h1>Nullspace</h1></div>
  <footer>
    <p>Scroll Down</p>
    <p>Made by Nullspace Studio</p>
  </footer>
</div>
```

Key idea: `.split-overlay` is an **exact duplicate** of the preloader's content. It sits underneath the preloader and is pre-positioned (via `gsap.set`) to the preloader's FINAL logo-lockup state. At the end of the lockup animation the preloader is clipped to the top half of the screen and the split-overlay to the bottom half — a pixel-perfect invisible swap — so the two halves can then slide apart in opposite directions.

## Styling
Font (Google Fonts): **DM Sans** — the only family.

- `* { margin:0; padding:0; box-sizing:border-box }`; `body { font-family:"DM Sans", sans-serif }`.
- `img { width:100%; height:100%; object-fit:cover }`.
- `h1 { text-transform:uppercase; font-size:6rem; font-weight:600; line-height:1 }`.
- `p { text-transform:uppercase; font-size:13px; font-weight:500 }`.

Layers:
- `.preloader, .split-overlay, .tags-overlay { position:fixed; width:100vw; height:100svh }`.
- `.preloader, .split-overlay { background-color:#0a0a0a; color:#fff }`.
- z-index: `.preloader` and `.tags-overlay` = `2`; `.split-overlay` = `1`.
- `.intro-title { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; text-align:center }` (applies inside both dark layers).
- `.outro-title { position:absolute; top:50%; left:calc(50% + 10rem); transform:translate(-50%,-50%) }`.

Tags:
- `.tag { position:absolute; width:max-content; color:#5a5a5a; overflow:hidden }`.
- `.tag-1 { top:15%; left:15% }`, `.tag-2 { bottom:15%; left:25% }`, `.tag-3 { bottom:30%; right:15% }`.

Page content:
- `.container { position:relative; width:100%; height:100%; min-height:100svh; display:flex; flex-direction:column; justify-content:space-between; z-index:2; clip-path: polygon(0 48%, 0 48%, 0 52%, 0 52%) }` — **initial clip-path is a zero-width sliver at the left edge** (all x = 0, y between 48% and 52%), so the content is invisible until the timeline sweeps the slit open.
- `.container .hero-img { position:absolute; width:100%; height:100% }` (full-bleed image behind everything in the container).
- `nav, footer { position:relative; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; color:#fff; z-index:2 }`. `nav p#logo { font-weight:600; font-size:20px }`.
- `.card { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:30%; height:70%; display:flex; justify-content:center; align-items:center; background-color:#fff; clip-path: polygon(0% 50%, 100% 50%, 100% 50%, 0% 50%) }` — initial clip-path is a degenerate horizontal line at mid-height (card hidden). Its `h1 { text-align:center; width:100%; font-size:3rem }`.

SplitText piece styles (these create the masks):
- `.intro-title .char, .outro-title .char, .card .char { position:relative; display:inline-block; overflow:hidden }` — each char is its own overflow-hidden mask.
- `.intro-title .char, .outro-title .char { margin-top: 0.75rem }`.
- `.intro-title .char span, .outro-title .char span, .tag .word { position:relative; display:inline-block; transform:translateY(-100%); will-change:transform }` — title chars and tag words start hidden ABOVE their mask.
- `.card .char span { position:relative; display:inline-block; transform:translateY(100%); will-change:transform }` — card chars start hidden BELOW their mask.
- `.intro-title .first-char { transform-origin: top left }` (matters for its `scale` tween).

## GSAP effect (be exact)

### Setup
```js
CustomEase.create("hop", ".8, 0, .3, 1");
```

SplitText helper — split, then manually wrap each char's text in an inner `<span>` (the CSS above animates `.char span`, not `.char`):

```js
const splitTextElements = (selector, type = "words,chars", addFirstChar = false) => {
  document.querySelectorAll(selector).forEach((element) => {
    const splitText = new SplitText(element, {
      type,
      wordsClass: "word",
      charsClass: "char",
    });
    if (type.includes("chars")) {
      splitText.chars.forEach((char, index) => {
        const originalText = char.textContent;
        char.innerHTML = `<span>${originalText}</span>`;
        if (addFirstChar && index === 0) char.classList.add("first-char");
      });
    }
  });
};

splitTextElements(".intro-title h1", "words, chars", true); // hits BOTH preloader & split-overlay copies
splitTextElements(".outro-title h1");                        // words + chars ("1","0"), both copies
splitTextElements(".tag p", "words");                        // words only, no inner spans
splitTextElements(".card h1", "words, chars", true);

const isMobile = window.innerWidth <= 1000;
```

### Pre-position the split-overlay duplicate to the FINAL state
```js
gsap.set(
  [".split-overlay .intro-title .first-char span",
   ".split-overlay .outro-title .char span"],
  { y: "0%" }                          // its "N" and "10" are already visible
);
gsap.set(".split-overlay .intro-title .first-char", {
  x: isMobile ? "7.5rem" : "18rem",
  y: isMobile ? "-1rem" : "-2.75rem",
  fontWeight: "900",
  scale: 0.75,
});
gsap.set(".split-overlay .outro-title .char", {
  x: isMobile ? "-3rem" : "-8rem",
  fontSize: isMobile ? "6rem" : "14rem",
  fontWeight: "500",
});
```
(The non-first intro chars of the split-overlay keep their CSS `translateY(-100%)`, i.e. hidden — the duplicate shows only the finished "N + 10" lockup.)

### The timeline
One timeline, `gsap.timeline({ defaults: { ease: "hop" } })`. All position params below are **absolute times in seconds**. `tags = gsap.utils.toArray(".tag")`.

1. **Tags in** — for each tag `i` (0,1,2), at time `0.5 + i * 0.1`: its `p .word` → `{ y: "0%", duration: 0.75 }` (words drop down into view from `-100%`).
2. **Intro title in** — at `0.5`: `".preloader .intro-title .char span"` → `{ y: "0%", duration: 0.75, stagger: 0.05 }`. "Nullspace Studio" drops in char by char from above through the per-char masks.
3. **Intro title out (except first char)** — at `2`: `".preloader .intro-title .char:not(.first-char) span"` → `{ y: "100%", duration: 0.75, stagger: 0.05 }`. Every char except the leading "N" continues DOWN and out of its mask (in through the top, out through the bottom).
4. **Outro "10" in** — at `2.5`: `".preloader .outro-title .char span"` → `{ y: "0%", duration: 0.75, stagger: 0.075 }`.
5. **Slide together** — at `3.5`, two simultaneous 1s tweens:
   - `".preloader .intro-title .first-char"` → `{ x: isMobile ? "9rem" : "21.25rem", duration: 1 }` (the "N" travels right toward center; it overshoots its final x slightly).
   - `".preloader .outro-title .char"` → `{ x: isMobile ? "-3rem" : "-8rem", duration: 1 }` (the "10" chars travel left).
6. **Morph into logo lockup** — at `4.5`, two simultaneous 0.75s tweens:
   - `".preloader .intro-title .first-char"` → `{ x: isMobile ? "7.5rem" : "18rem", y: isMobile ? "-1rem" : "-2.75rem", fontWeight: "900", scale: 0.75, duration: 0.75 }` — the "N" pulls back/up, shrinks to 75% (transform-origin top left) and becomes extra-bold.
   - `".preloader .outro-title .char"` → `{ x: isMobile ? "-3rem" : "-8rem", fontSize: isMobile ? "6rem" : "14rem", fontWeight: "500", duration: 0.75 }` — the "10" scales up via animated `fontSize` (6rem → 14rem on desktop). These values EXACTLY match the split-overlay `gsap.set` pre-positioning.
   - The second tween's `onComplete` performs the **invisible swap**:
     ```js
     gsap.set(".preloader",     { clipPath: "polygon(0 0, 100% 0, 100% 50%, 0 50%)" });   // top half
     gsap.set(".split-overlay", { clipPath: "polygon(0 50%, 100% 50%, 100% 100%, 0 100%)" }); // bottom half
     ```
     Since both layers now render identical pixels, nothing appears to change.
7. **Letterbox slit opens** — at `5`: `".container"` → `{ clipPath: "polygon(0% 48%, 100% 48%, 100% 52%, 0% 52%)", duration: 1 }`. The content's clip sweeps from the zero-width left-edge sliver to a thin full-width band between 48% and 52% viewport height — a glowing slit of the hero image appears across the seam.
8. **Tags out** — for each tag `i`, at `5.5 + i * 0.1`: its `p .word` → `{ y: "100%", duration: 0.75 }` (exit downward).
9. **The split** — at `6`, three overlapping tweens:
   - `[".preloader", ".split-overlay"]` → `{ y: (i) => (i === 0 ? "-50%" : "50%"), duration: 1 }` — top half slides up offscreen, bottom half slides down.
   - `".container"` → `{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 1 }` — the letterbox expands to full screen in sync with the halves parting.
   - at `6.25`: `".container .card"` → `{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 0.75 }` — the white card unmasks vertically from its center line.
10. **Card title in** — at `6.5`: `".container .card h1 .char span"` → `{ y: "0%", duration: 0.75, stagger: 0.05 }` — "Nullspace" rises from BELOW (its spans start at `translateY(100%)`, unlike the title chars which started at `-100%`).

### Ease reference
- `hop` = `CustomEase.create("hop", ".8, 0, .3, 1")` — used by every tween (timeline default). No other eases.

## Assets / images
**One image**: a full-bleed hero background revealed behind the split — a wide, desaturated architectural interior with strong negative space and directional light (calm, minimal, editorial mood). Landscape orientation (roughly 16:9), displayed with `object-fit: cover` filling the viewport. White nav/footer text sits on top of it, so darker/muted imagery reads best.

## Behavior notes
- **Autoplay once** on load; no scroll/hover/click triggers, no ScrollTrigger. Total runtime ≈ 7.25s.
- `isMobile` breakpoint at `window.innerWidth <= 1000` swaps the rem offsets/font sizes as listed above.
- Responsive CSS (`max-width: 1000px`): `h1 { font-size:2.5rem }`; `.outro-title { left: calc(50% + 4rem) }`; `.card { width:75% }`; `.card h1 { font-size:2.5rem }`; `.intro-title .char, .outro-title .char { margin-top:0.5rem }`.
- Use `100svh` for all full-height layers so mobile browser chrome doesn't clip.
- The preloader/split-overlay never get removed from the DOM — after sliding ±50% they sit offscreen behind the revealed page (the container is z-index 2, above the split-overlay's z-index 1).
- Keep the `will-change: transform` hints on the animated `.char span` / `.word` elements.
