0

Saltar al contenido

Dirección de arte, lista para tu IA.

Únete a la lista
HeroGratis

Lune

Elévate por encima del ruido

Qué incluye

Prompts
HTML · 365 líneas / React · 275 líneas
Vídeo · escritorio
1280×720 · loop de 5 s · 2.2 MB
Vídeo · móvil
540×960 · 0.8 MB
Pósters
Escritorio y móvil, primer fotograma
Alojamiento
cdn.orbital.supply, ya incluido en el prompt
Tipografías
Geist + Cormorant Garamond · Google Fonts
HTML
Un archivo, sin librerías
React
React 19 · Vite · TypeScript · Tailwind 4 · motion · lucide
Movimiento
focus-pull, mask-reveal, blur-focus, stagger-in

Probada y documentada

24 de septiembre de 2026

  • Reproducida en Claude y Lovable
  • Reconstruida a ciegas desde el prompt: HTML y React
  • Revisada a 375, 768, 1440, 1920 px
  • Revisor de la spec: 0 errores, sin enlaces a generadores
Lune construida en Lovable desde el prompt de ReactVer la reproducción en Lovable

Cómo usarla

  1. Claude

    Pega el prompt de HTML en un chat nuevo. Obtienes un index.html para servir por HTTP o publicar donde quieras.

  2. Lovable

    Crea un proyecto y pega el prompt de React como primer mensaje. Instala el stack exacto y construye la página.

  3. Vite + React

    Abre un proyecto vacío de Vite + React y pega el prompt de React. Indica los paquetes exactos; las URLs de los recursos ya están dentro.

El prompt

React · Vite275 líneas
Abrir el texto completo
# Build: "Lune" — cinematic single-viewport hero for a quiet AI writing app (React)

## 0. Brief

A full-screen, single-viewport landing hero for **Lune**, a calm AI writing app. Behind everything plays a looping film: a woman in a white dress on a dark hill, facing a colossal pale moon over an endless sea of clouds. The page must feel still, expensive and cinematic. The "wow" moment is the opening: the film fades in from black while it zooms out and comes into focus, then the interface settles in one piece at a time and the italic words *the noise.* come into focus. All copy sits at the bottom over the dark hill, and the film breathes above it. "Watch the film" hides the whole interface so only the film remains; any click or key brings it back.

## 1. Output & hard constraints

- Stack (exact): **React 19 + Vite + TypeScript + Tailwind CSS v4 via `@tailwindcss/vite` + `motion` (import from `motion/react`) + `lucide-react`**. Install `tailwindcss` and `@tailwindcss/vite` as devDependencies (`npm i -D`), `motion` and `lucide-react` as dependencies. No other runtime dependencies (the Vite scaffold's dev tooling is fine).
- Files: `vite.config.ts`, `index.html`, `src/index.css`, `src/main.tsx`, `src/motion.ts`, `src/App.tsx`, `src/components/Film.tsx`, `src/components/Header.tsx`, `src/components/Hero.tsx`, `src/components/MetaBar.tsx`, `src/components/MobileMenu.tsx`.
- Styling is given as Tailwind classes; use them verbatim. Custom CSS only where section 6.1 gives it literally.
- Breakpoints: use ONLY the arbitrary variants `min-[641px]:`, `min-[901px]:`, `min-[1600px]:`, `min-[1920px]:` (and the one `[@media(...)]` variant given). Never `sm:`/`md:`/`lg:` — Tailwind v4 outputs arbitrary variants before named ones, so mixing them makes `sm:` override the wider breakpoints.
- Exports: every component is a `default` export. `Header.tsx` also exports `NAV_LINKS: { label: string; href: string }[]` and `MoonMark({ className })`. `FILM` in `Film.tsx` is a private constant with keys `desktop`, `mobile`, `posterDesktop`, `posterMobile`.
- `index.html` `<head>`: `<meta charset="UTF-8" />`, `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />`, title `Lune — Rise above the noise`, meta description `Lune is a quiet writing space with an AI that listens more than it talks.`, `<meta name="theme-color" content="#07090d" />`, the two preconnect links (section 3) and this favicon: `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23eef1f5' d='M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z'/%3E%3C/svg%3E" />`. Delete the scaffold's `public/` icons and demo CSS/assets.
- Page type: single viewport, **no scroll** above 640px wide; below 640px the page may scroll vertically.
- Do NOT add: extra sections, feature cards, pricing, testimonials, forms, logo strips, cursor effects, WebGL, canvas, routing or state libraries.
- Do NOT substitute, rename or invent any asset URL. Use the URLs in section 2 verbatim.
- Anti-flash: `<html>` and `<body>` in `index.html` carry `style="background:#07090d;color:#eef1f5"`, and the first rule after `@theme` in `src/index.css` is `html, body { background: #07090d !important; color: #eef1f5; }`.
- `vite.config.ts`: `plugins: [react(), tailwindcss()]`.
- Zero console errors; `npm run build` passes with no TypeScript errors.

## 2. Assets (exact URLs — the only media files)

| ID | Type | URL | Role |
|---|---|---|---|
| HERO_VIDEO | video/mp4 | `https://cdn.orbital.supply/tpl-0001/hero.mp4` | Looping film, 16:9, 5 s seamless loop — viewports wider than 640px |
| HERO_VIDEO_MOBILE | video/mp4 | `https://cdn.orbital.supply/tpl-0001/hero-mobile.mp4` | Same film cropped 9:16 — viewports ≤640px |
| HERO_POSTER | image/jpeg | `https://cdn.orbital.supply/tpl-0001/hero-poster.jpg` | First frame of HERO_VIDEO |
| HERO_POSTER_MOBILE | image/jpeg | `https://cdn.orbital.supply/tpl-0001/hero-poster-mobile.jpg` | First frame of HERO_VIDEO_MOBILE |

Keep them in a `FILM` constant in `Film.tsx`. ONE `<video>`; its `src` and `poster` switch with the viewport (section 8). If the video fails, the poster stays visible.

## 3. Typography

First line of `src/index.css`, then Tailwind:
```css
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@1,400&family=Geist:wght@400;500;600&display=swap');
@import "tailwindcss";
```
`index.html` also has `<link rel="preconnect" href="https://fonts.googleapis.com" />` and `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />`.

- `font-sans` = Geist (400 · 500 · 600) → everything.
- `font-serif` = Cormorant Garamond 400 italic → ONLY the words `the noise.` in the H1.

## 4. Design tokens

```css
@theme {
  --font-sans: "Geist", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-serif: "Cormorant Garamond", "Times New Roman", serif;
  --color-bg: #07090d;
  --color-text: #eef1f5;
  --color-ink: #0b0e14;
  --color-accent: #c9d6ea;
  --ease-expo: cubic-bezier(.19, 1, .22, 1);
}
```
Opacity modifiers used throughout: `text-text/66` (muted), `text-text/42` (faint), `border-text/16` (glass border), `border-text/32` (glass border hover), `bg-text/6` (glass), `bg-text/10` (glass hover), `border-text/14` (divider), `bg-bg/62` (menu backdrop).

## 5. Layer stack & component tree

```
<App>
  div[data-paused][data-film].relative.grid.h-dvh.min-h-[640px].grid-rows-[auto_1fr_auto].overflow-hidden.min-[641px]:min-h-[560px]
    <Film paused onBlocked />                     absolute inset-0 z-0  (video → .scrim z-[1] → .grain z-[2])
    <Header menuOpen onToggleMenu inert={filmMode} />   .chrome relative z-50
    <Hero onFilmMode inert={filmMode || menuOpen} />          .chrome relative z-10
    <MetaBar paused onToggle inert={filmMode || menuOpen} />  .chrome relative z-10
  <MobileMenu open onClose />                     fixed inset-0 z-40 (sibling of the stage)
  <p aria-live="polite"> film-mode hint           fixed z-[60] (sibling of the stage)
```
The stage must NOT create a stacking context (no `isolate`), so the header (z-50) stays above the open menu (z-40).

`App` state: `paused` (initial value `useReducedMotion() === true`), `menuOpen` (false) and `filmMode` (false). The stage div gets `data-paused={paused}` and `data-film={filmMode}` (the CSS in 6.1 keys off them). `onBlocked = useCallback(() => setPaused(true), [])`.

## 6. Components (exact)

### 6.1 `src/index.css` after `@theme`
```css
html, body { background: #07090d !important; color: #eef1f5; }
html, body, #root { height: 100%; }
body {
  font-family: var(--font-sans);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-synthesis: none;
  overflow: hidden;
}
@media (max-width: 640px) { body { overflow-y: auto; } }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 3px; border-radius: 9999px; }

@layer components {
  /* Legibility scrims over the film */
  .scrim {
    background:
      linear-gradient(to bottom, rgba(7,9,13,.55) 0%, rgba(7,9,13,0) 22%),
      linear-gradient(to top, rgba(7,9,13,.92) 0%, rgba(7,9,13,.55) 26%, rgba(7,9,13,0) 52%);
  }
  @media (max-width: 640px) {
    .scrim {
      background:
        linear-gradient(to bottom, rgba(7,9,13,.55) 0%, rgba(7,9,13,0) 18%),
        linear-gradient(to top, rgba(7,9,13,.95) 0%, rgba(7,9,13,.72) 42%, rgba(7,9,13,0) 72%);
    }
  }

  /* Animated film grain */
  .grain {
    background-size: 200px 200px;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.72' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
    animation: grain-shift 1s steps(6) infinite;
  }

  /* Moonlight primary button with shine sweep */
  .btn-moon {
    background: linear-gradient(180deg, #f6f8fb 0%, #e3e9f1 100%);
    color: var(--color-ink);
    box-shadow: inset 0 1px 0 rgba(255,255,255,.9), 0 1px 2px rgba(7,9,13,.4);
  }
  .btn-moon::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background: linear-gradient(100deg, transparent 32%, rgba(255,255,255,.85) 50%, transparent 68%);
    transform: translateX(-130%);
    transition: transform .65s ease;
  }
  .btn-moon:hover { box-shadow: inset 0 1px 0 #fff, 0 0 0 1px rgba(201,214,234,.5), 0 8px 32px rgba(201,214,234,.28); }
  .btn-moon:hover::after { transform: translateX(130%); }
  @media (hover: none) { .btn-moon::after { display: none; } }

  .badge-dot { animation: badge-pulse 2.4s ease-out infinite; }

  /* Paused film also freezes the grain and the badge pulse */
  [data-paused="true"] .grain, [data-paused="true"] .badge-dot { animation-play-state: paused; }

  /* Film mode: the interface fades away (!important beats motion's inline opacity) */
  .chrome { transition: opacity .6s var(--ease-expo), filter .6s var(--ease-expo); }
  [data-film="true"] .chrome { opacity: 0 !important; filter: blur(8px) !important; pointer-events: none; }
}

@keyframes grain-shift {
  0% { transform: translate(0, 0); }
  20% { transform: translate(-4%, 3%); }
  40% { transform: translate(3%, -5%); }
  60% { transform: translate(-6%, -2%); }
  80% { transform: translate(5%, 4%); }
  100% { transform: translate(0, 0); }
}
@keyframes badge-pulse {
  0% { box-shadow: 0 0 0 0 rgba(201,214,234,.55); }
  70%, 100% { box-shadow: 0 0 0 8px rgba(201,214,234,0); }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation: none !important; transition: none !important; }
}
```

### 6.2 `src/motion.ts` — entrance presets
Export `EASE = [0.19, 1, 0.22, 1] as const` and a hook `useEnter(kind, delay)` that returns `{ initial, animate, transition }` to spread on a `motion.*` element. With `useReducedMotion()` true it returns `{}` (the element renders in its final state, no animation).

| kind | initial | animate | duration |
|---|---|---|---|
| `down` | `{ opacity: 0, y: -12 }` | `{ opacity: 1, y: 0 }` | 1 |
| `pop` | `{ opacity: 0, scale: 0.9 }` | `{ opacity: [0, 1, 1], scale: [0.9, 1.03, 1] }` + `times: [0, 0.7, 1]` | 1 |
| `rise` | `{ y: '110%' }` | `{ y: '0%' }` | 1.2 |
| `soft` | `{ opacity: 0, y: 16, filter: 'blur(6px)' }` | `{ opacity: 1, y: 0, filter: 'blur(0px)' }` | 1 |
| `fade` | `{ opacity: 0 }` | `{ opacity: 1 }` | 1 |
| `focus` | `{ opacity: 0.3, filter: 'blur(6px)' }` | `{ opacity: 1, filter: 'blur(0px)' }` | 1.6 |

`transition = { duration, delay, ease: EASE }`, plus `times: [0, 0.7, 1]` inside `transition` for `pop` only.

### 6.3 `Film.tsx` (props: `paused: boolean`, `onBlocked: () => void`)
- Root: `div.absolute.inset-0.z-0.overflow-hidden.bg-bg` with `aria-hidden="true"`.
- Lens: `motion.div.absolute.inset-0` · `initial={reduce ? false : { opacity: 0, scale: 1.12, filter: 'blur(14px)' }}` · `animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}` · `transition={{ duration: 2.4, delay: 0.1, ease: EASE }}`.
- Inside the lens: `<video id="hero-video">` with classes `absolute -top-[3%] -left-[3%] h-[106%] w-[106%] max-w-none object-cover will-change-transform` and props `src`, `poster`, `muted`, `loop`, `playsInline`, `autoPlay={!paused}`, `preload="auto"`. **`max-w-none` is required**: Tailwind's preflight sets `max-width:100%` on `video`, which would cap it and leave a dark strip on the right.
- After the lens: `div.scrim.pointer-events-none.absolute.inset-0.z-[1]` and `div.grain.pointer-events-none.absolute.-inset-1/2.z-[2].opacity-[0.22].mix-blend-overlay`.

### 6.4 `Header.tsx` (props: `menuOpen`, `onToggleMenu`, `inert`)
Export `NAV_LINKS = [Manifesto #manifesto, Features #features, Journal #journal, Pricing #pricing]` and a `MoonMark` SVG: `viewBox="0 0 24 24"`, `aria-hidden`, path `M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z` with `fill="currentColor"`.

- `header` (`inert={inert}`): `chrome relative z-50 grid grid-cols-[1fr_auto_1fr] items-center px-5 pt-4 min-[641px]:px-8 min-[641px]:pt-6 min-[1600px]:px-12 min-[1600px]:pt-8 min-[1920px]:px-16`
- Logo `motion.a` (`useEnter('down', 0.9)`), `href="#top"`, `aria-label="Lune home"`: `inline-flex h-11 items-center gap-2 justify-self-start text-lg font-semibold tracking-[-0.03em]` → `MoonMark` `h-5 w-5` + `Lune`.
- Nav `motion.nav` (`useEnter('down', 1)`), `id="site-nav"`, `aria-label="Primary"`: `hidden gap-1 rounded-full border border-text/16 bg-text/6 p-[3px] backdrop-blur-[16px] backdrop-saturate-[1.4] min-[901px]:flex`. Each link: `inline-flex h-9 items-center rounded-full px-4 text-sm tracking-[-0.01em] text-text/66 transition-colors duration-300 hover:bg-text/10 hover:text-text`.
- Actions `motion.div` (`useEnter('down', 1.1)`): `col-start-3 flex items-center gap-2 justify-self-end`, containing:
  - "Sign in" `#signin`: `hidden h-11 items-center px-3 text-sm text-text/66 transition-colors duration-300 hover:text-text min-[901px]:inline-flex`
  - "Get Lune" `#start`: `btn-moon relative isolate hidden h-11 items-center overflow-hidden rounded-full px-5 text-sm font-medium tracking-[-0.01em] transition-[scale,box-shadow] duration-300 ease-expo active:scale-[0.98] min-[901px]:inline-flex`
  - Menu `button#menu-btn` (`aria-expanded={menuOpen}`, `aria-controls="menu-panel"`, text `Close` when open else `Menu`): `inline-flex h-11 items-center rounded-full border border-text/16 bg-text/6 px-5 text-sm font-medium backdrop-blur-[16px] transition-colors duration-300 hover:border-text/32 hover:bg-text/10 min-[901px]:hidden`

### 6.5 `Hero.tsx` (props: `onFilmMode: () => void`, `inert`)
- `main#top` (`inert={inert}`): `chrome relative z-10 flex min-h-0 items-end px-5 pb-6 min-[641px]:px-8 min-[641px]:pb-8 min-[1600px]:px-12 min-[1920px]:px-16`
- Grid wrapper: `w-full min-[901px]:grid min-[901px]:grid-cols-12 min-[901px]:items-end min-[901px]:gap-x-8`
- Title column `min-[901px]:col-span-8`:
  - Badge `motion.p` (`useEnter('pop', 1.2)`): `mb-6 inline-flex h-8 items-center gap-2 rounded-full border border-text/16 bg-text/6 pr-3 pl-2.5 text-xs tracking-[0.02em] text-text/66 backdrop-blur-[16px] [@media(min-width:901px)_and_(max-height:760px)]:mb-4` → `span.badge-dot.h-1.5.w-1.5.rounded-full.bg-accent` (aria-hidden) + `strong.font-medium.text-text` "Lune 2.0" + `span` "— now in orbit" (the 8px flex gap spaces them).
  - `h1`: `text-[clamp(48px,13vw,72px)] leading-[0.94] font-medium tracking-[-0.045em] min-[641px]:text-[clamp(56px,10vw,96px)] min-[901px]:text-[clamp(56px,7.4vw,144px)] [@media(min-width:901px)_and_(max-height:760px)]:text-[clamp(48px,6vw,96px)]`
    - Two mask lines, each `span` with `-mx-[0.06em] -mb-[0.1em] block overflow-hidden px-[0.06em] pb-[0.1em]`:
      1. `motion.span.block` (`useEnter('rise', 1.35)`) "Rise above"
      2. `motion.span.block` (`useEnter('rise', 1.5)`) → `motion.em` (`useEnter('focus', 1.7)`) `inline-block font-serif text-[1.14em] leading-[0.8] font-normal tracking-[-0.02em] text-accent italic` "the noise."
- Side column: `mt-6 w-full max-w-[480px] min-[901px]:col-span-4 min-[901px]:col-start-9 min-[901px]:mt-0 min-[901px]:max-w-[400px] min-[901px]:justify-self-end min-[901px]:pb-4 min-[1600px]:max-w-[424px] min-[1920px]:max-w-[456px]`
  - Lede `motion.p` (`useEnter('soft', 1.8)`): `mb-6 text-base leading-[1.55] tracking-[-0.005em] text-text/66 min-[1600px]:text-lg` — "Lune is a quiet writing space with an AI that listens more than it talks. No feeds, no pings — just your thoughts, finally in orbit."
  - Actions: `flex flex-col gap-2 min-[641px]:flex-row min-[641px]:flex-wrap`
    - Primary `motion.a` `#start` (`useEnter('soft', 1.95)`): `group btn-moon relative isolate inline-flex h-12 items-center justify-between gap-3 overflow-hidden rounded-full pr-2 pl-5 text-base font-medium tracking-[-0.01em] whitespace-nowrap transition-[scale,box-shadow] duration-300 ease-expo active:scale-[0.98] min-[641px]:h-11 min-[641px]:justify-center min-[641px]:text-sm` → `span` "Start writing — free" + orb `span` (aria-hidden) `grid h-7 w-7 place-items-center rounded-full bg-ink text-text transition-transform duration-300 ease-expo group-hover:translate-x-0.5 group-hover:scale-[1.06]` holding lucide `ArrowRight` `size={14} strokeWidth={2}`.
    - Ghost `motion.button` `type="button"` `id="film-mode-btn"` `onClick={onFilmMode}` (`useEnter('soft', 2.05)`): `inline-flex h-12 items-center justify-center gap-2 rounded-full border border-text/16 bg-text/6 px-5 text-base font-medium tracking-[-0.01em] whitespace-nowrap backdrop-blur-[16px] transition-colors duration-300 hover:border-text/32 hover:bg-text/10 active:scale-[0.98] min-[641px]:h-11 min-[641px]:text-sm` → lucide `Play` `size={14} fill="currentColor" strokeWidth={0}` (aria-hidden) + "Watch the film".

### 6.6 `MetaBar.tsx` (props: `paused`, `onToggle`, `inert`)
- `motion.footer` (`useEnter('fade', 2.2)`, `inert={inert}`): `chrome relative z-10 mx-5 grid grid-cols-[1fr_auto_1fr] items-center gap-4 border-t border-text/14 pt-3 pb-[calc(16px+env(safe-area-inset-bottom))] text-xs tracking-[0.08em] text-text/42 uppercase tabular-nums min-[641px]:mx-8 min-[1600px]:mx-12 min-[1920px]:mx-16`
  1. `span.col-start-1.hidden.justify-self-start.min-[901px]:inline` — "28.2724° N, 16.6425° W — 3,718 m above the noise"
  2. `span.col-start-2.hidden.min-[641px]:inline` — "macOS · iOS · Web"
  3. `button#film-toggle` (`type="button"`, `onClick={onToggle}`; no aria-pressed — the visible label carries the state): `relative col-start-3 inline-flex h-8 justify-self-end items-center gap-2 tracking-[0.08em] uppercase transition-colors duration-300 before:absolute before:-inset-x-2 before:-inset-y-1.5 before:content-[''] hover:text-text` → lucide `Play` when paused else `Pause` (`size={12} fill="currentColor" strokeWidth={0}`, aria-hidden) + label "Play film" / "Pause film".

### 6.7 `MobileMenu.tsx` (props: `open`, `onClose`)
- `div#menu-panel` (`role="dialog"`, `aria-label="Menu"`, `aria-hidden={!open}`): `fixed inset-0 z-40 flex flex-col justify-center gap-2 bg-bg/62 px-6 pt-24 pb-12 backdrop-blur-[24px] duration-[350ms] ease-expo` + `visible opacity-100 transition-opacity` when open, `invisible opacity-0 transition-[opacity,visibility]` when closed. (Only opacity transitions on open, so the panel is visible — and focusable — in the same frame it opens.)
- The 4 `NAV_LINKS`: `py-2 text-[32px] font-medium tracking-[-0.03em] text-text`, `tabIndex={open ? 0 : -1}`, `onClick={onClose}`.
- Below them `div.mt-6.flex.flex-col.gap-2`: "Start writing — free" `#start` (`btn-moon relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden rounded-full text-base font-medium`) and "Sign in" `#signin` (`inline-flex h-12 w-full items-center justify-center rounded-full border border-text/16 bg-text/6 text-base font-medium backdrop-blur-[16px]`), same `tabIndex` and `onClick`.

### 6.8 Film-mode hint (last child of `App`)
`p` with `aria-live="polite"`: `pointer-events-none fixed bottom-[calc(24px+env(safe-area-inset-bottom))] left-1/2 z-[60] -translate-x-1/2 text-xs tracking-[0.08em] whitespace-nowrap text-text/66 uppercase transition-opacity duration-[600ms] ease-expo` + `opacity-100 delay-[400ms]` in film mode, `opacity-0` otherwise. Text `Click anywhere or press any key to return` only in film mode, empty otherwise.

## 7. Motion

Entrance timeline (seconds): film lens 0.1 (2.4 long) → logo 0.9 → nav 1.0 → actions 1.1 → badge 1.2 → H1 line 1 1.35 → H1 line 2 1.5 → italic focus 1.7 → lede 1.8 → primary 1.95 → ghost 2.05 → meta bar 2.2; everything has settled by ≈3.3s. All use `EASE`. Film mode fades the three `.chrome` blocks with `!important` in CSS because motion leaves inline opacity on the animated elements. Press states use Tailwind v4's `scale` utility (the individual `scale` property), which never conflicts with motion's `transform`.

Pointer parallax (in `Film.tsx`, only when `(hover: hover) and (pointer: fine)` matches and reduced motion is off): target `x = (clientX/innerWidth − 0.5) × −24`, `y = (clientY/innerHeight − 0.5) × −16`; every animation frame `current += (target − current) × 0.06`; write `translate3d(x px, y px, 0)` (2 decimals) straight to `video.style.transform` through a ref (no React state per frame). `pointermove` is on `window`; targets reset to 0 on `document` `mouseout` when `relatedTarget` is null (pointer left the window). Clean up listeners and the rAF on unmount.

## 8. Behaviour

1. `Film.tsx` has a small `useMedia(query)` hook (initial value from `matchMedia(query).matches`, updated on `change`). `isMobile = useMedia('(max-width: 640px)')` picks `src`/`poster` from `FILM` (mobile pair ≤640px, desktop pair otherwise).
2. An effect on `[paused, src, onBlocked]`: `paused` → `video.pause()`, otherwise `video.play().catch((e: DOMException) => { if (e.name === 'NotAllowedError') onBlocked() })` — only a blocked autoplay flips the toggle to "Play film"; other rejections (source swap `AbortError`, failed load) are ignored.
3. `MetaBar`'s toggle flips `paused`. Reduced motion is read once at mount: with it, `paused` starts `true`, so the poster shows until the user presses Play. `data-paused` also freezes the grain and the badge pulse.
4. **Film mode:** "Watch the film" calls `setPaused(false)` and `setFilmMode(true)`. An effect on `[filmMode]` registers, after `setTimeout(0)`, `click` and `keydown` listeners on `document`; either one sets `filmMode` false and, on the next animation frame, focuses `#film-mode-btn`. Clean up the timeout and listeners in the effect's cleanup.
5. **Menu:** the button toggles `menuOpen`; menu links call `onClose`. An effect (mounted once) closes it on Escape and when `(min-width: 901px)` starts matching. A second effect on `[menuOpen]` sets `document.body.style.overflow` to `hidden` while open (empty otherwise), focuses the first link in `#menu-panel` on open, and focuses `#menu-btn` when it closes after having been open (track the previous value with a ref). While open, `Hero` and `MetaBar` are `inert`, so Tab stays in the header and the panel.

## 9. Responsive

| Width | What changes |
|---|---|
| `≤640px` | vertical mobile film · H1 `clamp(48px,13vw,72px)` · 20px sides · CTAs stacked, full width, 48px tall, 16px text · only the film toggle in the meta bar · stronger scrim · page may scroll (min-height 640px) |
| `641–900px` | H1 `clamp(56px,10vw,96px)` · CTAs in a row · lede block under the title (max 480px) · "Menu" pill instead of nav/Sign in/Get Lune |
| `≥901px` | 12-col hero grid (title 8 cols, side 4 cols right-aligned, max 400px) · H1 `clamp(56px,7.4vw,144px)` · full header · coordinates visible |
| `≥901px` and height ≤760px | H1 `clamp(48px,6vw,96px)` · badge margin 16px |
| `≥1600px` | 48px sides, 32px header top, lede 18px, side column 424px |
| `≥1920px` | 64px sides, side column 456px |

No horizontal scroll at 375, 768, 1440 and 1920px.

## 10. Accessibility & performance

- One `<h1>`. Landmarks: `header`, `nav[aria-label="Primary"]`, `main#top`, `footer`.
- The film is `aria-hidden`; decorative icons carry `aria-hidden`.
- The film toggle pauses everything that moves on its own (film, grain, badge pulse); touch targets ≥ 44px.
- The mobile menu is a `role="dialog"` panel; while open the hero and meta bar are `inert` (the header's Close stays reachable), focus moves in and returns; in film mode the hidden blocks are `inert` and the hint is announced via `aria-live`.
- Reduced motion: `useEnter` returns `{}`, the lens has `initial={false}`, CSS animations/transitions are off, and the film starts paused.
- The poster is what shows through the opening fade (and permanently if the video fails); the video swap causes no layout shift.

## 11. Acceptance checklist

- [ ] **First paint:** near-black `#07090d`, never white.
- [ ] **Opening:** over 2.4s the film fades in from black while zooming out from 112% and going from blurred to sharp.
- [ ] **Entrance order:** logo → nav → actions → badge pop → "Rise above" and "the noise." rise out of their masks → italic words sharpen → lede and CTAs rise with blur → meta bar fades in (all settled by ≈3.3s).
- [ ] **Hero moment:** at 1440×900 the headline sits bottom-left over the dark hill (second line in pale-blue Cormorant italic); lede and both CTAs are bottom-right with the CTAs on one row; the moon and the woman stay uncovered.
- [ ] **Parallax:** moving the mouse drifts the film up to 12px/8px the opposite way; no dark edge ever shows on any side.
- [ ] **Buttons:** primary shows a light sweep on hover and its arrow orb nudges right; ghost brightens its border.
- [ ] **Meta bar:** coordinates left, platforms centred, toggle right; hiding one item never moves the others.
- [ ] **Film toggle:** right-aligned at every size; "Pause film" ⇄ "Play film" with the matching icon; pausing also freezes the grain and badge pulse.
- [ ] **Film mode:** "Watch the film" fades header, hero and meta bar out with a blur (0.6s); the hint fades in bottom-centre; any click or key restores everything and focus returns to the button.
- [ ] **Press:** buttons shrink to 98% while pressed.
- [ ] **1920px:** 64px side margins (not 32px).
- [ ] **≤900px:** a "Menu" pill replaces the nav; the menu opens full-screen with blur, and the header (logo + "Close") stays above it.
- [ ] **≤640px:** the vertical mobile film plays; CTAs stacked and full-width; text readable over the stronger scrim.
- [ ] **Reduced motion:** everything visible immediately; video paused on its poster; toggle reads "Play film".
- [ ] **Robustness:** with the video blocked, the poster shows and the layout is identical.
- [ ] Zero console errors · `npm run build` passes · only the dependencies and URLs listed here.
HTML · un archivo365 líneas
Abrir el texto completo
# Build: "Lune" — cinematic single-viewport hero for a quiet AI writing app

## 0. Brief

A full-screen, single-viewport landing hero for **Lune**, a calm AI writing app. Behind everything plays a looping film: a woman in a white dress on a dark hill, facing a colossal pale moon over an endless sea of clouds. The page must feel still, expensive and cinematic. The "wow" moment is the opening: the film fades in from black while it zooms out and comes into focus, then the interface settles in one piece at a time and the italic words *the noise.* come into focus. All copy sits at the bottom over the dark hill, and the film breathes above it. "Watch the film" hides the whole interface so only the film remains; any click or key brings it back.

## 1. Output & hard constraints

- Deliver ONE self-contained `index.html` with inline `<style>` and one inline `<script>` (an IIFE). No build step.
- External libraries: **none**. Only external files: the Google Fonts stylesheet in section 3 and the 4 assets in section 2.
- `<meta charset="utf-8">` · document title: `Lune — Rise above the noise` · `lang="en"` · `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` · `<meta name="theme-color" content="#07090d">` · meta description: `Lune is a quiet writing space with an AI that listens more than it talks.`
- Favicon: `<link rel="icon" type="image/svg+xml" href="…">` with this exact data URI: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23eef1f5' d='M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z'/%3E%3C/svg%3E`
- Page type: single viewport, **no scroll** above 640px wide; below 640px the page may scroll vertically.
- Do NOT add: extra sections, feature cards, pricing, testimonials, forms, logos strip, cursor effects, WebGL, canvas or any library.
- Do NOT substitute, rename or invent any asset URL. Use the URLs in section 2 verbatim.
- Exact `<head>` order: charset → viewport → `<title>` → description → theme-color → favicon → the two preconnect links → the Google Fonts stylesheet (section 3) → the single `<style>` block.
- Anti-flash: the FIRST CSS rule is `html,body{background:#07090d!important;color:#eef1f5}` and the body tag is `<body style="background:#07090d;color:#eef1f5">`.
- Zero console errors.

## 2. Assets (exact URLs — the only media files)

| ID | Type | URL | Role |
|---|---|---|---|
| HERO_VIDEO | video/mp4 | `https://cdn.orbital.supply/tpl-0001/hero.mp4` | Looping film, 16:9, 1280×720, 5 s seamless loop — viewports wider than 640px |
| HERO_VIDEO_MOBILE | video/mp4 | `https://cdn.orbital.supply/tpl-0001/hero-mobile.mp4` | Same film cropped 9:16 (540×960) — viewports ≤640px |
| HERO_POSTER | image/jpeg | `https://cdn.orbital.supply/tpl-0001/hero-poster.jpg` | First frame of HERO_VIDEO (poster + fallback) |
| HERO_POSTER_MOBILE | image/jpeg | `https://cdn.orbital.supply/tpl-0001/hero-poster-mobile.jpg` | First frame of HERO_VIDEO_MOBILE |

Rules:
- ONE `<video id="hero-video">` with `muted loop playsinline autoplay preload="auto"`, `poster` = HERO_POSTER, and **no `src` attribute in the HTML**. Put the four URLs in `data-src`, `data-src-mobile`, `data-poster`, `data-poster-mobile`; the script picks the pair (section 8).
- If the video fails, the poster stays visible and nothing shifts.
- All icons and the logo are inline SVG (paths given below). No icon fonts.

## 3. Typography

Load in `<head>` (after `<link rel="preconnect" href="https://fonts.googleapis.com">` and `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`):

```
https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@1,400&family=Geist:wght@400;500;600&display=swap
```

| Role | Stack | Weights | Used on |
|---|---|---|---|
| UI / display | `"Geist",system-ui,-apple-system,"Segoe UI",sans-serif` | 400 · 500 · 600 | everything except the `<em>` |
| Accent serif | `"Cormorant Garamond","Times New Roman",serif` | 400 italic | ONLY the words `the noise.` in the H1 |

Body (exact): `body{font-family:var(--font-sans);font-size:16px;line-height:1.5;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility;font-synthesis:none;overflow:hidden}`.

## 4. Design tokens (base = desktop 901–1599px)

```css
:root{
  --bg:#07090d; --text:#eef1f5; --ink:#0b0e14;
  --muted:rgba(238,241,245,.66); --faint:rgba(238,241,245,.42);
  --accent:#c9d6ea; --line:rgba(238,241,245,.14);
  --glass:rgba(238,241,245,.06); --glass-hover:rgba(238,241,245,.1);
  --glass-border:rgba(238,241,245,.16); --glass-border-hover:rgba(238,241,245,.32);
  --font-sans:"Geist",system-ui,-apple-system,"Segoe UI",sans-serif;
  --font-serif:"Cormorant Garamond","Times New Roman",serif;
  --h1:clamp(56px,7.4vw,144px); --lede:16px; --ui:14px; --label:12px;
  --ctl-h:44px; --pad-x:32px; --pad-y:24px; --side-max:400px;
  --ease:cubic-bezier(.19,1,.22,1);
}
```

Base reset: `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}` · `html,body{height:100%}` · `a{color:inherit;text-decoration:none}` · `button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}` · `svg{display:block;flex:none}` · `:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:999px}`.

## 5. Layer stack & DOM

Z-order (back → front): `.film` (z 0: video → `.scrim` z 1 → `.grain` z 2) · `main.hero` + `footer.meta` (z 10) · `.menu-panel` (fixed, z 40) · `header.header` (z 50, stays above the open menu) · `.film-hint` (fixed, z 60).

Exact markup (ids and classes are load-bearing):

```html
<body style="background:#07090d;color:#eef1f5">
<div class="stage">
  <div class="film" id="film" aria-hidden="true">
    <div class="film-lens">
      <video id="hero-video" muted loop playsinline autoplay preload="auto"
        poster="https://cdn.orbital.supply/tpl-0001/hero-poster.jpg"
        data-src="https://cdn.orbital.supply/tpl-0001/hero.mp4"
        data-src-mobile="https://cdn.orbital.supply/tpl-0001/hero-mobile.mp4"
        data-poster="https://cdn.orbital.supply/tpl-0001/hero-poster.jpg"
        data-poster-mobile="https://cdn.orbital.supply/tpl-0001/hero-poster-mobile.jpg"></video>
    </div>
    <div class="scrim"></div>
    <div class="grain"></div>
  </div>

  <header class="header">
    <a class="logo a a-down" style="--d:.9s" href="#top" aria-label="Lune home">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
      <span>Lune</span>
    </a>
    <nav class="nav a a-down" style="--d:1s" id="site-nav" aria-label="Primary">
      <a href="#manifesto">Manifesto</a><a href="#features">Features</a><a href="#journal">Journal</a><a href="#pricing">Pricing</a>
    </nav>
    <div class="header-actions a a-down" style="--d:1.1s">
      <a class="link-quiet" href="#signin">Sign in</a>
      <a class="btn btn-primary" href="#start">Get Lune</a>
      <button class="btn btn-ghost menu-btn" id="menu-btn" type="button" aria-expanded="false" aria-controls="menu-panel">Menu</button>
    </div>
  </header>

  <main class="hero" id="top">
    <div class="hero-grid">
      <div class="hero-title">
        <p class="badge a a-pop" style="--d:1.2s"><span class="badge-dot" aria-hidden="true"></span><strong>Lune 2.0</strong><span>— now in orbit</span></p>
        <h1>
          <span class="line"><span class="line-inner a a-rise" style="--d:1.35s">Rise above</span></span>
          <span class="line"><span class="line-inner a a-rise" style="--d:1.5s"><em>the noise.</em></span></span>
        </h1>
      </div>
      <div class="hero-side">
        <p class="lede a a-soft" style="--d:1.8s">Lune is a quiet writing space with an AI that listens more than it talks. No feeds, no pings — just your thoughts, finally in orbit.</p>
        <div class="actions">
          <a class="btn btn-primary btn-orb a a-soft" style="--d:1.95s" href="#start">
            <span>Start writing — free</span>
            <span class="orb" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
          </a>
          <button class="btn btn-ghost a a-soft" style="--d:2.05s" id="film-mode-btn" type="button">
            <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg>
            <span>Watch the film</span>
          </button>
        </div>
      </div>
    </div>
  </main>

  <footer class="meta a a-fade" style="--d:2.2s">
    <span class="coords">28.2724° N, 16.6425° W — 3,718 m above the noise</span>
    <span class="platforms">macOS · iOS · Web</span>
    <button class="film-toggle" id="film-toggle" type="button" data-paused="false">
      <svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
      <svg class="i-play" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M7 4v16l13-8z"/></svg>
      <span class="film-toggle-label">Pause film</span>
    </button>
  </footer>
</div>

<div class="menu-panel" id="menu-panel" role="dialog" aria-label="Menu">
  <a href="#manifesto">Manifesto</a><a href="#features">Features</a><a href="#journal">Journal</a><a href="#pricing">Pricing</a>
  <div class="menu-cta">
    <a class="btn btn-primary" href="#start">Start writing — free</a>
    <a class="btn btn-ghost" href="#signin">Sign in</a>
  </div>
</div>

<p class="film-hint" id="film-hint" aria-live="polite"></p>
<script>/* section 8 */</script>
</body>
```

## 6. Components (exact CSS)

### 6.1 Stage, film, scrim, grain
```css
.stage{position:relative;height:100vh;height:100dvh;min-height:560px;overflow:hidden;display:grid;grid-template-rows:auto 1fr auto}
.film{position:absolute;inset:0;z-index:0;overflow:hidden;background:var(--bg)}
.film-lens{position:absolute;inset:0;animation:focus-pull 2.4s var(--ease) .1s both}
.film video{position:absolute;top:-3%;left:-3%;width:106%;height:106%;object-fit:cover;object-position:50% 50%;transform:translate3d(var(--px,0px),var(--py,0px),0);will-change:transform}
.scrim{position:absolute;inset:0;z-index:1;pointer-events:none;background:
  linear-gradient(to bottom,rgba(7,9,13,.55) 0%,rgba(7,9,13,0) 22%),
  linear-gradient(to top,rgba(7,9,13,.92) 0%,rgba(7,9,13,.55) 26%,rgba(7,9,13,0) 52%)}
.grain{position:absolute;inset:-50%;z-index:2;pointer-events:none;opacity:.22;mix-blend-mode:overlay;background-size:200px 200px;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.72' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  animation:grain-shift 1s steps(6) infinite}
body.is-paused .grain,body.is-paused .badge-dot{animation-play-state:paused}

.header,.hero,.meta{transition:opacity .6s var(--ease),filter .6s var(--ease)}
body.film-mode .header,body.film-mode .hero,body.film-mode .meta{opacity:0!important;filter:blur(8px)!important;pointer-events:none}
```
The video is 6% larger than the stage on purpose: it leaves room for the pointer parallax without showing edges. `.stage` must NOT have `isolation:isolate` (the header has to sit above the fixed menu). `body.is-paused` freezes the grain and the badge pulse together with the film; `body.film-mode` fades the three chrome blocks away; its `!important` is required so it also wins over the entrance animations and the reduced-motion / `.no-anim` rules.

### 6.2 Header
```css
.header{position:relative;z-index:50;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:var(--pad-y) var(--pad-x) 0}
.logo{justify-self:start;display:inline-flex;align-items:center;gap:8px;height:var(--ctl-h);font-size:18px;font-weight:600;letter-spacing:-.03em}
.logo svg{width:20px;height:20px}
.nav{display:flex;gap:4px;padding:3px;border-radius:999px;background:var(--glass);border:1px solid var(--glass-border);-webkit-backdrop-filter:blur(16px) saturate(140%);backdrop-filter:blur(16px) saturate(140%)}
.nav a{height:36px;display:inline-flex;align-items:center;padding:0 16px;border-radius:999px;font-size:var(--ui);letter-spacing:-.01em;color:var(--muted);transition:color .3s ease,background-color .3s ease}
.nav a:hover{color:var(--text);background:var(--glass-hover)}
.header-actions{grid-column:3;justify-self:end;display:flex;align-items:center;gap:8px}
.link-quiet{height:var(--ctl-h);display:inline-flex;align-items:center;padding:0 12px;font-size:var(--ui);color:var(--muted);transition:color .3s ease}
.link-quiet:hover{color:var(--text)}
.btn.menu-btn{display:none}
```

### 6.3 Buttons (moonlight primary with shine sweep · glass ghost · arrow orb)
The press effect uses the individual `scale` property, not `transform`, so it can never collide with the entrance animations.
```css
.btn{position:relative;isolation:isolate;overflow:hidden;height:var(--ctl-h);display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:0 20px;border-radius:999px;font-size:var(--ui);font-weight:500;letter-spacing:-.01em;line-height:1;white-space:nowrap;transition:scale .3s var(--ease),background-color .3s ease,border-color .3s ease,box-shadow .3s ease}
.btn:active{scale:.98}
.btn-primary{background:linear-gradient(180deg,#f6f8fb 0%,#e3e9f1 100%);color:var(--ink);box-shadow:inset 0 1px 0 rgba(255,255,255,.9),0 1px 2px rgba(7,9,13,.4)}
.btn-primary::after{content:"";position:absolute;inset:0;z-index:-1;background:linear-gradient(100deg,transparent 32%,rgba(255,255,255,.85) 50%,transparent 68%);transform:translateX(-130%);transition:transform .65s ease}
.btn-primary:hover{box-shadow:inset 0 1px 0 #fff,0 0 0 1px rgba(201,214,234,.5),0 8px 32px rgba(201,214,234,.28)}
.btn-primary:hover::after{transform:translateX(130%)}
.btn-orb{padding:0 8px 0 20px;gap:12px}
.orb{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;background:var(--ink);color:var(--text);transition:transform .3s var(--ease)}
.btn-orb:hover .orb{transform:translateX(2px) scale(1.06)}
.btn-ghost{background:var(--glass);color:var(--text);border:1px solid var(--glass-border);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}
.btn-ghost:hover{background:var(--glass-hover);border-color:var(--glass-border-hover)}
```

### 6.4 Hero (anchored to the bottom — never vertically centred)
```css
.hero{position:relative;z-index:10;display:flex;align-items:flex-end;padding:0 var(--pad-x) 32px;min-height:0}
.hero-grid{width:100%;display:grid;grid-template-columns:repeat(12,minmax(0,1fr));column-gap:32px;align-items:end}
.hero-title{grid-column:1 / span 8}
.hero-side{grid-column:9 / span 4;justify-self:end;width:100%;max-width:var(--side-max);padding-bottom:16px}
.badge{display:inline-flex;align-items:center;gap:8px;height:32px;padding:0 12px 0 10px;margin-bottom:24px;border-radius:999px;background:var(--glass);border:1px solid var(--glass-border);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);font-size:var(--label);letter-spacing:.02em;color:var(--muted)}
.badge strong{font-weight:500;color:var(--text)}
.badge-dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:badge-pulse 2.4s ease-out infinite}
h1{font-size:var(--h1);font-weight:500;line-height:.94;letter-spacing:-.045em;color:var(--text)}
.line{display:block;overflow:hidden;padding:0 .06em .1em;margin:0 -.06em -.1em}
.line-inner{display:block}
h1 em{font-family:var(--font-serif);font-style:italic;font-weight:400;font-size:1.14em;line-height:.8;letter-spacing:-.02em;color:var(--accent)}
.lede{font-size:var(--lede);line-height:1.55;letter-spacing:-.005em;color:var(--muted);margin-bottom:24px}
.actions{display:flex;flex-wrap:wrap;gap:8px}
```
`.line` is the mask for the rise animation: its padding/negative margin keep descenders and the italic overhang from being clipped. The badge text after `<strong>` is its own `<span>`; the 8px flex gap provides the spacing.

### 6.5 Meta bar (bottom strip)
Three fixed grid columns: coordinates left, platforms centred, film toggle right. Each item keeps its own column, so hiding one never moves the others.
```css
.meta{position:relative;z-index:10;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;margin:0 var(--pad-x);padding:12px 0 calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--line);font-size:var(--label);letter-spacing:.08em;text-transform:uppercase;font-variant-numeric:tabular-nums;color:var(--faint)}
.meta .coords{grid-column:1;justify-self:start}
.meta .platforms{grid-column:2}
.film-toggle{grid-column:3;justify-self:end;position:relative;height:32px;display:inline-flex;align-items:center;gap:8px;text-transform:uppercase;letter-spacing:.08em;font-size:var(--label);color:var(--faint);transition:color .3s ease}
.film-toggle::before{content:"";position:absolute;inset:-6px -8px}
.film-toggle:hover{color:var(--text)}
.film-toggle svg{width:12px;height:12px}
.film-toggle .i-play{display:none}
.film-toggle[data-paused="true"] .i-play{display:block}
.film-toggle[data-paused="true"] .i-pause{display:none}
```

### 6.6 Mobile menu panel
```css
.menu-panel{position:fixed;inset:0;z-index:40;display:flex;flex-direction:column;justify-content:center;gap:8px;padding:96px 24px 48px;background:rgba(7,9,13,.62);-webkit-backdrop-filter:blur(24px);backdrop-filter:blur(24px);opacity:0;visibility:hidden;transition:opacity .35s var(--ease),visibility 0s linear .35s}
body.menu-open{overflow:hidden}
body.menu-open .menu-panel{opacity:1;visibility:visible;transition:opacity .35s var(--ease)}
.menu-panel > a{font-size:32px;font-weight:500;letter-spacing:-.03em;color:var(--text);padding:8px 0}
.menu-panel .menu-cta{margin-top:24px;display:flex;flex-direction:column;gap:8px}
.menu-panel .menu-cta .btn{width:100%;font-size:16px;height:48px}
```
Use the child selector `.menu-panel > a` — a descendant selector would override the primary button's dark text. `body.menu-open` also locks page scroll.

### 6.7 Film-mode hint
```css
.film-hint{position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom));z-index:60;transform:translateX(-50%);font-size:var(--label);letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;color:var(--muted);opacity:0;pointer-events:none;transition:opacity .6s var(--ease)}
body.film-mode .film-hint{opacity:1;transition-delay:.4s}
```
Its text is set by the script only while film mode is on (section 8).

## 7. Motion

Entrance timeline (resting state is always visible; `animation-fill-mode:both` hides each element only during its own delay; when an element's own animation ends the script adds `.is-settled`, which removes the animation so hover, press and film-mode styles are never overridden):

| # | Element | Keyframe | Duration | Delay |
|---|---|---|---|---|
| 1 | `.film-lens` | `focus-pull` | 2.4s | 0.1s |
| 2 | `.logo` | `in-down` | 1s | 0.9s |
| 3 | `.nav` | `in-down` | 1s | 1.0s |
| 4 | `.header-actions` | `in-down` | 1s | 1.1s |
| 5 | `.badge` | `in-pop` | 1s | 1.2s |
| 6 | H1 line 1 `.line-inner` | `in-rise` | 1.2s | 1.35s |
| 7 | H1 line 2 `.line-inner` | `in-rise` | 1.2s | 1.5s |
| 8 | `h1 em` | `em-focus` | 1.6s | 1.7s |
| 9 | `.lede` | `in-soft` | 1s | 1.8s |
| 10 | primary CTA | `in-soft` | 1s | 1.95s |
| 11 | ghost CTA | `in-soft` | 1s | 2.05s |
| 12 | `.meta` | `in-fade` | 1s | 2.2s |

All entrance animations use `var(--ease)`. Delays are set inline with `style="--d:…"`.

```css
.a{animation-duration:1s;animation-timing-function:var(--ease);animation-fill-mode:both;animation-delay:var(--d,0s)}
.a-down{animation-name:in-down}
.a-pop{animation-name:in-pop}
.a-rise{animation-name:in-rise;animation-duration:1.2s}
.a-soft{animation-name:in-soft}
.a-fade{animation-name:in-fade}
.a.is-settled{animation:none}
.line-inner.a-rise em{animation:em-focus 1.6s var(--ease) 1.7s both}

@keyframes focus-pull{from{opacity:0;transform:scale(1.12);filter:blur(14px)}to{opacity:1;transform:none;filter:none}}
@keyframes in-down{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:none}}
@keyframes in-pop{0%{opacity:0;transform:scale(.9)}70%{opacity:1;transform:scale(1.03)}100%{opacity:1;transform:none}}
@keyframes in-rise{from{transform:translateY(110%)}to{transform:none}}
@keyframes in-soft{from{opacity:0;transform:translateY(16px);filter:blur(6px)}to{opacity:1;transform:none;filter:none}}
@keyframes in-fade{from{opacity:0}to{opacity:1}}
@keyframes em-focus{from{opacity:.3;filter:blur(6px)}to{opacity:1;filter:none}}
@keyframes badge-pulse{0%{box-shadow:0 0 0 0 rgba(201,214,234,.55)}70%,100%{box-shadow:0 0 0 8px rgba(201,214,234,0)}}
@keyframes grain-shift{0%{transform:translate(0,0)}20%{transform:translate(-4%,3%)}40%{transform:translate(3%,-5%)}60%{transform:translate(-6%,-2%)}80%{transform:translate(5%,4%)}100%{transform:translate(0,0)}}

.no-anim .a,.no-anim .film-lens,.no-anim h1 em{animation:none!important;opacity:1!important;transform:none!important;filter:none!important}
```

Interaction motion:
- **Pointer parallax on the film** (fine pointers only, never with reduced motion): target `x = (clientX/innerWidth − .5) × −24`, `y = (clientY/innerHeight − .5) × −16` (px); every frame `current += (target − current) × 0.06`; write to `--px` / `--py` on the video (2 decimals, `px`). When the pointer leaves the window (`mouseout` on `document` with `relatedTarget === null`) the targets return to 0. Listen for `pointermove` on `window`.
- Button hovers: shine sweep 0.65s, orb nudge `translateX(2px) scale(1.06)`, press `scale(.98)`.

## 8. Behaviour (JS — one IIFE; the steps below are in code order)

1. **Paused state helpers.** `showPaused(p)` only updates the UI: `#film-toggle[data-paused]` = `"true"`/`"false"`, its label `Play film`/`Pause film`, and `body.is-paused` on/off. `play()` calls `video.play()` and, only if the promise rejects with `NotAllowedError` (autoplay blocked), calls `showPaused(true)` so the UI never lies. Other rejections (e.g. `AbortError` when a source swap interrupts a pending play, or a video that fails to load) are ignored. `setPaused(p)` = `showPaused(p)` + `video.pause()` or `play()`. The toggle click calls `setPaused(!currentlyPaused)`.
2. **Reduced motion** (`prefers-reduced-motion: reduce`, read once at load): remove the video's `autoplay` attribute and call `showPaused(true)`. This runs before step 3.
3. **Pick the film:** `mobile = matchMedia('(max-width: 640px)')`. `pickSource()` uses `data-src-mobile` + `data-poster-mobile` when it matches, otherwise `data-src` + `data-poster`. If the chosen src equals the current `src` attribute, stop. Otherwise set `poster`, set `src`, call `load()`, and call `play()` unless the toggle says paused. Run once, then on every `change` of the query.
4. **Parallax loop:** only if `matchMedia('(hover: hover) and (pointer: fine)')` matches and reduced motion is off (values in section 7). The loop runs every frame.
5. **Film mode:** clicking `#film-mode-btn` adds `body.film-mode`, sets `inert = true` on `.header`, `.hero` and `.meta`, writes `Click anywhere or press any key to return` into `#film-hint`, and calls `setPaused(false)`. On the next tick (`setTimeout 0`) it listens for `click` and `keydown` on `document`; either one exits: remove `body.film-mode`, set `inert = false`, empty the hint, remove both listeners, focus `#film-mode-btn`.
6. **Mobile menu (dialog):** `setMenu(open)` does nothing if already in that state; otherwise toggles `body.menu-open`, sets `aria-expanded`, swaps the button text `Menu`/`Close`, sets `inert` on `.hero` and `.meta` while open (so Tab stays in the header and the panel), and moves focus to the first link of `#menu-panel` on open or back to `#menu-btn` on close. Triggers: the menu button, any link inside the panel (close), Escape (close), the viewport growing to `min-width: 901px` (close).
7. **Entrance cleanup:** for every `.a`, on its own `animationend` (`event.target === element`) add `.is-settled`. Then, after two `requestAnimationFrame`s, if `document.getAnimations` is missing or returns an empty list, add `.no-anim` to `<html>`.

## 9. Responsive

| Breakpoint | Token overrides | Layout changes |
|---|---|---|
| `≥1600px` | `--pad-x:48px; --pad-y:32px; --lede:18px; --side-max:424px` | — |
| `≥1920px` | `--pad-x:64px; --side-max:456px` | — |
| `≥901px` and `max-height:760px` | `--h1:clamp(48px,6vw,96px)` | `.badge{margin-bottom:16px}` |
| `≤900px` | `--h1:clamp(56px,10vw,96px)` | hide `.nav`, `.link-quiet` and `.header-actions .btn:not(.menu-btn)`; show `.btn.menu-btn{display:inline-flex}`; `.hero-grid{display:block}`; `.hero-side{max-width:480px;margin-top:24px;padding-bottom:0;justify-self:auto}`; hide `.meta .coords` |
| `≤640px` | `--pad-x:20px; --pad-y:16px; --h1:clamp(48px,13vw,72px)` | `body{overflow-y:auto}`; `.stage{min-height:640px}`; `.hero{padding-bottom:24px}`; `.actions{flex-direction:column}`; `.actions .btn{width:100%;height:48px;font-size:16px}`; `.btn-orb{justify-content:space-between}`; hide `.meta .platforms`; stronger scrim (below) |

Mobile scrim (≤640px):
```css
.scrim{background:linear-gradient(to bottom,rgba(7,9,13,.55) 0%,rgba(7,9,13,0) 18%),linear-gradient(to top,rgba(7,9,13,.95) 0%,rgba(7,9,13,.72) 42%,rgba(7,9,13,0) 72%)}
```
Also: `@media (hover:none){.btn-primary::after{display:none}}`.

No horizontal scroll at 375, 768, 1440 and 1920px.

## 10. Accessibility & performance

- One `<h1>`. Landmarks: `header`, `nav[aria-label="Primary"]`, `main#top`, `footer`.
- The film container is `aria-hidden="true"`; decorative SVGs carry `aria-hidden="true"`.
- `#film-toggle` pauses everything that moves on its own: the film, the grain and the badge pulse (content that moves for more than 5 seconds must be pausable).
- The mobile menu is a `role="dialog"` panel: while open, the hero and meta bar are `inert` (the header's Close button stays reachable), focus moves to the first link and returns to the Menu button on close.
- In film mode the hidden interface is `inert`, and the hint is announced through `aria-live`.
- Reduced motion (exact CSS):
```css
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation:none!important;transition:none!important}
  .a,.film-lens,h1 em{opacity:1!important;transform:none!important;filter:none!important}
}
```
- Touch targets ≥ 44px (the film toggle gets its hit area from `::before`).
- The poster is what shows through the opening fade (and permanently if the video fails or JS is off); starting the video causes no layout shift.

## 11. Acceptance checklist

- [ ] **First paint:** near-black `#07090d`, never white.
- [ ] **Opening:** over 2.4s the film fades in from black while zooming out from 112% and going from blurred to sharp.
- [ ] **Entrance order:** logo → nav → actions (from 0.9s, dropping 12px) → badge pops → "Rise above" then "the noise." rise out of their masks → the italic words go from blurred to sharp → lede and both CTAs rise with a blur → meta bar fades in (the whole sequence settles at ≈3.3s, when the italic focus completes).
- [ ] **Hero moment:** at 1440×900 the headline sits bottom-left over the dark hill (two lines, second line in pale-blue Cormorant italic); lede and CTAs are bottom-right on one row; the moon and the woman stay uncovered.
- [ ] **Parallax:** moving the mouse drifts the film up to 12px/8px the opposite way, smoothly, with no edge showing.
- [ ] **Buttons:** primary shows a light sweep on hover and its arrow orb nudges right; ghost brightens its border.
- [ ] **Meta bar:** coordinates left, platforms centred, film toggle right (at ≤900px the coordinates hide, at ≤640px the platforms hide; the others never move).
- [ ] **Film toggle:** "Pause film" (right-aligned at every size) pauses the video, the grain and the badge pulse, and becomes "Play film" with a play icon, and back.
- [ ] **Film mode:** "Watch the film" fades the whole interface out with a blur in 0.6s; the hint "Click anywhere or press any key to return" fades in at the bottom centre; any click or key restores the interface and focus returns to the button. This also works with reduced motion and when clicked before the entrance has finished.
- [ ] **Press:** buttons shrink to 98% while pressed, including the hero CTAs.
- [ ] **≤900px:** nav, Sign in and Get Lune disappear; a "Menu" pill appears at top-right; the menu opens full-screen with a blurred backdrop, and the header (logo + "Close") stays on top.
- [ ] **≤640px:** the vertical mobile film plays; CTAs are full-width and stacked; the text is readable over a stronger scrim.
- [ ] **Reduced motion:** everything is visible immediately, the video is paused on its poster, and the toggle reads "Play film".
- [ ] **Robustness:** with the video blocked, the poster shows and the layout is identical.
- [ ] Zero console errors · no libraries · only the URLs in sections 2 and 3.
Above the Clouds¿Solo quieres el vídeo?

Explorar Above the Clouds

Volver a las plantillas