Wanderlust
aadmin2026-11-05 发布Site模板9 阅读

Wanderlust

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

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

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, sections, visuals, motion and
interaction. Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. Use ES modules
with CDN importmaps for the two libraries used. Hardcode every value given here as a fixed
constant. Rebuild each block described below as a section of the one file.

## What it is

A dark, cinematic landing page for **Wanderlust**, a small-group adventure travel company. It opens
on a counting preloader, dissolves into aerial footage of a glacial river valley, and then the
interface *assembles around* a brightened window cut into that footage — a lens opening on the
shot, with the navigation, display type and captions gathering after it. Below that: a scattered
pile of sixteen polaroid postcards on near-black, a pinned photographic manifesto whose headline
writes itself word by word, four full-bleed "how we travel" panels that widen as you point at them,
and a parallax mountain CTA with a handwritten note that writes itself onto the view.

The palette is almost monochrome — true black and a near-black `#0c0c0c`, with everything written
in a warm cream `#ffedc3` at four different alphas, plus one saturated red used *only* for the
recording lamp. Type is Instrument Serif (display, with the leading capital of every word set in
the italic cut), Inter Tight Medium (all UI), and Caveat (one handwritten sign-off). Motion is
spring-based throughout, and the whole page is smooth-scrolled.

## Page shell & libraries

```html
<script type="importmap">
{ "imports": {
    "lenis": "https://unpkg.com/lenis@1.3.19/dist/lenis.mjs"
} }
</script>
```

Fonts — load from Google Fonts (the original self-hosts the same three families):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter+Tight:wght@500&family=Caveat:wght@400;700&display=swap" rel="stylesheet">
```

Springs: implement a tiny spring integrator in vanilla JS (tension/friction pairs are given
throughout, matching `@react-spring/web` semantics: `v += (-tension * (x - target) - friction * v) * dt`).
Do not pull in a spring library.

Smooth scroll: instantiate Lenis at defaults and drive it from `requestAnimationFrame`. Expose a
`stop()` / `start()` pair — the preloader holds scroll until it lifts.

### Global CSS — the token system, verbatim

```css
:root {
  /* primitives */
  --raw-color-white: #ffffff;
  --raw-color-neutral-100: #ededed;
  --raw-color-neutral-900: #171717;
  --raw-color-neutral-950: #0a0a0a;
  --raw-color-black: #000000;
  --raw-color-cream-100: #ffedc3;
  --raw-color-cream-100-alpha-40: rgb(255 237 195 / 0.4);
  --raw-color-cream-100-alpha-50: rgb(255 237 195 / 0.5);
  --raw-color-cream-100-alpha-80: rgb(255 237 195 / 0.8);
  --raw-color-black-alpha-50: rgb(0 0 0 / 0.5);
  --raw-color-black-alpha-40: rgb(0 0 0 / 0.4);
  --raw-color-black-alpha-15: rgb(0 0 0 / 0.15);
  --raw-color-signal-red: #ff3b30;
  --raw-color-ink-950: #0c0c0c;
  --raw-color-ink-950-alpha-10: rgb(12 12 12 / 0.1);

  --raw-duration-fast: 150ms;
  --raw-duration-normal: 250ms;
  --raw-radius-xs: 0.25rem;

  /* type scale — px @1440 ÷ 16 */
  --raw-text-display: 5.625rem;   /* 90px */
  --raw-text-wordmark: 1.5rem;    /* 24px */
  --raw-text-body: 0.875rem;      /* 14px */
  --raw-text-caption: 0.75rem;    /* 12px */

  /* line-heights are ABSOLUTE (multiplier × size), not ratios */
  --raw-leading-title: 4.5rem;     /* 72px  = 90 × 0.8 */
  --raw-leading-wordmark: 1.35rem; /* 21.6px = 24 × 0.9 */
  --raw-leading-body: 0.7rem;      /* 11.2px = 14 × 0.8 */
  --raw-leading-caption: 0.6rem;   /* 9.6px  = 12 × 0.8 */

  --raw-spacing-page: 1.5rem;  /* 24px */
  --raw-spacing-md: 0.75rem;   /* 12px */
  --raw-spacing-sm: 0.625rem;  /* 10px */
  --raw-spacing-xs: 0.375rem;  /* 6px */

  /* semantic roles */
  --background: var(--raw-color-white);
  --foreground: var(--raw-color-neutral-900);
  --surface-cinema: var(--raw-color-black);
  --surface-glass: var(--raw-color-ink-950-alpha-10);
  --surface-gallery: var(--raw-color-ink-950);
  --foreground-accent: var(--raw-color-cream-100);
  --foreground-accent-muted: var(--raw-color-cream-100-alpha-40);
  --foreground-accent-soft: var(--raw-color-cream-100-alpha-50);
  --foreground-accent-strong: var(--raw-color-cream-100-alpha-80);
  --foreground-ink: var(--raw-color-black);
  --foreground-ink-muted: var(--raw-color-black-alpha-40);
  --foreground-signal: var(--raw-color-signal-red);
  --scrim-page: var(--raw-color-black-alpha-50);
  --scrim-media: var(--raw-color-black-alpha-40);
  --scrim-scene: var(--raw-color-black-alpha-40);
  --scrim-cta: var(--raw-color-black-alpha-15);
  --scrim-panel: var(--raw-color-black-alpha-50);
  --rounding-media: var(--raw-radius-xs);

  --typeface-display: "Instrument Serif", serif;
  --typeface-ui: "Inter Tight", sans-serif;
  --typeface-hand: "Caveat", cursive;

  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
}

body {
  background: var(--background);
  color: var(--foreground);
  min-height: 100vh;
  /* `clip`, NOT `hidden` — overflow-x:hidden makes body a scroll container and
     silently breaks position:sticky on the pinned sections. */
  overflow-x: clip;
}
```

### The adaptive rem grid — this is load-bearing

Every layout number in this document is a **Figma pixel at a 1440-wide frame**, expressed in rem
against a 16px root. Scale the root font-size with the viewport so the whole design stays
proportional:

```css
html { font-size: 16px; }
@media (max-width: 1440px) { html { font-size: 1.111111vw; } } /* 16 * 100 / 1440 */
@media (max-width: 1024px) { html { font-size: 1.5625vw; } }   /* 16 * 100 / 1024 */
@media (max-width: 640px)  { html { font-size: 4.444444vw; } } /* 16 * 100 / 360  */
```

Above 1440px, scale the root font-size up fully proportionally: `font-size: (100vw / 1440) * 16px`.
Breakpoints elsewhere: `md` = 40.0625rem (641px), `lg` = 64.0625rem (1025px). So `max-lg:` means
≤1024 (tablet and below) and `max-md:` means ≤640 (mobile).

Add a `text-trim` utility used on every display text layer — it is what lets a 0.8 line-height sit
flush against the 24px page margin:

```css
.text-trim { text-box-trim: trim-both; text-box-edge: cap alphabetic; }
```

And a viewport utility, in this declaration order:

```css
.min-h-viewport { min-height: 100vh; min-height: 100dvh; }
```

### SegmentedText — the italic alternation

Display type across the whole page is rendered from arrays of `{ text, italic }` segments. The
**leading capital of every word takes the italic cut of Instrument Serif; the remainder is roman.**
Write one helper that renders such an array into `<span>`s and reuse it for the wordmark, the hero
title, and every block heading. Example: `Wanderlust` is `[W italic][ander][L italic][ust]`.

## The preloader

Full-screen, `--surface-cinema` (#000). Centred: the segmented wordmark in Instrument Serif, and a
counter that runs **00 → 100** over `COUNT_MS = 1200`ms tied to media readiness.

Timings, baked in:
- `HOLD_MS = 340` — beat after the count lands before the lift begins.
- `COUNT_MS = 1200` — the count.
- `LIFT_MS = 780` — the panel lifting away.
- `MIN_SHOW_MS = 2200` — the preloader is never shown for less than this, even from cache.

It waits on the hero clip having **frames** (`readyState >= 2`), not on it being fully downloaded.
Scroll is stopped while it is up and started as it lifts. It shows once per page load only.

## Block 1 — Hero

A full-viewport section on `--surface-cinema`. Layout numbers below are the Figma record for the
1440×800 frame; the page inset is a uniform **24px** (1.5rem).

```
margin              24                  uniform page inset
wordmark            left 24,  top 24     top-left, fixed
nav                 left 994, top 24, width 92
explore             left 1235, top 24, width 181   (flush right: 1440 − 24 − 181)
card                left 470, width 500, height 315, top calc(50% − 157.5)
cardCaptionTop      left 470, width 500, top calc(50% − 178.5)   (21px above card)
cardCaptionBottom   left 470, width 500, top calc(50% + 169.5)   (12px below card)
titleTop            calc(50% − 69)      both display blocks are two lines of 72px leading
titleStart          right 994 (= 1440 − 446), width 238
titleEnd            left 994, width 340.138     (446 + 994 = 1440 — mirrored about centre)
thumbnail           left 607, width 227, heights 132 (top) / 133 (bottom), centred,
                    bleeding off both edges
destinations        left 994, bottom 24         bottom-right, shares the 994 edge
viewModes           left 24,  bottom 24         bottom-left
```

The wordmark and nav are `position: fixed`, not absolute — they are the site's chrome.

### Copy, verbatim

- **wordmark**: `Wanderlust` — segments `[W italic][ander][L italic][ust]`
- **nav**: `JOURNAL.` `EXPERIENCES.` `ABOUT.` `CONTACT.`
- **explore**: `explore Destinations` (lowercase in the copy, `text-transform: uppercase` in CSS)
- **titleStart**: `Beyond Places` — `[B italic][eyond ][P italic][laces]`
- **titleEnd**, two lines: `Into` — `[I italic][nto]`; `Moments` — `[M italic][oments]`
- **cardCaptionTop**: `TRAVEL STORIES` · `JOURNAL`
- **cardCaptionBottom**: `01.` · `REAL JOURNEYS` · `42.`
- **destinationsLabel**: `ALL DESTINATIONS`
- **destinations**: `Iceland` `Norway` `Japan` `Indonesia`
- **viewModes**: `SLIDER` (active, full opacity) · `LIST` (muted)

Captions and small UI are Inter Tight Medium at `--text-caption` (12px) / `--leading-caption`,
letter-spaced slightly, in `--foreground-accent-muted`; the active view mode is
`--foreground-accent`.

### The opening — footage, veil, grain

A `<video>` fills the section: `Hero-video.mp4`, 2560×1440, muted, playsinline, autoplay.
`aria-label="Travel footage: a glacial river valley seen from the air"`.

- `VIDEO_RATE = 2` — `playbackRate = 2`. The footage is a slow aerial; at 1× the hero spends four
  seconds saying one thing. Doubling keeps every frame and halves the wait.
- `VEIL_MS = 900` — a `--scrim-page` (black 0.5) veil fades **up** over the footage across the
  first 900ms, starting with the video. The hero opens bright and settles into its mood.
- Film grain over the footage. One noise tile, **stepped** between fixed offsets — grain that
  slides is dust on the lens; grain that jumps is grain:

```js
const GRAIN = { cycleMs: 800, steps: 8, opacity: 0.3, tile: 200, shift: 41 };
```

Render it as a repeating background at `mix-blend-mode: overlay` (overlay leaves mid-tones alone and
works into shadows and highlights, which is where film grain lives). The tile is an inline SVG
`feTurbulence`, verbatim:

```
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23g)' opacity='0.9'/%3E%3C/svg%3E")
```

Step the tile's `translate` between 8 offsets `shift * i` px on an 800ms loop.

### The still swap

- `STILL_MS = 520` — when the clip reaches its last frame, cross-dissolve `hero-final-frame.jpg`
  (the clip's own last frame at 2560×1440) over the video across 520ms. A dissolve, not a cut: the
  still carries detail the encode threw away, and a cut would read as the picture sharpening in one
  tick.
- `STILL_LEAD_S = 1/30` — treat "within one frame at 30fps of the end" as being *at* the end;
  `ended` does not fire when a container's declared duration and last frame's presentation time
  disagree.

### The assembly

- `ASSEMBLY_MS = 2400`, driven by a linear master progress 0→1.
- `ASSEMBLY_LEAD_S = 0.4` **media seconds** before the clip's last frame the assembly starts (so
  0.2s wall clock at 2× rate) — the mask opens while the last of the movement is still on screen,
  so it reads as one gesture rather than two events.
- `ASSEMBLY_FALLBACK_MS = 8000` — start the assembly this long after `play()` is called no matter
  what the video does. Autoplay can be refused, a decode can stall, a tab can be backgrounded. The
  failure must land on *the interface being there*.

Phase breakpoints — everything is interpolated against these, no state switching, so any value in
between is a valid picture:

```js
const PHASE = { maskDuration: 0.3, uiStart: 0.3, uiStagger: 0.055, uiDuration: 0.2 };
const MASK  = { from: 0.62 };
```

The **window** (the card) scales up from `0.62` into place across the first 30% — it is a lens
opening on the footage, not a card dealt onto it. Below ~0.5 it reads as a flash rather than an
opening.

UI groups assemble in this order — the card **first and alone**, the interface gathering *around
what the lens found*:

```js
const UI_ORDER = ["card","captions","titles","wordmark","nav","explore","destinations","viewModes","thumbnails"];
```

```js
// the card starts at 0; everything else staggers from ITS index
const uiStart = g => g === "card" ? 0 : PHASE.uiStart + (UI_ORDER.indexOf(g) - 1) * PHASE.uiStagger;
const groupDuration = g => g === "card" ? PHASE.maskDuration : PHASE.uiDuration;
const smooth = t => { const x = Math.min(1, Math.max(0, t)); return x * x * (3 - 2 * x); };
const groupEase = (p, g) => smooth((p - uiStart(g)) / groupDuration(g));
```

Each group fades in and rises `REVEAL_TRAVEL = 1.25` rem. Keep the easing per-group (as above) and
the master linear, so `uiStagger` stays a real interval in ms and every group gets the same shape
regardless of where it sits in the timeline.

### The window's interior, and pointer-follow

Inside the card's rounded rect (`--rounding-media`, 4px) the footage is shown **undimmed and
graded**: undo the page scrim and add

```js
const HERO_TUNING = { cardLerp: 0.08, brightness: 1.18, saturation: 1.25, scrimOpacity: 1, returnMs: 800 };
```

so the window reads as a brighter, more saturated view of the same shot. The card and its two
caption rows follow the pointer on a lerp of `0.08` (heavier = lags further behind); the captions
move on the card's own vector. On pointer-leave they glide back to centre over `returnMs = 800`.
Fold the mask's entrance scale into the same single transform the ticker writes.

### The recording light

A small dot inside the window — a camera's tally lamp, in `--foreground-signal` (#ff3b30). It is
the one thing on the page that must read as a *signal* rather than as part of the composition.

```js
const REC = { pulseMs: 620, dim: 0.18 };
```

`pulseMs` is one half-cycle; the spring loops with `reverse`, so a full blink is twice that. It
never goes fully out — a dot that vanishes has been removed; one that drops to 0.18 is the same
lamp, dark for a beat.

### The two edge thumbnails

`hero-thumb2.png` (1440×800), used for both. Centred at left 607, width 227, heights 132 (top) and
133 (bottom), bleeding off both edges, dimmed by `--scrim-media` (black 0.4). Alt:
`A traveller raising a camera over a glacial river valley`.

### Small screens

Follow the reference arrangement: wordmark left and a `MENU` disclosure right along the top, the
switcher centred between them, display lines stacked and centred over a full-bleed card, and a
single bottom row — destinations left, explore right. The card is

```css
width: min(100vw - 3rem, 31.25rem, (100vh - 36rem) / 0.63);
```

All three terms are needed: without the second a 1024 tablet gets a card larger than desktop;
without the third a 1024×768 pushes "Beyond Places" off the top of the screen. Centre it vertically
with a `×0.315` offset rather than `translateY(-50%)` — the card's transform belongs to the
pointer-follow ticker.

## Block 2 — Destinations

Section on `--surface-gallery` (#0c0c0c) — a shade off true black so the white polaroid paper reads
as paper rather than as a cut-out. Frame is **1440×687**; lay the scene out with container queries
so every coordinate is a `cqw` fraction of the 1440 frame.

Heading, two lines, centred:
`Destinations` — `[D italic][estinations]`
`Worth Wandering` — `[W italic][orth ][W italic][andering]`

### The pile — sixteen polaroids, every one on the scene at once

`cx`/`cy` are the centre of the card's **ink** in frame coordinates and `width` is that ink's
width. No height — it comes from the photograph's own proportions, so a card can never be drawn to
a shape its file does not have. No rotation — every export is already turned. `z` is paint order,
1 at the bottom, and is **not** the reading order.

| id | name | file | cx | cy | width | z |
|---|---|---|---|---|---|---|
| iceland | ICELAND | Iceland.png | 982.59 | 370.28 | 212.092 | 7 |
| norway | NORWAY | Norway.png | 311.23 | 116.27 | 177.669 | 11 |
| japan | JAPAN | Japan.png | 441.86 | 352.08 | 193.726 | 15 |
| morocco | MOROCCO | Morocco.png | 381.73 | 551.64 | 191.631 | 16 |
| switzerland | SWITZERLAND | Switzerland.png | 164.38 | 44.99 | 223.278 | 13 |
| italy | ITALY | Italy.png | 292.65 | 406.16 | 185.295 | 10 |
| france | FRANCE | France.png | 723.58 | 413.78 | 208.139 | 4 |
| portugal | PORTUGAL | Portugal.png | 1154.05 | 544.61 | 212.092 | 1 |
| spain | SPAIN | Spain.png | 867.36 | 398.36 | 144.16 | 8 |
| greece | GREECE | Greece.png | 1285.05 | 82.61 | 212.092 | 14 |
| austria | AUSTRIA | Austria.png | 1164.92 | 137.61 | 211.111 | 12 |
| canada | CANADA | Canada.png | 802.95 | 566.67 | 182.347 | 5 |
| new-zealand | NEW ZEALAND | New Zealand.png | 1381.78 | 137.49 | 197.924 | 3 |
| australia | AUSTRALIA | Australia.png | 1065.79 | 471.88 | 172.42 | 6 |
| slovenia | SLOVENIA | Slovenia.png | 576.63 | 452.42 | 208.582 | 9 |
| istanbul | ISTANBUL | Istanbul.png | 654.54 | 319.71 | 190.046 | 2 |

Alt text names the caption printed on the card: `A polaroid captioned Iceland`, and so on.

Two country lists flank the scene — 01–08 fill the left column, 09–16 the right, in the table's
order. Pointing at a name raises its card.

### The three stickers

They belong to no country and sit above every card at rest (`STICKER_Z`); a raised card comes above
them. Centre and size straight from the frame, with **no ink correction** — each file already has
exactly the ratio its box is given.

| file | cx | cy | width | height |
|---|---|---|---|---|
| Sticker1-art.png | 364.53 | 328.1594 | 54.6133 | 66.3188 |
| Sticker3.png | 618.97 | 347.5557 | 40.6249 | 47.1114 |
| Sticker4.png | 1075.5 | 338.5 | 35 | 29 |

### Motion

```js
const CARD_HOVER = {
  from: "translate3d(0px, 0px, 0px) scale(1)",
  to:   "translate3d(0px, -6px, 0px) scale(1.025)",
  config: { tension: 300, friction: 30 },
};
const CARD_TILT = { perspective: 520, lift: 0.7, rise: 40, scale: 1.015,
                    tiltX: 14, tiltY: 18, roll: 2.5,
                    config: { tension: 190, friction: 26 } };
const CARD_DIM  = { rest: 0.45, stickers: 0.62, config: { tension: 150, friction: 26 } };
const toShade   = level => `brightness(${level})`;
```

At rest every card sits at `brightness(0.45)` and the stickers at `0.62`; the pointed-at card comes
to full and tilts toward the cursor.

Entrance, scrubbed on scroll from `top bottom` → `center center`:

```js
const GOLDEN_ANGLE = 137.508;   // fans the entrance directions apart
const ENTRANCE = { span: 0.7, clearance: 3, drift: 6, rotate: 10 };
```

Each card flies in from off-frame along a direction stepped by the golden angle, so no two arrive
from the same place.

Parallax, scrubbed from `center center` → `bottom top`:

```js
const PARALLAX = { rise: 11, spread: 5.5, floor: 0.15, stickers: 0.55 };
```

Touch devices get a press state instead of hover: `-translate-y-[6px] scale-[1.025]` over
`--duration-normal` on `:active`, suppressed under `prefers-reduced-motion`.

## Block 3 — Philosophy

A **pinned** photographic section, frame 1440×800. The backdrop is
`pexels-arthousestudio-4344885.jpg` (5760×6400) — a glacial river braiding across a green valley,
seen from the air — under `--scrim-scene` (black 0.4). It pans as you scroll:

```js
const BACKDROP_PAN = { from: "0%", to: "-50%" };
```

Headline, three lines, centred, in `--foreground-accent`, Instrument Serif at `--text-display`
(90px) / `--leading-title` (72px), the leading capital of every word italic — G, S, T, S, W, Y:

```
Go Somewhere That
Stays
With You
```

It **writes itself word by word** as the section is scrubbed:

```js
const WORD_STAGGER = 0.03;      // progress offset between consecutive words
const WORD_DURATION = 0.14;     // one word's fade
const WORD_TRAVEL_EM = 0.28;    // how far a word rises, in em
const HANDOVER = 0.14;          // where the headline hands over to the rest
```

Two supporting paragraphs flank it, in `--foreground-accent-soft` (cream at 0.5 — half, not the
hero's 0.4, because they sit on a busier photograph):

- left: `We plan trips for the moments you'll still ` / `talk about years later.`
- right: `The quiet mornings, the unexpected turns, ` / `the places you almost didn't find.`

A collage — `Collage.png` (1504×1616), *a polaroid of a traveller with her arms outstretched, taped
over torn notebook pages* — sits turned by `COLLAGE_TURN = 7` degrees.

The sign-off is the **one handwritten voice in the design**, in Caveat (`--typeface-hand`):

```
Less ticking places off a list.
More actually being there.
```

Reveal choreography for the non-headline groups:

```js
const REVEAL_MS = 900, REVEAL_STAGGER = 0.05, REVEAL_DURATION = 0.28;
const smooth = t => { const x = Math.min(1, Math.max(0, t)); return x * x * (3 - 2 * x); };
```

## Block 4 — How We Travel

Frame 1440×800. Heading `How We Travel` — `[H italic][ow ][W italic][e ][T italic][ravel]` — and
subtitle `Different trips, same intention.`

Four full-height panels side by side (`PANEL_COUNT = 4`). Pointing at one **widens** it and dims
the rest:

```js
const WIDEN = { wide: 2.5, narrow: 3.4 };   // desktop / narrow weighting
const MEDIA_TRAVEL = 101;                    // how far the photo travels inside its panel, px
const SLIDE = { tension: 130, friction: 28 };
const HEAD_CUE = 0.55;                       // where the heading cue fires within the open
```

Panel body copy sits under a gradient whose dark end is `--scrim-panel` (black 0.5), and is written
in `--foreground-accent-strong` (cream at 0.8) below the desktop base — the same copy on the same
photographs measured 3.2–4.2 contrast on a phone, and 0.8 takes it to 5.6–8.2.

`bodyWidth` is Figma's own measure for the body column and differs per panel, because each width is
the one that breaks its sentence into the four lines the file draws.

**Panel 1 — Small Group Trips** (`[S italic][mall ][G italic][roup ][T italic][rips]`), bodyWidth 390, `OpenedImage1.png`
> We travel in small groups so everyone feels included, heard and free to be themselves.

Sign-off: `More connection, less crowd.`
Alt: `Four travellers in bright rain jackets cheering on a mossy clifftop above a canyon`

**Panel 2 — Road Trips** (`[R italic][oad ][T italic][rips]`), bodyWidth 420, `OpenedImage2.png`
> Scenic roads, unexpected stops and the freedom to change the plan when it feels right.

Sign-off: `The best views aren't on Google Maps.`
Alt: `A white car alone on a road across black sand, under a jagged mountain ridge`

**Panel 3 — Handpicked Stays** (`[H italic][andpicked ][S italic][tays]`), bodyWidth 400, `OpenedImage3.png`
> We choose places with soul. Local, beautiful, comfortable and run by people who care.

Sign-off: `Stay somewhere you'll remember.`
Alt: `A traveller standing on wet rocks beside a turquoise waterfall`

**Panel 4 — Local Experiences** (`[L italic][ocal ][E italic][xperiences]`), bodyWidth 490, `OpenedImage4.png`
> We meet local people, try real food, learn the stories and see the places that don't make it to the guidebooks.

Sign-off: `The heart of the journey.`
Alt: `Four people around a picnic table with warm mugs, snow-dusted hills behind them`

### Below 1024px — the stacked arrangement

`NARROW_QUERY = "(max-width: 1024px)"`. The row becomes a vertical stack of full-bleed cards:

```
section  bg #0c0c0c, relative, z-10, width 100%, margin-top -100dvh
header   padding-inline 24px, padding-top 104px, padding-bottom 48px, centred
         (padding-top 88px below 640px)
card     position relative, isolate, height 62dvh, min-height 26rem, overflow hidden
         (height 56dvh, min-height 22rem below 640px)
photo    absolute, inset-x 0, top -10%, height 120%, will-change transform
foot     absolute, inset-x 24px, inset-y 0, z-index 2, flex column,
         centred both axes, gap 32px (24px below 640px), text-align center
```

with `STACK_PARALLAX = 0.08` on the photo. Image sizes hint:
`(max-width: 1024px) 100vw, 50vw`.

## Block 5 — Plan a Journey (+ footer)

Frame 1440×1077. A layered parallax mountain scene, and the one block that reads against a **bright
sky** rather than a dark photograph — so its display type and note copy are **ink**, not cream
(`--foreground-ink` / `--foreground-ink-muted`), under a `--scrim-cta` wash of only black 0.15 (a
touch, not a scrim).

Layers, back to front, each parallaxed by pointer with these amplitudes in px:

```js
const PARALLAX = {
  far:        { x: 8,  y: 5  },   // 03_mountains_midground 1.png
  steam:      { x: 16, y: 10 },   // 02_steam_screen_blend 1.png
  foreground: { x: 28, y: 16 },   // 01_foreground 1.png
};
const PARALLAX_CONFIG = { tension: 90, friction: 30 };
```

behind them `BackgroundImage.png` (*a green ridge running down into cloud*).

The steam layer drifts and breathes on its own long loops:

```js
const STEAM_DRIFT_MS = 28000, STEAM_PULSE_MS = 25000;
const STEAM_DRIFT = 1.5, STEAM_SCALE = 0.04, STEAM_OPACITY = 0.85, STEAM_STRENGTH = 0.4;
const STEAM_ALPHA_GAMMA = { exponent: 3, amplitude: 3 };
const STEAM_FILTER_ID = "b5-steam-wisps";
```

Drive the wisps with an SVG turbulence/displacement filter of that id, gamma-shaping the alpha with
the exponent/amplitude pair above so the steam thins out at its edges rather than ending on a hard
line.

### The note that writes itself

`Pin.png` — *a handwritten note pinned to the view* — tilts in and its lines write themselves as the
section is scrubbed:

```js
const WRITE_WINDOW = [0.35, 0.85];   // scroll progress range the writing occupies
const WRITE_LERP = 0.055;
const WRITE_EPSILON = 0.0004;
const NOTE_TILT = { origin: "51% 10%", enter: -9, rest: 0, hover: 2.6, drop: 6,
                    config: { tension: 120, friction: 18 } };
```

Note copy (uppercased in CSS, not in the copy), three lines:

```
Tell us what you're
dreaming of. We'll shape
the journey.
```

Aside: `ready when you are`. Two underline strokes (`Vector1.svg`, `Vector2.svg`) draw in beneath
the emphasised words; the aside comes in over the last tenth of the write window (`[0.9, 1]`).

Title: `Plan a Journey` — `[P italic][lan a ][J italic][ourney]`, in ink.

Action: `Start planning` → `/plan`, with `Icon1.svg` as the arrow:

```js
const ARROW_SLIDE = { from: "translateX(-50%)", to: "translateX(0%)",
                      config: { tension: 240, friction: 26 } };
```

Hover behaviour is gated on `HOVER_QUERY = "(hover: hover) and (pointer: fine)"`. Image size hints:
scene `100vw`, note `(max-width: 1024px) 342px, 24vw`.

### Footer

A **frosted** panel — `--surface-glass` (`rgb(12 12 12 / 0.1)`) with a backdrop blur: a blur first
and a fill second. The wordmark repeats with the hero's segmentation, then three link columns:

**DESTINATIONS** — Iceland · Norway · Japan · Indonesia
**EXPERIENCES** — Adventure · Nature · Culture · Slow Travel
**COMPANY** — About Us · Journal · Testimonials · Contact

Newsletter: `Stories, guides & ideas for wherever you're headed next.` with placeholder
`Your e-mail` and button `Subscribe`.

Social marks — `SocialMedia1.svg` (Instagram), `SocialMedia2.svg` (Facebook), `SocialMedia3.png`
(YouTube), each linking to `#`.

Copyright: `© 2024 Wanderlust. All rights reserved.`

## Fixed parameters (bake these in)

```js
// preloader
HOLD_MS = 340; COUNT_MS = 1200; LIFT_MS = 780; MIN_SHOW_MS = 2200;
// hero
VEIL_MS = 900; VIDEO_RATE = 2; ASSEMBLY_MS = 2400; ASSEMBLY_LEAD_S = 0.4;
STILL_MS = 520; STILL_LEAD_S = 1/30; ASSEMBLY_FALLBACK_MS = 8000;
PHASE = { maskDuration: 0.3, uiStart: 0.3, uiStagger: 0.055, uiDuration: 0.2 };
MASK = { from: 0.62 }; REVEAL_TRAVEL = 1.25;
REC = { pulseMs: 620, dim: 0.18 };
GRAIN = { cycleMs: 800, steps: 8, opacity: 0.3, tile: 200, shift: 41 };
HERO_TUNING = { cardLerp: 0.08, brightness: 1.18, saturation: 1.25, scrimOpacity: 1, returnMs: 800 };
// destinations
FRAME 1440×687; GOLDEN_ANGLE = 137.508; SWAP_TRAVEL = 20;
CARD_HOVER  = { to: "translate3d(0,-6px,0) scale(1.025)", tension: 300, friction: 30 };
CARD_TILT   = { perspective: 520, lift: 0.7, rise: 40, scale: 1.015, tiltX: 14, tiltY: 18, roll: 2.5, tension: 190, friction: 26 };
CARD_DIM    = { rest: 0.45, stickers: 0.62, tension: 150, friction: 26 };
ENTRANCE    = { span: 0.7, clearance: 3, drift: 6, rotate: 10 };
PARALLAX    = { rise: 11, spread: 5.5, floor: 0.15, stickers: 0.55 };
// philosophy
FRAME 1440×800; BACKDROP_PAN 0% → -50%; HANDOVER = 0.14;
WORD_STAGGER = 0.03; WORD_DURATION = 0.14; WORD_TRAVEL_EM = 0.28; COLLAGE_TURN = 7;
REVEAL_MS = 900; REVEAL_STAGGER = 0.05; REVEAL_DURATION = 0.28;
// travel
FRAME 1440×800; PANEL_COUNT = 4; WIDEN = { wide: 2.5, narrow: 3.4 };
MEDIA_TRAVEL = 101; SLIDE = { tension: 130, friction: 28 }; HEAD_CUE = 0.55;
STACK_PARALLAX = 0.08; NARROW_QUERY = "(max-width: 1024px)";
// plan
FRAME 1440×1077; PARALLAX far {8,5} steam {16,10} foreground {28,16}, tension 90 friction 30;
STEAM_DRIFT_MS = 28000; STEAM_PULSE_MS = 25000; STEAM_DRIFT = 1.5; STEAM_SCALE = 0.04;
STEAM_OPACITY = 0.85; STEAM_STRENGTH = 0.4; STEAM_ALPHA_GAMMA = { exponent: 3, amplitude: 3 };
WRITE_WINDOW = [0.35, 0.85]; WRITE_LERP = 0.055; WRITE_EPSILON = 0.0004;
NOTE_TILT = { origin: "51% 10%", enter: -9, rest: 0, hover: 2.6, drop: 6, tension: 120, friction: 18 };
ARROW_SLIDE = { tension: 240, friction: 26 };
```

Honour `prefers-reduced-motion` throughout: hold every scrubbed animation at its resting state and
drop the pointer-follow, the grain step, and the steam loops.

## Assets

All media loads from the public bucket. Base:

```
ASSET_BASE_URL = https://storage.getlayers.ai/assets/wanderlust-555800a518
```

Folder names contain spaces — keep them percent-encoded in the URLs exactly as written.

**Hero**
```
${ASSET_BASE_URL}/hero/Hero-video.mp4
${ASSET_BASE_URL}/hero/hero-final-frame.jpg
${ASSET_BASE_URL}/hero/hero-thumb2.png
${ASSET_BASE_URL}/hero/hero-landscape1.png
${ASSET_BASE_URL}/hero/hero-lens1.png
```

**Block 2 — the sixteen cards and the stickers**
```
${ASSET_BASE_URL}/Block2/Iceland.png
${ASSET_BASE_URL}/Block2/Norway.png
${ASSET_BASE_URL}/Block2/Japan.png
${ASSET_BASE_URL}/Block2/Morocco.png
${ASSET_BASE_URL}/Block2/Switzerland.png
${ASSET_BASE_URL}/Block2/Italy.png
${ASSET_BASE_URL}/Block2/France.png
${ASSET_BASE_URL}/Block2/Portugal.png
${ASSET_BASE_URL}/Block2/Spain.png
${ASSET_BASE_URL}/Block2/Greece.png
${ASSET_BASE_URL}/Block2/Austria.png
${ASSET_BASE_URL}/Block2/Canada.png
${ASSET_BASE_URL}/Block2/New%20Zealand.png
${ASSET_BASE_URL}/Block2/Australia.png
${ASSET_BASE_URL}/Block2/Slovenia.png
${ASSET_BASE_URL}/Block2/Istanbul.png
${ASSET_BASE_URL}/Block2/Sticker1-art.png
${ASSET_BASE_URL}/Block2/Sticker3.png
${ASSET_BASE_URL}/Block2/Sticker4.png
```

**Block 3**
```
${ASSET_BASE_URL}/Block%203/pexels-arthousestudio-4344885.jpg
${ASSET_BASE_URL}/Block%203/Collage.png
```

**Block 4**
```
${ASSET_BASE_URL}/Block4/OpenedImage1.png
${ASSET_BASE_URL}/Block4/OpenedImage2.png
${ASSET_BASE_URL}/Block4/OpenedImage3.png
${ASSET_BASE_URL}/Block4/OpenedImage4.png
```

**Block 5**
```
${ASSET_BASE_URL}/Block%205/BackgroundImage.png
${ASSET_BASE_URL}/Block%205/03_mountains_midground%201.png
${ASSET_BASE_URL}/Block%205/02_steam_screen_blend%201.png
${ASSET_BASE_URL}/Block%205/01_foreground%201.png
${ASSET_BASE_URL}/Block%205/Pin.png
${ASSET_BASE_URL}/Block%205/Vector1.svg
${ASSET_BASE_URL}/Block%205/Vector2.svg
${ASSET_BASE_URL}/Block%205/Icon1.svg
${ASSET_BASE_URL}/Block%205/SocialMedia1.svg
${ASSET_BASE_URL}/Block%205/SocialMedia2.svg
${ASSET_BASE_URL}/Block%205/SocialMedia3.png
```

If any of these fail to load, the page must say so visibly rather than rendering an empty frame.
[/hidecontent]
← 返回首页