Altitude
aadmin2026-11-05 发布Site模板7 阅读

Altitude

类型:Site模板 · 收录:2026-11-05 01:13:00 · 源码:会员可见

[hidecontent type="vip1"]
# Recreate this site as a single HTML file: Altitude

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion, and
interaction. Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. Use ES modules
and a CDN importmap only for the physics-spring helper (Popmotion `popmotion@11` from unpkg) — or
inline your own critically-damped spring integrator; everything else is hand-written WebGL2 and
Canvas2D with no libraries. Hardcode every value given here as a fixed constant. Rebuild each
component described below as one clearly-labelled block of the single file.

## What it is

A single full-bleed hero for **Altitude**, a service that scouts off-grid mountain cabins and
lodges. A looping starry-night video fills the viewport; a **WebGL2 fluid vortex** follows the
cursor, swirling warm-gold and cool-sky ink over the sky and **warping the video** where it moves
(with a faint chromatic-aberration split), fading away when the cursor stops. Over that sit a
centred masthead, a large serif display heading revealed letter-by-letter, a frosted-glass enquiry
form, and a frosted-glass stats bar. Before all of it, a **navy preloader** counts 0→100 inside a
progress ring over a field of drifting comets, then clears as a **circular aperture** that opens
from the centre — the hero animates in *through* the opening. Everything moves on physics springs;
type is a warm-white serif (Gilda Display) over a neutral sans (General Sans).

## Page shell, fonts & libraries

- **Viewport & scroll:** one non-scrolling screen. `html, body { margin:0; height:100%; }`,
  `body { overflow:hidden; background:#04305c; }`. The hero is `min-height:100lvh`.
- **Fonts (Google Fonts, in `<head>`):**
  `<link href="https://fonts.googleapis.com/css2?family=Gilda+Display&family=General+Sans:wght@400&display=swap" rel="stylesheet">`
  — Gilda Display is the **display** face (headings, brand mark, stat figures, button, counter);
  General Sans is the **text** face (nav, body, form fields). If General Sans is unavailable on
  Google Fonts, substitute any neutral 400-weight grotesque (e.g. Inter). Use
  `font-family:"Gilda Display",serif` and `font-family:"General Sans","Inter",sans-serif`.
- **Design tokens** — declare all of these as CSS custom properties on `:root` and use them
  everywhere (no raw literals in rules):
  ```css
  :root{
    --on-media:#ffffff;              /* all copy/borders over the media, used with opacity */
    --scrim:#000000;                 /* dark scrim + glass tint, used with opacity */
    --surface-base:#f0f2fa;          /* page colour behind the media */
    --intro-ground:#04305c;          /* preloader navy ground */
    --action-foreground:#000000;     /* text on the gradient button */
    --vortex-warm:#ffd75a;           /* ink hue A — valley lantern gold */
    --vortex-cool:#8ccfff;           /* ink hue B — night-sky blue */
    --action-gradient:linear-gradient(104.458deg,#ffe5ac 10.028%,#97bdde 100%);
    --display-fade:linear-gradient(to right,rgb(0 0 0/1) 0%,rgb(0 0 0/.3) 100%);
    --glass-inset:inset 0 -4px 12px rgb(255 255 255/.2);
    --radius-field:70px; --radius-button:29px;
    --blur-glass:5px; --blur-glass-strong:23.1px;
    --text-display:64px; --text-display-compact:28px; --text-counter:44px;
    --text-title:24px; --text-lead:18px; --text-body:16px;
    --leading-display:1.1; --leading-body:1.3;
    --duration-fast:150ms; --ease-entrance:cubic-bezier(0.2,0,0,1);
  }
  ```
  The original scales the whole layout with a rem grid (root font-size tracks the viewport width);
  for the single-file rebuild you may keep px as above and add a couple of `@media` breakpoints as
  noted per section. Below `sm` (≤640px) the display heading uses `--text-display-compact` (28px).

## Layout & sections (in order)

The hero is one `<section>`, `position:relative; isolate; display:flex; flex-direction:column;
min-height:100lvh; overflow:hidden; background:var(--surface-base)`. Stacking order bottom→top:
(1) the WebGL media layer, (2) a `30%`-black scrim, (3) masthead, (4) two vertical column rules,
(5) the centred heading + form, (6) the stats bar. All copy colour is `var(--on-media)` at various
opacities.

### 1. Media layer (`position:absolute; inset:0`)
A `<video>` (`src`=the starry-night asset, see **Assets**) `autoplay loop muted playsinline`,
`object-fit:cover; position:absolute; inset:0; width/height:100%`, `aria-hidden`. **On top of it**
a `<canvas>` (`position:absolute; inset:0; width/height:100%`) that runs the fluid simulation
(below). The canvas draws the video *itself* plus the ink; until the sim has a frame it stays
transparent so the plain `<video>` shows through. Directly above the media, a scrim div:
`position:absolute; inset:0; background:color-mix(in srgb,var(--scrim) 30%,transparent)`.

### 2. Masthead (`<header>`, `z-index:20`)
A bar at the top: `border-bottom:1px solid rgb(255 255 255/.2); height:60px; padding:0 30px;
display:flex; align-items:center; justify-content:space-between`. Three logical parts:
- **navStart** (left): a `<nav aria-label="Primary">` with links **Home** (`/`) and **Stays**
  (`/stays`), `display:flex; gap:64px`.
- **brand** (centre): a link **ALTITUDE** (`text-transform:uppercase`), Gilda Display,
  `font-size:var(--text-lead)`, **absolutely centred**: `position:absolute; left:50%;
  transform:translateX(-50%)`.
- **navEnd** (right): a `<nav aria-label="Secondary">` with links **Journal** (`/journal`) and
  **About** (`/about`), `display:flex; gap:64px`.
- Links: `font-size:var(--text-body); color:var(--on-media)`; hover →
  `color:rgb(255 255 255/.6)` via `transition:color var(--duration-fast) var(--ease-entrance)`.
- Below `md` (≤768px) stack it: brand on its own centred line, the two nav groups pushed to the
  edges on a row beneath; drop the absolute centring.

### 3. Column rules (`aria-hidden`, `z-index:30`, only shown ≥`lg` 1024px)
Two 1px vertical hairlines, `background:rgb(255 255 255/.2)`, spanning from just under the masthead
to the bottom: `position:absolute; top:60px; bottom:0`; one at `left:260px`, the other at
`right:260px`. `transform-origin:top`. They **draw downward** on intro (scaleY 0→1, see motion).

### 4. Centre band (heading + form) — `z-index:10`
A flex column, `flex:1; align-items:center; justify-content:center; gap:32px; padding:48px 30px`.
- **Heading** `<h1>`: two lines, centred, Gilda Display, `font-size:var(--text-display)` (64px;
  28px below `sm`), `line-height:1.1`, `color:var(--on-media)`, `max-width:707px`. Apply the
  left-to-right fade as a **mask** (not background-clip, which would break the per-letter spans):
  `mask-image:var(--display-fade); -webkit-mask-image:var(--display-fade)`. The two lines are
  **authored breaks** (one `<span>` each, `display:flex; justify-content:center`):
  - Line 1: `A light on the mountain,`
  - Line 2: `kept on just for you.`
  Reveal is **letter-by-letter** (see motion). Split each line into per-character inline-block
  spans; spaces get width but no animated glyph.
- **Form** (see section 5).

### 5. Enquiry form (glass capsule)
A `<form>` centred under the heading, `max-width:553px; width:100%`. Single frosted capsule:
`border:1px solid rgb(255 255 255/.3); background:color-mix(in srgb,var(--scrim) 20%,transparent);
backdrop-filter:blur(var(--blur-glass)); box-shadow:var(--glass-inset);
border-radius:var(--radius-field); height:54px; display:flex; align-items:center; gap:34px;
padding:3px 3px 3px 34px`. `focus-within` → border `rgb(255 255 255/.6)`.
- Two inputs, each with an `sr-only` `<label>`: **Name** (`type=text`, placeholder "Name") and
  **Email** (`type=email`, placeholder "Email"), each `width:150px`, transparent background,
  `color:var(--on-media)`, `font-size:var(--text-body)`; placeholder colour `var(--on-media)`,
  dimming to `rgb(255 255 255/.5)` on focus; no outline.
- Submit **button**: label **Send**, Gilda Display `font-size:var(--text-lead)`, height 48px,
  `padding:0 48px`, `border:1px solid rgb(255 255 255/.8)`, `border-radius:var(--radius-button)`,
  `background-image:var(--action-gradient)`, `color:var(--action-foreground)`; followed by a small
  4px dot (`border-radius:50%; background:var(--action-foreground)`). Hover → `opacity:.85`.
- `submit` is intercepted (`preventDefault`) — no backend.
- Below `sm` the capsule becomes a stacked card: `flex-direction:column; gap:12px; padding:12px;
  border-radius:var(--radius-button); height:auto`; inputs go full-width.

### 6. Stats bar (`z-index:10`, frosted, along the foot)
A grid, `border-top:1px solid rgb(255 255 255/.2)`,
`background:color-mix(in srgb,var(--scrim) 40%,transparent)`,
`backdrop-filter:blur(var(--blur-glass-strong))`, `padding:30px`. From `lg`:
`grid-template-columns:1fr auto 1fr; align-items:start`. Three cells:
- **Left figure** — a `<dl>` rendered `flex-direction:column-reverse` (value visually above label,
  DOM order `dt`→`dd`): term **Stays scouted**, figure **240+**. Figure = Gilda Display
  `font-size:var(--text-title)` uppercase; term = `font-size:var(--text-body)`,
  `color:rgb(255 255 255/.7)`, `white-space:nowrap`. Left-aligned.
- **Centre pitch** — `<h2>` **Stays worth the climb** (Gilda Display, `--text-title`, centred),
  then a 4px dot (`background:rgb(255 255 255/.2)`), then a `<p>` (`max-width:524px`, centred,
  `color:rgb(255 255 255/.7)`, `--text-body`) with the exact copy:
  > Hearth finds the cabins and lodges that sit where the road runs out — no neighbors, no noise,
  > just a warm room and a sky you forgot existed. Every stay is scouted in person, so all you have
  > to do is arrive.
  The paragraph reveals **word-by-word** (see motion).
- **Right figure** — same `<dl>` shape, right-aligned (`items-end; text-align:right`): term
  **Nights off-grid**, figure **60K**.
- Below `lg`, stack: the pitch takes a full row on top (centred), the two figures side-by-side on a
  2-col row beneath.

## The WebGL / canvas scene — the fluid vortex

A **WebGL2** fluid solver (Pavel Dobryakov's WebGL-Fluid-Simulation, MIT), trimmed: no bloom, no
dithering, no rAF of its own — the caller drives frames, and ink enters only through `splat()`.
Reproduce it verbatim. Get the context with
`canvas.getContext("webgl2",{alpha:true,depth:false,stencil:false,antialias:false,preserveDrawingBuffer:false})`;
bail (leave the plain video showing) if WebGL2 or `EXT_color_buffer_float` is missing.
`OES_texture_float_linear` is optional (picks LINEAR vs NEAREST filtering and the advection shader
variant).

**Config (bake in):**
```js
const FLUID_CONFIG = { simResolution:200, dyeResolution:512, densityDissipation:0.93,
  velocityDissipation:0.96, pressureDissipation:0.8, pressureIterations:20, curl:42,
  splatRadius:0.16 };
```

**Render targets:** dye = double FBO at dye-resolution, `RGBA16F`; velocity = double FBO at
sim-resolution, `RG16F`; divergence, curl = single FBO `R16F` NEAREST; pressure = double FBO
`R16F` NEAREST. Texture type `HALF_FLOAT`. `getResolution(res)` scales by the drawing-buffer aspect
(longer edge = `round(res*aspect)`, shorter = `round(res)`). A fullscreen quad: positions
`[-1,-1,-1,1,1,1,1,-1]`, indices `[0,1,2,0,2,3]`, attribute `aPosition` at location 0. `blit(fbo)`
binds the FBO and `drawElements(TRIANGLES,6,UNSIGNED_SHORT,0)`.

**Vertex shader (all programs share it):**
```glsl
#version 300 es
precision highp float;
in vec2 aPosition;
out vec2 vUv;
out vec2 vL;
out vec2 vR;
out vec2 vT;
out vec2 vB;
uniform vec2 texelSize;
void main () {
  vUv = aPosition * 0.5 + 0.5;
  vL = vUv - vec2(texelSize.x, 0.0);
  vR = vUv + vec2(texelSize.x, 0.0);
  vT = vUv + vec2(0.0, texelSize.y);
  vB = vUv - vec2(0.0, texelSize.y);
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
```

**Fragment header (prepended to every fragment shader below):**
```glsl
#version 300 es
precision highp float;
precision highp sampler2D;
in vec2 vUv;
in vec2 vL;
in vec2 vR;
in vec2 vT;
in vec2 vB;
out vec4 fragColor;
```

**CLEAR:**
```glsl
uniform sampler2D uTexture;
uniform float value;
void main () { fragColor = value * texture(uTexture, vUv); }
```

**SPLAT:**
```glsl
uniform sampler2D uTarget;
uniform float aspectRatio;
uniform vec3 color;
uniform vec2 point;
uniform float radius;
void main () {
  vec2 p = vUv - point.xy;
  p.x *= aspectRatio;
  vec3 splat = exp(-dot(p, p) / radius) * color;
  vec3 base = texture(uTarget, vUv).xyz;
  fragColor = vec4(base + splat, 1.0);
}
```

**ADVECTION (linear-filtering variant):**
```glsl
uniform sampler2D uVelocity;
uniform sampler2D uSource;
uniform vec2 texelSize;
uniform float dt;
uniform float dissipation;
void main () {
  vec2 coord = vUv - dt * texture(uVelocity, vUv).xy * texelSize;
  fragColor = dissipation * texture(uSource, coord);
  fragColor.a = 1.0;
}
```

**ADVECTION (manual-filtering variant, when `OES_texture_float_linear` is absent):**
```glsl
uniform sampler2D uVelocity;
uniform sampler2D uSource;
uniform vec2 texelSize;
uniform vec2 dyeTexelSize;
uniform float dt;
uniform float dissipation;
vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) {
  vec2 st = uv / tsize - 0.5;
  vec2 iuv = floor(st);
  vec2 fuv = fract(st);
  vec4 a = texture(sam, (iuv + vec2(0.5, 0.5)) * tsize);
  vec4 b = texture(sam, (iuv + vec2(1.5, 0.5)) * tsize);
  vec4 c = texture(sam, (iuv + vec2(0.5, 1.5)) * tsize);
  vec4 d = texture(sam, (iuv + vec2(1.5, 1.5)) * tsize);
  return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
}
void main () {
  vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize;
  fragColor = dissipation * bilerp(uSource, coord, dyeTexelSize);
  fragColor.a = 1.0;
}
```

**DIVERGENCE:**
```glsl
uniform sampler2D uVelocity;
void main () {
  float L = texture(uVelocity, vL).x;
  float R = texture(uVelocity, vR).x;
  float T = texture(uVelocity, vT).y;
  float B = texture(uVelocity, vB).y;
  vec2 C = texture(uVelocity, vUv).xy;
  if (vL.x < 0.0) { L = -C.x; }
  if (vR.x > 1.0) { R = -C.x; }
  if (vT.y > 1.0) { T = -C.y; }
  if (vB.y < 0.0) { B = -C.y; }
  fragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);
}
```

**CURL:**
```glsl
uniform sampler2D uVelocity;
void main () {
  float L = texture(uVelocity, vL).y;
  float R = texture(uVelocity, vR).y;
  float T = texture(uVelocity, vT).x;
  float B = texture(uVelocity, vB).x;
  fragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);
}
```

**VORTICITY:**
```glsl
uniform sampler2D uVelocity;
uniform sampler2D uCurl;
uniform float curl;
uniform float dt;
void main () {
  float L = texture(uCurl, vL).x;
  float R = texture(uCurl, vR).x;
  float T = texture(uCurl, vT).x;
  float B = texture(uCurl, vB).x;
  float C = texture(uCurl, vUv).x;
  vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
  force /= length(force) + 0.0001;
  force *= curl * C;
  force.y *= -1.0;
  vec2 vel = texture(uVelocity, vUv).xy;
  fragColor = vec4(vel + force * dt, 0.0, 1.0);
}
```

**PRESSURE:**
```glsl
uniform sampler2D uPressure;
uniform sampler2D uDivergence;
void main () {
  float L = texture(uPressure, vL).x;
  float R = texture(uPressure, vR).x;
  float T = texture(uPressure, vT).x;
  float B = texture(uPressure, vB).x;
  float divergence = texture(uDivergence, vUv).x;
  fragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0);
}
```

**GRADIENT SUBTRACT:**
```glsl
uniform sampler2D uPressure;
uniform sampler2D uVelocity;
void main () {
  float L = texture(uPressure, vL).x;
  float R = texture(uPressure, vR).x;
  float T = texture(uPressure, vT).x;
  float B = texture(uPressure, vB).x;
  vec2 velocity = texture(uVelocity, vUv).xy;
  velocity.xy -= vec2(R - L, T - B);
  fragColor = vec4(velocity, 0.0, 1.0);
}
```

**COMPOSITE (the one custom shader — draws the video warped by the velocity field, ink over top):**
```glsl
uniform sampler2D uDye;
uniform sampler2D uVideo;
uniform sampler2D uVelocity;
uniform vec2 texelSize;
uniform vec2 uSimTexel;
uniform vec2 uVideoScale;
uniform vec2 uVideoOffset;
uniform float uWarp;
uniform float uAberration;

vec2 cover (vec2 uv) { return uv * uVideoScale + uVideoOffset; }

const float MAX_DISPLACEMENT = 0.035;

void main () {
  vec3 dye = texture(uDye, vUv).rgb;

  float mask = smoothstep(0.015, 0.32, max(dye.r, max(dye.g, dye.b)));

  vec2 flow = texture(uVelocity, vUv).xy * uSimTexel * uWarp;
  float distance = length(flow);
  if (distance > MAX_DISPLACEMENT) flow *= MAX_DISPLACEMENT / distance;

  vec2 displacement = flow * mask;
  float strength = clamp(length(displacement) / MAX_DISPLACEMENT, 0.0, 1.0);
  vec2 shift = displacement * uAberration;

  vec3 media;
  media.r = texture(uVideo, cover(vUv + displacement + shift)).r;
  media.g = texture(uVideo, cover(vUv + displacement)).g;
  media.b = texture(uVideo, cover(vUv + displacement - shift)).b;

  float luminance = dot(media, vec3(0.299, 0.587, 0.114));
  media += (media - vec3(luminance)) * strength * 0.55;

  vec3 L = texture(uDye, vL).rgb;
  vec3 R = texture(uDye, vR).rgb;
  vec3 T = texture(uDye, vT).rgb;
  vec3 B = texture(uDye, vB).rgb;
  vec3 C = dye;
  float dx = length(R) - length(L);
  float dy = length(T) - length(B);
  vec3 n = normalize(vec3(dx, dy, length(texelSize)));
  float diffuse = clamp(dot(n, vec3(0.0, 0.0, 1.0)) + 0.7, 0.7, 1.0);
  C *= diffuse;

  fragColor = vec4(media + C, 1.0);
}
```

**`splat(x, y, dx, dy, color)`** — first into velocity: viewport = sim size, `uTarget`=velocity
read, `aspectRatio`=canvas.w/canvas.h, `point`=`(x/canvas.w, 1 - y/canvas.h)`,
`color`=`(dx, -dy, 1)`, `radius`=`splatRadius/100`; blit→velocity write, swap. Then into dye:
viewport = dye size, same program, `uTarget`=dye read, `color`=`(r,g,b)`; blit→dye write, swap.

**`step(dt)`** (dt fixed at **0.016**), all at sim viewport, blend disabled:
1. **curl**: bind velocity → curl FBO.
2. **vorticity**: velocity+curl, uniform `curl`=42, `dt` → velocity write, swap.
3. **divergence**: velocity → divergence FBO.
4. **clear** pressure: `uTexture`=pressure read, `value`=`pressureDissipation` (0.8) → pressure
   write, swap.
5. **pressure**: `uDivergence` bound once, then loop `pressureIterations` (20) times: bind pressure
   read → pressure write, swap.
6. **gradientSubtract**: pressure+velocity → velocity write, swap.
7. **advection** of velocity: `uVelocity`=`uSource`=velocity read, `dt`, `dissipation`=
   `velocityDissipation` (0.96) → velocity write, swap.
8. **advection** of dye at dye viewport: `uVelocity`=velocity read, `uSource`=dye read,
   `dissipation`=`densityDissipation` (0.93) → dye write, swap. (Manual variant also sets
   `dyeTexelSize`.)

**`render({source, sourceAspect, warp, aberration})`** at drawing-buffer viewport: if no source,
clear to transparent `(0,0,0,0)` and return (plain video shows through). Else upload the `<video>`
to a texture on unit 2 (`UNPACK_FLIP_Y_WEBGL=true` during upload — vUv is y-up, the frame y-down;
`LINEAR` filtering, `CLAMP_TO_EDGE`). Compute object-fit-cover scale/offset from
`sourceAspect` vs the drawing-buffer aspect. Bind the composite program with `uSimTexel`=
`(1/simW,1/simH)`, `uVideoScale`, `uVideoOffset=((1-scaleX)/2,(1-scaleY)/2)`, `uWarp`, `uAberration`,
`uDye`=dye read, `uVelocity`=velocity read, `uVideo`=unit 2; blit to the default framebuffer.

### Driving the vortex (the interaction — reproduce exactly)

Two springs and a stirring orbit. Constants:
```js
const FOLLOW_CONFIG = { tension:90, friction:24, mass:1.3 };  // vortex chases the pointer, laggy
const FADE_CONFIG   = { tension:24, friction:22, mass:1.2 };  // intensity swells in / dies away
const ORBIT_SPEED   = 0.026;   // rad/frame — ~one revolution / 4s
const ORBIT_RADIUS  = 150;     // px, capped against the viewport
const STIR_STRENGTH = 9;       // injected velocity per frame, scaled by how far the orbit moved
const INK_GAIN      = 0.3;     // dye push (kept low so it doesn't saturate to white over video)
const COLOR_INTERVAL= 120;     // ms between colour picks
const IDLE_TIMEOUT  = 180;     // ms of a still cursor before the vortex fades
const WARP_STRENGTH = 0.2;     // how hard the flow drags the picture
const ABERRATION    = 0.09;    // colour split as a fraction of the displacement
```
- Spring `vortex = { x, y, intensity }`. On `pointermove` (window, passive): map to canvas-local
  `x,y`; on the **first** move `set` `{x,y}` immediately (so it never flies in from 0,0); mark not
  idle and start `intensity→1` with `FADE_CONFIG`; start `{x,y}` toward the pointer with
  `FOLLOW_CONFIG`. On `pointerleave`(document)/`blur`(window): idle, `intensity→0`.
- **Per frame** (rAF; throttle sim work to ~60fps): call `resize()`. If not idle and
  `now - lastMove > IDLE_TIMEOUT`, go idle and fade `intensity→0`. Read `intensity`; if `>0.01`:
  advance `angle += ORBIT_SPEED`; compute `radius = min(ORBIT_RADIUS, canvas.w*0.22, canvas.h*0.28)
  * (0.72 + 0.28*sin(angle*0.37))`; the stir point is `(vortex.x + cos(angle)*radius,
  vortex.y + sin(angle)*radius)`. On the first orbiting frame just record it; afterwards every
  `COLOR_INTERVAL` ms re-pick the colour, then `splat(x, y, (x-prevX)*STIR_STRENGTH,
  (y-prevY)*STIR_STRENGTH, {r*intensity, g*intensity, b*intensity})`; update prev. Then
  `step(0.016)`. Finally `render({ source: videoReady ? videoEl : null,
  sourceAspect: videoW/videoH, warp: WARP_STRENGTH*intensity, aberration: ABERRATION })` — note
  warp scales with intensity, so a still cursor = a still picture.
- **Colour pick** — bias toward the warm end, land near one hue or the other (never the muddy
  middle); the solver does the mixing:
  ```js
  const warm = [1.0, 0.843, 0.353];  // #ffd75a in 0..1
  const cool = [0.549, 0.812, 1.0];  // #8ccfff in 0..1
  const t = Math.random() < 0.55 ? Math.random()*0.2 : 0.8 + Math.random()*0.2;
  const color = { r:(warm[0]+(cool[0]-warm[0])*t)*INK_GAIN,
                  g:(warm[1]+(cool[1]-warm[1])*t)*INK_GAIN,
                  b:(warm[2]+(cool[2]-warm[2])*t)*INK_GAIN };
  ```
- **Disable on small/touch screens** (roughly `width < 768`): skip the canvas + listeners and let
  the plain `<video>` play — matches the original's mobile-off behaviour.

## The loader / reveal (the hero moment the preview captures)

A fixed full-screen overlay, `z-index:60`, `background:var(--intro-ground)` (#04305c), centred
content. It counts up, then clears as a circular aperture.

- **Comet field** — a `<canvas>` filling the overlay, 14 comets drifting **down and to the left**
  (angle `θ = π*0.28`; `dirX = -cos θ`, `dirY = sin θ`). Each comet: `speed` 260–620 px/s, tail
  `length` 90–320px, `width` 0.6–1.6px, `alpha` 0.25–0.9, seeded at random positions. Draw each as
  a tapered `createLinearGradient` from tail (`rgba(255,255,255,0)`) to head
  (`rgba(255,255,255,alpha)`), `lineCap:round`. When a comet's **tail** clears the left/bottom
  edge, respawn it above-and-right with fresh randoms. DPR-capped at 2.
- **Counter ring** — a centred box (~300px, ~340px ≥sm) holding an SVG `viewBox="0 0 100 100"`
  rotated `-90deg`: a circle `r=48`, `stroke-width=0.25`, `stroke=rgb(255 255 255/.7)`, drawn with
  `stroke-dasharray=2πr` and `stroke-dashoffset=2πr*(1 - value/100)` so it fills as the number
  climbs. In the centre, Gilda Display `font-size:var(--text-counter)` (44px): the rounded value in
  `var(--on-media)` then a `%` sign in `rgb(255 255 255/.6)`.
- **Timing** — spring `counter.value`: start toward **90** with `{tension:18,friction:26,mass:1.4}`
  (counts up on trust). Enforce a **minimum visible time of 1300ms** from mount; once both the
  `window load` event has fired and that floor has passed, spring `value→100` with
  `{tension:130,friction:26}`. `onRest` of that: **flip the intro gate to complete** (the hero
  begins animating in *now*, through the opening), then start the reveal.
- **Reveal (aperture, not a fade)** — spring `reveal.radius` 0→**145** with
  `{tension:90,friction:32,mass:1,precision:0.5}`. Apply to the overlay as a **mask** so it never
  changes opacity — a hole opens from the centre and grows past the corners:
  ```js
  const REVEAL_FEATHER = 14;
  const aperture = r => {
    const inner = Math.max(0, r - REVEAL_FEATHER);
    return `radial-gradient(circle at 50% 50%, transparent ${inner}%, black ${r}%)`;
  };
  // overlay.style.maskImage = overlay.style.webkitMaskImage = aperture(reveal.radius)
  ```
  The feather is on the **inside** of the radius (opaque stop anchored at `r`, transparent at
  `r-feather`) so the centre stays solid until the reveal actually starts. `onRest` → unmount the
  overlay. Under `prefers-reduced-motion`, jump every spring to its end (overlay clears immediately).

## Hero entrance (gated on the intro completing, i.e. as the aperture opens)

Everything is mounted from first paint holding its "out" state; when the gate flips, run these
physics-spring entrances (delays in ms from the flip):
- **Masthead** — `opacity 0→1, y -14→0`, `{tension:60,friction:24}`, delay 0.
- **Column rules** — `scaleY 0→1` from `transform-origin:top`, `opacity 0→1`,
  `{tension:40,friction:26,mass:1.2}`, delay 240 (they rule themselves downward).
- **Heading** — **letter by letter**, per character: `y 26→0, opacity 0→1, blur(10px)→blur(0)`,
  `{tension:90,friction:24,mass:1.1}`, stagger **26ms** per letter, base delay 120. Line 2's letters
  continue line 1's stagger so the two lines read as one sweep (line 2 base delay =
  `120 + (letters in line 1)*26`). **No `overflow` clipping** — the blur halo must not be cropped.
- **Form** — `opacity 0→1, y 22→0, blur(8px)→blur(0)`, `{tension:60,friction:26}`, delay 760.
- **Stats bar** — the bar rises as one: `opacity 0→1, y 30→0`, `{tension:55,friction:26}`,
  delay 900. Then the pitch paragraph reveals **word by word**: `y 16→0, opacity 0→1,
  blur(7px)→blur(0)`, `{tension:80,friction:24}`, stagger **18ms**, delay 1150.

Implement the letter/word reveals by wrapping each letter/word in an inline-block span and
animating its transform/opacity/filter on a spring with the per-item stagger; drive all springs off
one shared rAF loop.

## Fixed parameters (bake these in)

- Copy: heading `["A light on the mountain,", "kept on just for you."]`; brand `ALTITUDE`; nav
  `Home /stays` … as listed; button `Send`; stats `240+ / Stays scouted`, `60K / Nights off-grid`;
  pitch title `Stays worth the climb` + the exact body paragraph above.
- Fluid: sim 200, dye 512, densityDissipation 0.93, velocityDissipation 0.96, pressureDissipation
  0.8, pressureIterations 20, curl 42, splatRadius 0.16; `dt=0.016`; MAX_DISPLACEMENT 0.035;
  INK_GAIN 0.3; WARP_STRENGTH 0.2; ABERRATION 0.09; ORBIT_SPEED 0.026; ORBIT_RADIUS 150;
  STIR_STRENGTH 9; COLOR_INTERVAL 120; IDLE_TIMEOUT 180.
- Preloader: HOLD_AT 90, MIN_VISIBLE_MS 1300, REVEAL_END 145, REVEAL_FEATHER 14, ring r=48,
  14 comets, angle π*0.28.
- Colours/tokens exactly as in the `:root` block above. Gradient
  `linear-gradient(104.458deg,#ffe5ac 10.028%,#97bdde 100%)`. Ink hues `#ffd75a` / `#8ccfff`.

## Assets

- **Hero background video** — `starry-night.mp4` (a slow, dark, starry mountain night with
  occasional meteors):
  `https://api.getlayers.ai/storage/v1/object/public/public/assets/altitude-5d53730046/starry-night.mp4`
  Load it into the `<video>` element and use that same element as the WebGL video texture. No other
  local assets.
[/hidecontent]
← 返回首页