0

Saltar al contenido

Dirección de arte, lista para tu IA.

Únete a la lista
Volver a las plantillasEXP-0002 / Landing
LandingGratis

Tarnway

Cuatro refugios, una sola línea

Qué incluye

Prompts
HTML · 459 líneas / React · 455 líneas
Medios
Escena WebGL
Alojamiento
cdn.orbital.supply, ya incluido en el prompt
Tipografías
Archivo · Google Fonts
HTML
Un archivo · Three.js 0.186.1
React
Three.js 0.186.1 · Tailwind 4.3.3
Movimiento
scroll-flight, route-draw, mask-reveal

Probada y documentada

05 de octubre de 2026

  • Reproducida en Claude
  • 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

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. 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 · Vite455 líneas
Abrir el texto completo
# Build: "Tarnway" — a paper relief map you fly along, hut to hut

## 0. Brief

A scrolling landing page for **Tarnway**, a four-day hut-to-hut traverse of a fictional range, the Ardel massif. The whole page is a 3D relief map printed on cream paper: ink contour lines, a dashed trail and four huts beside small hatched lakes (tarns). Scrolling flies the camera from a high overview down onto the trail and along it, stage by stage, while a vermilion line draws itself up to a position marker, the stage index lights up and the elevation profile at the bottom follows; the last chapter rises to a warm dusk view of the whole line. It must feel calm, precise and printed, never like a map app or a game. The "wow" is the first scroll: the paper map tilts and dives onto the trail.

## 1. Output & hard constraints

- Stack, exact versions (no ranges): the Vite `react-ts` template with `react`/`react-dom` 19.3.0, `vite` 8.3.1, `@vitejs/plugin-react` 6.1.1, `typescript` 6.0.3, `@types/react`/`@types/react-dom` 19.3.0; plus `tailwindcss` and `@tailwindcss/vite` 4.3.3, `three` 0.186.1 and `@types/three` 0.186.0. No `motion`, no icon library, no linter (drop the template's `oxlint` and its script/config; keep its `@types/node`, which `tsconfig.node.json` uses), nothing else. StrictMode on; `npm run build` passes with zero TypeScript errors.
- Files: `index.html`, `src/main.tsx`, `src/index.css`, `src/App.tsx` (root, poster, stage, labels, marker, engine start), `src/flight.ts` and `src/config.ts` (section 8), `src/components/` `Header.tsx` (with the menu dialog), `Side.tsx`, `Band.tsx`, `Chapters.tsx` (six chapters from a data array).
- `index.html`: `lang="en"`; charset; viewport `width=device-width, initial-scale=1, viewport-fit=cover`; title `Tarnway — Four huts. One line.`; description "A four-day hut-to-hut traverse of the Ardel massif, booked end to end."; `theme-color` `#EEE7D6`; preconnects to `https://fonts.googleapis.com` and `https://fonts.gstatic.com` (crossorigin); `<link rel="icon" href="data:,">`; `<body style="background:#EEE7D6;color:#1E2B25">`.
- `src/index.css`, in this order: the font `@import url(…)` (section 3), `@import "tailwindcss";`, the anti-flash rule `html,body{background:#EEE7D6!important;color:#1E2B25}`, the tokens of section 4, then every rule of sections 6, 7 and 9 as plain CSS **outside any `@layer`** (so it beats Tailwind's base styles), with the reduced-motion block last. Keep every `tw-*` class name and `data-*` attribute: the CSS and the engine target them. Custom properties use the `--tn-` prefix on purpose: Tailwind v4 owns `--tw-*` variable names. The component styles are given as CSS on purpose (same rules as the html version of this piece): do not translate them into utilities.
- Page type: one scrolling page of six chapters over a fixed full-screen 3D map. Exactly two layouts: desktop (≥1024px) and mobile (<1024px).
- Build exactly what this prompt describes. Do NOT add:
  - map controls of any kind (drag, orbit, zoom, pan), real maps, GPS or geographic data, weather, availability;
  - a booking or payment form (the booking CTA is a `mailto:` link), prices, other routes;
  - an automatic tour, a play/pause control, water ripples or any other animation while the page is at rest;
  - a third layout, other sections (pricing, testimonials, gallery, FAQ, footer), extra images, icon fonts, other fonts or libraries.
- Do NOT substitute, rename or invent URLs: section 2, verbatim.
- Zero console errors in `npm run dev` and `npm run preview`, also with WebGL unavailable (section 8, step 5).

## 2. Assets (exact URLs — the only images)

The terrain, trail and huts are generated in code (section 8): no models, textures or environment files. The only images are the fallback posters, shown under the map until it starts and for good if WebGL is missing.

| ID | Type | URL | Size | Role |
|---|---|---|---|---|
| POSTER_WEBP | image/webp | `https://cdn.orbital.supply/exp-0002/poster.webp` | 1600×1000 | Overview, ≥1024px |
| POSTER_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0002/poster.jpg` | 1600×1000 | `<img src>` fallback |
| POSTER_MOBILE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0002/poster-mobile.webp` | 780×1688 | Overview, <1024px |
| POSTER_MOBILE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0002/poster-mobile.jpg` | 780×1688 | Fallback |

`<picture class="tw-poster" aria-hidden="true">`: `<source media="(max-width: 1023px)">` for the mobile files (webp first; `type="image/webp"` / `type="image/jpeg"`), `<source type="image/webp">` POSTER_WEBP, `<img src="POSTER_JPG" alt="" width="1600" height="1000" decoding="async">`. A failed poster leaves the paper; nothing moves.

## 3. Typography

```
https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap
```
It is the FIRST line of `src/index.css` as `@import url("…");` (preconnects in `index.html`). One family, two widths (`font-stretch` drives the `wdth` axis):

| Role | Settings | Used ONLY on |
|---|---|---|
| Display | `font-stretch:125%`, 800, uppercase | h1, h2, stage titles, brand, big readouts, index numbers, menu links |
| Label | `font-stretch:125%`, 700, uppercase, `letter-spacing:.14em`, 11px (10–12px where stated) | kickers, hut names, stat labels, chips, buttons (112%) |
| Text | `font-stretch:100%`, 400 | paragraphs |
| UI | `font-stretch:100%`, 500–600 | nav links, meta lines, legend |

Stack: `"Archivo", Arial, sans-serif`. Every number uses `font-variant-numeric:tabular-nums`. Global: `-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; font-synthesis:none`. Archivo has ↑ ↓ × and the en dash; it has no → arrow: never use →.

## 4. Design tokens (base viewport 1440px)

```css
:root{
  --tn-paper:#EEE7D6; --tn-card:rgba(244,239,227,.94); --tn-ink:#1E2B25; --tn-ink-2:#33443B; --tn-muted:#4E5A52;
  --tn-accent:#C9432B; --tn-line:rgba(30,43,37,.2); --tn-edge:rgba(30,43,37,.85);
  --tn-pad:40px; --tn-top:72px; --tn-band:112px; --tn-side:272px; --tn-card-w:400px; --tn-hero-w:520px;
  --tn-h1:60px; --tn-h2:40px; --tn-title:26px;
  --tn-ease:cubic-bezier(.22,.8,.24,1);
}
```
Colours come from the paper map: ink on paper 11.9:1, muted on paper 5.9:1. The accent is used ONLY for route marks (trail line, hut squares, position marker and dot, active stage bar, compass tip), never for text.

## 5. Layer stack & DOM

Z-order (back → front): `.tw-poster` z 0 · `.tw-stage` z 1 (canvas) · `.tw-hut` labels and `.tw-marker` z 2 · `main` chapters z 3 · `.tw-side` and `.tw-band` z 5 · `.tw-head` z 10 · `dialog` (top layer).

Exact markup, to be rendered as JSX 1:1 (section 8, step 4; ids, classes and `data-*` attributes are load-bearing: the engine uses them). Copy is final. `App.tsx` renders the root, poster, stage, labels and marker and places `Header`, `Side`, `Band` and `Chapters` where their markup sits; the `<dialog>` is rendered by `Header` (inside `#tw` is fine: it opens in the top layer).

```html
<body style="background:#EEE7D6;color:#1E2B25">
<div class="tw" id="tw">
  <picture class="tw-poster" aria-hidden="true"><!-- section 2 --></picture>
  <div class="tw-stage" data-flight-stage aria-hidden="true"></div>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Oss Hut</b><small>2,030 m</small></span></span>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Brae Hut</b><small>1,950 m</small></span></span>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Kettle Hut</b><small>2,110 m</small></span></span>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Skarn Hut</b><small>2,480 m</small></span></span>
  <span class="tw-marker" data-flight-marker aria-hidden="true"></span>

  <header class="tw-head">
    <a class="tw-brand" href="#top"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M2 17c3-6 6-10 9-10s6 4 9 10"/><path d="M6.5 17c1.5-3 3-5 4.5-5s3 2 4.5 5"/></svg>Tarnway</a>
    <nav class="tw-nav" aria-label="Primary"><a href="#stage-1">Route</a><a href="#included">Included</a><a href="#season">Season</a></nav>
    <a class="tw-btn tw-head-cta" href="#book">Book the huts</a>
    <button class="tw-menu-btn" type="button" aria-expanded="false" aria-controls="tw-menu">Menu</button>
  </header>

  <aside class="tw-side">
    <nav class="tw-index" aria-label="Route stages">
      <p class="tw-kicker">The line <span>4 stages · 47.4 km</span></p>
      <ol>
        <li><a href="#stage-1" data-stage-link="1"><span class="tw-num">01</span><span class="tw-name">Oss<span> Hut</span></span><span class="tw-meta">9.4 km · ↑ 900 m · 4 h 45</span></a></li>
        <li><a href="#stage-2" data-stage-link="2"><span class="tw-num">02</span><span class="tw-name">Brae<span> Hut</span></span><span class="tw-meta">13.5 km · ↑ 580 m · 5 h</span></a></li>
        <li><a href="#stage-3" data-stage-link="3"><span class="tw-num">03</span><span class="tw-name">Kettle<span> Hut</span></span><span class="tw-meta">14.3 km · ↑ 920 m · 6 h 30</span></a></li>
        <li><a href="#stage-4" data-stage-link="4"><span class="tw-num">04</span><span class="tw-name">Skarn<span> Hut</span></span><span class="tw-meta">10.2 km · ↑ 670 m · 4 h</span></a></li>
      </ol>
    </nav>
    <div class="tw-legend"><span class="tw-compass" aria-hidden="true"><i></i></span><p>Contour interval 50 m<br>Vertical exaggeration ×2.5</p></div>
  </aside>

  <figure class="tw-band" role="img" aria-label="Elevation profile: 47.4 km from 1,450 m; highest point 2,510 m at Aske col">
    <div class="tw-read"><span class="tw-kicker">Elevation</span><b><span data-readout-km>0.0</span> km</b><b><span data-readout-alt>1,450</span> m</b></div>
    <div class="tw-plot">
      <svg viewBox="0 0 1000 120" preserveAspectRatio="none" aria-hidden="true">
        <defs><clipPath id="tw-done"><rect data-profile-clip x="0" y="0" width="0" height="120"/></clipPath></defs>
        <path class="tw-area" data-profile-area d=""/><path class="tw-full" data-profile-line d=""/><path class="tw-done" data-profile-line clip-path="url(#tw-done)" d=""/>
      </svg>
      <i class="tw-tick" data-profile-tick><span>01</span></i><i class="tw-tick" data-profile-tick><span>02</span></i><i class="tw-tick" data-profile-tick><span>03</span></i><i class="tw-tick" data-profile-tick><span>04</span></i>
      <i class="tw-dot" data-profile-dot></i>
    </div>
  </figure>

  <main class="tw-track">
    <section class="tw-ch tw-ch-hero" id="top" data-f="0">
      <div class="tw-card tw-hero">
        <p class="tw-kicker">Hut-to-hut traverse · Ardel massif</p>
        <h1><span class="tw-mask"><span>Four huts.</span></span><span class="tw-mask"><span>One line.</span></span></h1>
        <p class="tw-lede">Four days and 47 km across the Ardel massif, sleeping beside a high tarn every night. We book the huts and move your bag; you walk the line.</p>
        <div class="tw-ctas"><a class="tw-btn" href="#book">Book the huts</a><a class="tw-link" href="#stage-1">Follow the route ↓</a></div>
      </div>
    </section>
    <!-- stage-1 … stage-4 go here (table below) -->
    <section class="tw-ch tw-ch-end" id="book" data-f="6" aria-labelledby="tw-end">
      <div class="tw-card tw-end">
        <p class="tw-kicker">Arrival · Skarn Hut, 2,480 m</p>
        <h2 id="tw-end">The whole line, drawn.</h2>
        <dl class="tw-stats"><div><dt>Distance</dt><dd>47.4 km</dd></div><div><dt>Ascent</dt><dd>3,070 m</dd></div><div><dt>Descent</dt><dd>2,050 m</dd></div><div><dt>Nights</dt><dd>4</dd></div></dl>
        <div class="tw-blocks">
          <div id="included"><h3>Included</h3><p>Four nights in the huts with dinner and breakfast, your bag moved every morning and a printed relief map of the line.</p></div>
          <div id="season"><h3>Season</h3><p>The huts open from 20 June to 20 September, for groups of two to eight walkers.</p></div>
        </div>
        <div class="tw-ctas"><a class="tw-btn" href="mailto:huts@tarnway.example?subject=Tarnway%20dates">Request dates</a><a class="tw-link" href="#top">Back to the overview ↑</a></div>
      </div>
    </section>
  </main>
</div>
<dialog class="tw-menu" id="tw-menu" aria-label="Menu">
  <div class="tw-menu-top"><span class="tw-brand" aria-hidden="true">Tarnway</span><button class="tw-menu-close" type="button">Close</button></div>
  <nav aria-label="Menu links"><a href="#stage-1">Route</a><a href="#included">Included</a><a href="#season">Season</a></nav>
  <a class="tw-btn" href="#book">Book the huts</a>
</dialog>
</body>
```

Each stage chapter, in this order between the hero and `#book` (N = 1…4):
`<section class="tw-ch" id="stage-N" data-f="…" aria-labelledby="tw-sN"><article class="tw-card"><p class="tw-kicker">Kicker</p><h2 class="tw-title" id="tw-sN">Title</h2><dl class="tw-stats">` four `<div><dt>Distance|Ascent|Descent|Time</dt><dd>value</dd></div>` `</dl><p>Text</p></article></section>`

| id | data-f | Kicker | Title | Distance | Ascent | Descent | Time | Text |
|---|---|---|---|---|---|---|---|---|
| stage-1 | 1.5 | Stage 01 · Day 1 | Vell – Oss Hut | 9.4 km | 900 m | 320 m | 4 h 45 | Out of the larch woods and up the long western shoulder. Oss Hut sits on the lip of its tarn at 2,030 m. |
| stage-2 | 2.5 | Stage 02 · Day 2 | Oss Hut – Brae Hut | 13.5 km | 580 m | 670 m | 5 h | Over Aske col at 2,510 m, the highest step of the traverse, then down into the hanging valley of Brae Tarn. |
| stage-3 | 3.5 | Stage 03 · Day 3 | Brae Hut – Kettle Hut | 14.3 km | 920 m | 760 m | 6 h 30 | The longest day: a balcony path under the north face, two short climbs and the quiet basin of Kettle Tarn. |
| stage-4 | 4.5 | Stage 04 · Day 4 | Kettle Hut – Skarn Hut | 10.2 km | 670 m | 300 m | 4 h | The shortest day and the widest view. Skarn Hut, at 2,480 m, looks back along the whole line. |

Every figure on the page (km, ascent, altitudes) is computed from the terrain of section 8: keep them exactly as written.

## 6. Components

Links and buttons: `:focus-visible{outline:2px solid var(--tn-ink);outline-offset:3px}`. Hovers change colour or underline only, never `transform`/`translate` (the entrance animates `translate`).

### 6.1 Page, poster and stage
- `html{scroll-behavior:smooth}`; `body{margin:0;font-family:"Archivo",Arial,sans-serif;font-size:16px;line-height:1.55;font-variant-numeric:tabular-nums}`; `button{font-family:inherit}`; `.tw{position:relative}`.
- `.tw-poster{position:fixed;inset:0;z-index:0}`; its `img{display:block;width:100%;height:100%;object-fit:cover}`. `.tw.is-3d .tw-poster{opacity:0;transition:opacity .8s linear .4s}`.
- `.tw-stage{position:fixed;inset:0;z-index:1;opacity:0;transition:opacity 1.6s var(--tn-ease);--flight-offset-x:.08;--flight-offset-y:0}`; `.tw.is-3d .tw-stage{opacity:1}`; `.tw-stage canvas{display:block;width:100%;height:100%}`. The two offsets move the optical centre so the massif sits in the free area between the index and the cards (the engine reads them on resize).

### 6.2 Hut labels and position marker (placed by the engine)
- `.tw-hut{position:fixed;left:0;top:0;z-index:2;display:flex;align-items:center;gap:6px;height:10px;margin:-5px 0 0 -5px;pointer-events:none;will-change:transform}` (10px high like its square, the text plate overflows evenly: the square's centre sits on the hut).
- `.tw-hut i{width:10px;height:10px;box-sizing:border-box;border:2px solid var(--tn-accent);background:var(--tn-paper)}`; `.tw-hut.is-reached i{background:var(--tn-accent)}`.
- `.tw-hut>span{padding:3px 6px 3px 5px;background:rgba(238,231,214,.9);font-size:10.5px;line-height:1.2;font-weight:500;color:var(--tn-muted);white-space:nowrap}` (tabular); `.tw-hut b`: Label look at 10.5px, `letter-spacing:.12em`, ink, `margin-right:5px`; `.tw-hut small{font-size:inherit}`.
- `.tw-marker{position:fixed;left:0;top:0;z-index:2;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:var(--tn-accent);box-shadow:0 0 0 3px var(--tn-paper),0 0 0 4px var(--tn-ink);pointer-events:none}`.
- `.tw-hut.is-off,.tw-marker.is-off,.tw:not(.is-3d) .tw-hut,.tw:not(.is-3d) .tw-marker{visibility:hidden}` (hidden until the engine places them); `.tw.is-flat .tw-hut,.tw.is-flat .tw-marker{display:none}`.

### 6.3 Header
- `.tw-head{position:fixed;inset:0 0 auto;z-index:10;height:var(--tn-top);display:flex;align-items:center;gap:32px;padding:0 var(--tn-pad);background:linear-gradient(var(--tn-paper) 40%,rgba(238,231,214,0))}`.
- `.tw-brand`: inline-flex, centred, gap 10px, ink, no underline, Display 15px, `letter-spacing:.22em`. `.tw-nav`: `margin-left:auto`, flex, gap 28px, links UI 14px 500 ink, underline on hover (offset 5px).
- `.tw-menu-btn` (mobile only: `display:none` from 1024px, `display:inline-flex; align-items:center` below): `margin-left:auto; height:40px; padding:0 14px; border:1px solid var(--tn-ink); border-radius:2px; background:transparent; color:var(--tn-ink)`, Label style at `font-stretch:112%`.

### 6.4 Buttons and links
- `.tw-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:44px;padding:0 20px;border-radius:2px;background:var(--tn-ink);color:var(--tn-paper);text-decoration:none;font-size:12px;font-stretch:112%;font-weight:700;letter-spacing:.1em;text-transform:uppercase;transition:background-color .3s var(--tn-ease)}`; hover `background:var(--tn-ink-2)`. `.tw-head-cta{height:40px}`.
- `.tw-link{font-size:14px;font-weight:600;color:var(--tn-ink);text-decoration:underline;text-underline-offset:5px;text-decoration-thickness:1px}`; hover thickness 2px.

### 6.5 Side column: stage index and legend (desktop)
- `.tw-side{position:fixed;left:var(--tn-pad);top:calc(var(--tn-top) + 16px);bottom:calc(var(--tn-band) + 24px);z-index:5;width:var(--tn-side);display:flex;flex-direction:column;justify-content:space-between;pointer-events:none}`; its two children `pointer-events:auto`. Its entrance leaves a `translate` on it, so it is the containing box of anything positioned inside it (never give its children `position:fixed`).
- `.tw-index`, `.tw-legend`, `.tw-card` look like map legends: `background:var(--tn-card); border:1px solid var(--tn-edge)`, square, no shadow.
- `.tw-index{padding:16px 18px 4px}`; `.tw-kicker`: Label style, muted, margin 0; inside the index it is flex, `justify-content:space-between`, and its `span` is UI 12px 500 normal case with no letter-spacing.
- `.tw-index ol{list-style:none;margin:10px 0 0;padding:0}`.
- `.tw-index a{position:relative;display:grid;grid-template-columns:34px 1fr;column-gap:10px;padding:12px 0;border-top:1px solid var(--tn-line);color:var(--tn-ink);text-decoration:none}`.
  - `.tw-num`: `grid-row:span 2`, Display 20px/1, tabular, muted.
  - `.tw-name`: Label at 12px, `letter-spacing:.1em`, ink; `::before` an 8px square (`box-sizing:border-box; border:1.5px solid var(--tn-accent)`, inline-block, `margin-right:8px`, `vertical-align:0`).
  - `.tw-meta`: UI 12.5px 500, tabular, muted, `margin-top:3px`.
  - Active (`[aria-current]`): `.tw-num` ink; `::after{content:"";position:absolute;left:-18px;top:12px;bottom:12px;width:3px;background:var(--tn-accent)}`. Active and `.is-done`: the square is filled with the accent.
- `.tw-legend{display:flex;align-items:center;gap:14px;padding:12px 16px}`; `p{margin:0;font-size:12px;line-height:1.45;font-weight:500;color:var(--tn-muted)}`.
- `.tw-compass{flex:none;display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--tn-edge);border-radius:50%}`; `.tw-compass i{width:10px;height:26px;rotate:var(--flight-north,0deg);background:linear-gradient(var(--tn-accent) 50%,var(--tn-ink) 50%);clip-path:polygon(50% 0,100% 50%,50% 100%,0 50%)}` (accent tip = north; the engine writes `--flight-north` on `#tw`).

### 6.6 Chapters and cards
- `.tw-track{position:relative;z-index:3}`.
- `.tw-ch{min-height:140vh;min-height:140svh;display:flex;align-items:center;justify-content:flex-end;padding:0 var(--tn-pad);box-sizing:border-box}`; `.tw-ch-hero,.tw-ch-end{min-height:100vh;min-height:100svh;padding-top:var(--tn-top);padding-bottom:var(--tn-band)}`.
- `.tw-card{box-sizing:border-box;width:var(--tn-card-w);padding:24px 26px 26px}`; `.tw-hero,.tw-end{width:var(--tn-hero-w);padding:30px 32px 32px}`.
- `h1{margin:14px 0 18px;font-size:var(--tn-h1);line-height:.95;letter-spacing:-.01em}` (Display). `.tw-mask{display:block;overflow:hidden;padding-bottom:.04em}`; `.tw-mask>span{display:block}`.
- `.tw-lede{margin:0 0 24px;max-width:42ch;font-size:17px;line-height:1.55}`.
- `.tw-ctas{display:flex;flex-wrap:wrap;align-items:center;gap:14px 22px}`.
- `.tw-title{margin:10px 0 16px;font-size:var(--tn-title);line-height:1.05;letter-spacing:.01em}` (Display). `.tw-end h2{margin:12px 0 18px;font-size:var(--tn-h2);line-height:.98}` (Display).
- `.tw-stats{display:grid;grid-template-columns:repeat(4,auto);justify-content:start;gap:0 18px;margin:0 0 16px;padding:12px 0;border-block:1px solid var(--tn-line)}`; `dt`: Label at 10px with `font-stretch:112%; letter-spacing:.08em`, muted; `dd{margin:4px 0 0;font-size:18px;font-stretch:100%;font-weight:700}`.
- Stage text `.tw-card>p:last-child`: 15.5px/1.55, margin 0 (a broader `.tw-card p` would override `.tw-kicker`).
- `.tw-blocks{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:0 0 22px}`; `h3`: Label style, ink, margin `0 0 6px`; `p{margin:0;font-size:14.5px;line-height:1.5;color:var(--tn-muted)}`. `#included,#season{scroll-margin-top:120px}`.

### 6.7 Elevation band
- `.tw-band{position:fixed;left:0;right:0;bottom:0;z-index:5;box-sizing:border-box;height:var(--tn-band);margin:0;display:grid;grid-template-columns:var(--tn-side) 1fr;column-gap:24px;align-items:center;padding:0 var(--tn-pad);background:var(--tn-paper);border-top:1px solid var(--tn-edge)}` (opaque: cards slide under it).
- `.tw-read{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 18px}`; its kicker `flex-basis:100%` (a row of its own); `b`: Display at 22px/1.1, not uppercase.
- `.tw-plot{position:relative;height:72px}`; its `svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}`; paths: `.tw-area{fill:rgba(30,43,37,.08)}`, `.tw-full{fill:none;stroke:rgba(30,43,37,.45);stroke-width:1.25}`, `.tw-done{fill:none;stroke:var(--tn-accent);stroke-width:2.5}`, all with `vector-effect:non-scaling-stroke`.
- `.tw-tick,.tw-dot{position:absolute;left:calc(var(--x) * 100%);top:calc(var(--y) * 100%);translate:-50% -50%;--x:0;--y:1}` (defaults until the engine writes them). `.tw-tick{width:9px;height:9px;box-sizing:border-box;border:2px solid var(--tn-ink);background:var(--tn-paper)}`; its `span{position:absolute;bottom:calc(100% + 4px);left:50%;translate:-50% 0;font-size:9px;font-style:normal;font-stretch:125%;font-weight:700;color:var(--tn-muted)}`. `.tw-dot{width:12px;height:12px;border-radius:50%;background:var(--tn-accent);box-shadow:0 0 0 2px var(--tn-paper)}`.

### 6.8 Mobile menu (a native `<dialog>`, <1024px only)
- `.tw-menu{box-sizing:border-box;width:100%;max-width:none;height:100%;max-height:none;margin:0;padding:0 16px 24px;border:0;background:var(--tn-paper);color:var(--tn-ink)}` and ONLY `.tw-menu[open]{display:flex;flex-direction:column}` (a `display` rule without `[open]` would show the closed dialog). It covers the whole screen, so it has no visible backdrop.
- `.tw-menu-top`: 60px, flex, `space-between`, centred; `.tw-menu-close` looks like `.tw-menu-btn`. Links: block, Display 34px/1.1, ink, no underline, padding `14px 0`, `border-bottom:1px solid var(--tn-line)`. The `.tw-btn` sits at the bottom (`margin-top:auto`), full width, 48px high.

## 7. Motion (MP-CALM: long, quiet, no bounce)

Entrance. Keyframes animate only `opacity` and `translate` with `animation-fill-mode:both`; resting state is visible.

All with `--tn-ease`: `.tw-head` tn-fade 1.2s at .1s · `.tw-hero .tw-kicker` tn-fade 1.2s at .3s · `h1 .tw-mask:nth-child(1)>span` tn-rise 1.4s at .45s · `h1 .tw-mask:nth-child(2)>span` tn-rise 1.4s at .6s · `.tw-lede` tn-fade 1.2s at .8s · `.tw-hero .tw-ctas` tn-fade 1.2s at .95s · `.tw-side` tn-fade 1.2s at 1.1s · `.tw-band` tn-fade 1.2s at 1.2s. The map: the canvas fades in over 1.6s (6.1) while the engine's camera settles from 12% farther over 2.4s.

```css
@keyframes tn-rise{from{translate:0 105%}to{translate:0 0}}
@keyframes tn-fade{from{opacity:0;translate:0 12px}to{opacity:1;translate:0 0}}
```
Scroll motion belongs to the engine (damped camera). Stage cards simply scroll in; no entrance of their own.

Reduced motion (last rules of the stylesheet):
```css
@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  *,*::before,*::after{animation:none!important;transition:none!important}
}
```
The engine also stops the flight: the camera cuts between the view of each chapter.

## 8. Behaviour (TypeScript)

1. **`src/flight.ts`**: the engine core below **verbatim** (it starts with its four `import` lines). It builds the terrain, contour shader, trail, camera, profile, labels and compass, handles scroll on touch screens, reduced motion, the no-WebGL fallback, and returns a cleanup function.

```ts
import * as THREE from 'three';
import { Line2 } from 'three/addons/lines/Line2.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';

type N = number;
type V2 = [N, N];
type V3 = [N, N, N];
type Pose = [THREE.Vector3, THREE.Vector3];
export type Relief = { seed: N; size: N; grid: N; height: N; freq: N; ridge: N; env: [N, N, N, N]; envPow: N; power: N;
  route: V2[]; hutIdx: N[]; carve: V2; tarn: [N, N, N, N[]]; base: N; vscale: N };
export type FlightConfig = { relief: Relief; kmPerUnit: N; lift: N; fov: N; contours: V2; colors: { paper: string; warm: string; ink: string; accent: string };
  light: V3; duskLight: V3; poses: { overview: [V3, V3]; final: [V3, V3] }; fit: N; chase: { back: N; up: N; side: N; clear: N; ahead: N };
  damping: N; introMs: N; introPull: N; lineWidth: V2; labelRange: N };

// Relief: pure JS, deterministic (same seed = same massif and same figures in every build).
function makeRelief(o: Relief) {
  const { seed, size, grid, route } = o, n = grid + 1, step = size / grid;
  const hash = (x: N, z: N) => { let h = Math.imul(x, 374761393) ^ Math.imul(z, 668265263) ^ Math.imul(seed, 2246822519); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; };
  const noise = (x: N, z: N): V3 => {
    const i = Math.floor(x), j = Math.floor(z), fx = x - i, fz = z - j, u = fx * fx * (3 - 2 * fx), v = fz * fz * (3 - 2 * fz);
    const a = hash(i, j), b = hash(i + 1, j), c = hash(i, j + 1), k = a - b - c + hash(i + 1, j + 1);
    return [2 * (a + (b - a) * u + (c - a) * v + k * u * v) - 1, 12 * fx * (1 - fx) * (b - a + k * v), 12 * fz * (1 - fz) * (c - a + k * u)]; };
  const eroded = (x: N, z: N) => {
    let s = 0, amp = 1, dx = 0, dz = 0, px = x * o.freq + 11.3, pz = z * o.freq + 7.9;
    for (let i = 0; i < 7; i++) { let [h, hx, hz] = noise(px, pz);
      if (i) { const r = 1 - o.ridge * (1 + 2 * Math.sign(h)); h += o.ridge * (1 - 2 * Math.abs(h) - h); hx *= r; hz *= r; }
      dx += hx; dz += hz; s += amp * h / (1 + dx * dx + dz * dz); amp *= 0.5; const t = px; px = (0.8 * t - 0.6 * pz) * 2; pz = (0.6 * t + 0.8 * pz) * 2; }
    return s; };
  const envelope = (x: N, z: N) => { const r0 = Math.hypot((x - z) * 0.7071 / o.env[0], (x + z) * 0.7071 / o.env[1]);
    const e = Math.min(1, Math.max(0, (1 - r0 - o.env[3] * noise(x * 0.045 + 3.1, z * 0.045 + 8.7)[0] * Math.min(1, r0)) / (1 - o.env[2]))); return e * e * (3 - 2 * e); };
  let lo = 1e9, hi = -1e9, top = 0;
  for (let j = 0; j <= 64; j++) for (let i = 0; i <= 64; i++) { const v = eroded(size * (i / 64 - 0.5), size * (j / 64 - 0.5)); lo = Math.min(lo, v); hi = Math.max(hi, v); }
  const shape = (x: N, z: N) => Math.pow(envelope(x, z), o.envPow) * Math.pow(Math.max(0, (eroded(x, z) - lo) / (hi - lo)), o.power);
  for (let j = 0; j <= 128; j++) for (let i = 0; i <= 128; i++) top = Math.max(top, shape(size * (i / 128 - 0.5), size * (j / 128 - 0.5)));
  // route: Catmull-Rom through the control points, parametrised by map (horizontal) distance
  const cr = (a: N, b: N, c: N, d: N, t: N) => 0.5 * (2 * b + (c - a) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (3 * b - a - 3 * c + d) * t * t * t), pts: V2[] = [], acc = [0];
  for (let i = 0; i < route.length - 1; i++) { const [p0, p1, p2, p3] = [route[Math.max(0, i - 1)], route[i], route[i + 1], route[Math.min(route.length - 1, i + 2)]];
    for (let k = 0; k < 40; k++) pts.push([cr(p0[0], p1[0], p2[0], p3[0], k / 40), cr(p0[1], p1[1], p2[1], p3[1], k / 40)]); }
  pts.push(route[route.length - 1]); for (let i = 1; i < pts.length; i++) acc.push(acc[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
  const L = acc[acc.length - 1], huts = o.hutIdx.map((i) => acc[i * 40] / L);
  const at = (t: N): V2 => { const d = Math.min(1, Math.max(0, t)) * L; let i = 1; while (i < acc.length - 1 && acc[i] < d) i++; const k = (d - acc[i - 1]) / (acc[i] - acc[i - 1] || 1);
    return [pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * k, pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * k]; };
  const seg = Array.from({ length: 65 }, (_, i) => at(i / 64));
  const dist = (x: N, z: N) => { let m = 1e9; for (let i = 0; i < 64; i++) { const [ax, az] = seg[i], dx = seg[i + 1][0] - ax, dz = seg[i + 1][1] - az;
    const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz))); m = Math.min(m, Math.hypot(x - ax - dx * t, z - az - dz * t)); } return m; };
  const carved = (x: N, z: N) => o.height * shape(x, z) / top * (1 - o.carve[0] * Math.exp(-((dist(x, z) / o.carve[1]) ** 2)));
  const tarns = huts.map((t, i) => { const [x, z] = at(t), [x2, z2] = at(t + 0.004), [x0, z0] = at(t - 0.004), l = Math.hypot(z0 - z2, x2 - x0) || 1, s = o.tarn[3][i] * o.tarn[0];
    const cx = x + (z0 - z2) / l * s, cz = z + (x2 - x0) / l * s; return { x: cx, z: cz, level: carved(cx, cz) - o.tarn[2] }; });
  const H = new Float32Array(n * n), W = new Float32Array(n * n);
  for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) { const x = i * step - size / 2, z = j * step - size / 2; let h = carved(x, z);
    for (const c of tarns) { const r = Math.hypot(x - c.x, z - c.z) / o.tarn[1]; if (r < 1) { const s = Math.min(1, (1 - r) / 0.45); h += (c.level - h) * s * s * (3 - 2 * s); if (r < 0.6) W[j * n + i] = 1; } }
    H[j * n + i] = Math.max(0, h); }
  const sample = (x: N, z: N) => { const fx = Math.min(grid - 1e-6, Math.max(0, (x + size / 2) / step)), fz = Math.min(grid - 1e-6, Math.max(0, (z + size / 2) / step)), i = Math.floor(fx), j = Math.floor(fz), u = fx - i, v = fz - j;
    const a = H[j * n + i], b = H[j * n + i + 1], c = H[j * n + n + i], d = H[j * n + n + i + 1]; return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; };
  return { H, W, at, sample, huts, length: L };
}

const VERT = `attribute float aWater; varying float vH, vWater; varying vec3 vN; varying vec2 vXZ;
void main(){ vH = position.y; vN = normal; vWater = aWater; vXZ = position.xz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
// Contours are drawn per pixel from the height; fwidth() keeps them ~1px at any distance and fades minor lines where they would crowd.
const FRAG = `uniform vec3 uPaper, uWarm, uInk, uLight; uniform float uFlat, uBase, uScale, uDusk, uHalf, uMinor, uMajor;
varying float vH, vWater; varying vec3 vN; varying vec2 vXZ;
float iso(float v, float k){ float w = fwidth(v), d = abs(fract(v - 0.5) - 0.5); return 1.0 - smoothstep(w * k * 0.5, w * k * 0.5 + w, d); }
void main(){
  float m = uBase + vH * uScale;
  float lines = max(iso(m / uMinor, 1.0) * (1.0 - smoothstep(0.16, 0.36, fwidth(m / uMinor))) * 0.42, iso(m / uMajor, 1.6) * (1.0 - smoothstep(0.22, 0.45, fwidth(m / uMajor))) * 0.7);
  vec3 paper = mix(uPaper, uWarm, uDusk), col = paper * (1.0 + 0.6 * (dot(normalize(vN), uLight) - uFlat));
  col = mix(col, uInk, lines * (1.0 - vWater) * step(0.03, vH));
  col = mix(col, mix(paper * 0.94, uInk, iso(vXZ.y * 2.4, 1.0) * 0.5), vWater);
  gl_FragColor = vec4(mix(paper, col, 1.0 - smoothstep(uHalf - 10.0, uHalf, max(abs(vXZ.x), abs(vXZ.y)))), 1.0);
}`;

export function initFlight(root: HTMLElement, C: FlightConfig): () => void {
  const $$ = <T extends Element = HTMLElement>(s: string) => [...root.querySelectorAll<T>(s)], $ = <T extends Element = HTMLElement>(s: string) => root.querySelector<T>(s), off: (() => void)[] = [];
  const on = (el: EventTarget, ev: string, fn: EventListener, opt?: AddEventListenerOptions) => { el.addEventListener(ev, fn, opt); off.push(() => el.removeEventListener(ev, fn, opt)); };
  const R = makeRelief(C.relief), T = [0, ...R.huts], S = R.huts.length, reduce = matchMedia('(prefers-reduced-motion: reduce)');
  const P = (t: N) => { const [x, z] = R.at(t); return new THREE.Vector3(x, R.sample(x, z) + C.lift, z); };
  const NP = 240, alt = Array.from({ length: NP + 1 }, (_, i) => C.relief.base + R.sample(...R.at(i / NP)) * C.relief.vscale), lo = Math.min(...alt), hi = Math.max(...alt);
  const py = (m: N) => 112 - (m - lo) / (hi - lo) * 96, line = alt.map((m, i) => `${i / NP * 1000},${py(m).toFixed(1)}`).join('L');
  $$<Element>('[data-profile-line]').forEach((el) => el.setAttribute('d', 'M' + line));
  $$<Element>('[data-profile-area]').forEach((el) => el.setAttribute('d', 'M0,120L' + line + 'L1000,120Z'));
  const pin = (node: HTMLElement, t: N, m: N) => { node.style.setProperty('--x', t.toFixed(4)); node.style.setProperty('--y', (py(m) / 120).toFixed(4)); };
  $$('[data-profile-tick]').forEach((el, k) => pin(el, R.huts[k], alt[Math.round(R.huts[k] * NP)]));
  const clip = $<Element>('[data-profile-clip]'), dot = $('[data-profile-dot]'), km = $('[data-readout-km]'), el = $('[data-readout-alt]'), links = $$('[data-stage-link]'), labels = $$('[data-hut-label]'), marker = $('[data-flight-marker]');
  // timeline: each [data-f] chapter pins value F when its centre meets the viewport centre; 0 overview · 1..S+1 stages · S+2 dusk
  let mids: V2[] = [];
  const measure = () => { mids = $$('[data-f]').map((c): V2 => { const r = c.getBoundingClientRect(); return [r.top + scrollY + r.height / 2 - innerHeight / 2, +(c.dataset.f ?? 0)]; }); };
  const goal = () => { const y = scrollY; if (reduce.matches) return mids.reduce((b, m) => (Math.abs(m[0] - y) < Math.abs(b[0] - y) ? m : b))[1];
    for (let i = 1; i < mids.length; i++) if (y < mids[i][0]) return y <= mids[0][0] ? mids[0][1] : mids[i - 1][1] + (mids[i][1] - mids[i - 1][1]) * (y - mids[i - 1][0]) / (mids[i][0] - mids[i - 1][0]);
    return mids[mids.length - 1][1]; };
  const routeT = (F: N) => { const k = Math.min(S, Math.max(1, Math.floor(F))); return F <= 1 ? 0 : F >= S + 1 ? 1 : T[k - 1] + (F - k) * (T[k] - T[k - 1]); };
  const ui = (F: N, t: N) => { const k = F >= 1 && F < S + 1 ? Math.floor(F) : 0, i = Math.round(t * NP);
    links.forEach((a) => { const s = +(a.dataset.stageLink ?? 0); if (s === k) a.setAttribute('aria-current', 'step'); else a.removeAttribute('aria-current'); a.classList.toggle('is-done', F >= s + 1); });
    clip?.setAttribute('width', String(t * 1000)); if (dot) pin(dot, t, alt[i]);
    if (km) km.textContent = (t * R.length * C.kmPerUnit).toFixed(1); if (el) el.textContent = (Math.round(alt[i] / 10) * 10).toLocaleString('en-US'); };
  root.classList.remove('is-flat', 'is-3d'); let renderer: THREE.WebGLRenderer | null = null;
  try { if (document.createElement('canvas').getContext('webgl2')) renderer = new THREE.WebGLRenderer({ antialias: true }); } catch { renderer = null; }
  if (!renderer) {
    root.classList.add('is-flat'); const flat = () => { const F = goal(); ui(F, routeT(F)); };
    on(window, 'scroll', flat, { passive: true }); on(window, 'resize', () => { measure(); flat(); }); measure(); flat(); return () => { off.forEach((f) => f()); root.classList.remove('is-flat'); }; }
  const gl: THREE.WebGLRenderer = renderer;
  gl.outputColorSpace = THREE.LinearSRGBColorSpace; // colours are read as linear: CSS hex in = same pixel out, global ColorManagement untouched
  const col = (h: string) => new THREE.Color().setStyle(h, THREE.LinearSRGBColorSpace), mount = $('[data-flight-stage]')!, scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(C.fov, 1, 0.5, 400);
  mount.appendChild(gl.domElement);
  const g = new THREE.PlaneGeometry(C.relief.size, C.relief.size, C.relief.grid, C.relief.grid); g.rotateX(-Math.PI / 2);
  for (let i = 0; i < R.H.length; i++) g.attributes.position.setY(i, R.H[i]); g.computeVertexNormals(); g.setAttribute('aWater', new THREE.BufferAttribute(R.W, 1));
  const sun = new THREE.Vector3(...C.light).normalize(), dusk = new THREE.Vector3(...C.duskLight).normalize(), paper = col(C.colors.paper), warm = col(C.colors.warm), clear = new THREE.Color();
  const U: Record<string, THREE.IUniform> = { uPaper: { value: paper }, uWarm: { value: warm }, uInk: { value: col(C.colors.ink) }, uLight: { value: sun.clone() }, uFlat: { value: sun.y }, uBase: { value: C.relief.base },
    uScale: { value: C.relief.vscale }, uDusk: { value: 0 }, uHalf: { value: C.relief.size / 2 }, uMinor: { value: C.contours[0] }, uMajor: { value: C.contours[1] } };
  scene.add(new THREE.Mesh(g, new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: FRAG })));
  const NL = 400, flat: N[] = []; for (let i = 0; i <= NL; i++) flat.push(...P(i / NL).toArray());
  const path = (m: LineMaterial) => { const lg = new LineGeometry(); lg.setPositions(flat); const l = new Line2(lg, m); l.computeLineDistances(); scene.add(l); return l; };
  const ghost = path(new LineMaterial({ color: col(C.colors.ink), dashed: true, dashSize: 1.1, gapSize: 0.8, transparent: true, opacity: 0.8 })), drawn = path(new LineMaterial({ color: col(C.colors.accent) }));
  const huts = R.huts.map((t) => P(t).setY(P(t).y + 0.6));
  // camera: overview -> chase along the route -> dusk overview
  const V = (a: V3) => new THREE.Vector3(...a), ov: Pose = [V(C.poses.overview[0]), V(C.poses.overview[1])], fin: Pose = [V(C.poses.final[0]), V(C.poses.final[1])];
  const chase = (t: N): Pose => { const c = P(t), d = P(t + 0.012).sub(P(t - 0.012)).setY(0).normalize(), p = c.clone().addScaledVector(d, -C.chase.back).add(new THREE.Vector3(-d.z, 0, d.x).multiplyScalar(C.chase.side));
    p.y = Math.max(c.y + C.chase.up, R.sample(p.x, p.z) + C.chase.clear); return [p, P(t + C.chase.ahead)]; };
  const ease = (x: N) => x * x * x * (x * (x * 6 - 15) + 10), mix = (a: Pose, b: Pose, k: N): Pose => [a[0].clone().lerp(b[0], k), a[1].clone().lerp(b[1], k)];
  const pose = (F: N): Pose => (F <= 1 ? mix(ov, chase(0), ease(Math.max(0, F))) : F < S + 1 ? chase(routeT(F)) : mix(chase(1), fin, ease(Math.min(1, F - S - 1))));
  let w = 1, h = 1, fit = 1, F = 0, dirty = true, last = performance.now(), raf = 0, intro = reduce.matches ? 0 : 1;
  const t0 = last;
  const resize = () => { w = mount.clientWidth; h = mount.clientHeight; const dpr = Math.min(devicePixelRatio, w < 768 ? 1.5 : 2); gl.setPixelRatio(dpr); gl.setSize(w, h, false);
    const cs = getComputedStyle(mount), ox = parseFloat(cs.getPropertyValue('--flight-offset-x')) || 0, oy = parseFloat(cs.getPropertyValue('--flight-offset-y')) || 0;
    camera.aspect = w / h; camera.setViewOffset(w, h, ox * w, oy * h, w, h); fit = camera.aspect < 1 ? Math.pow(1 / camera.aspect, C.fit) : 1; // optical centre follows the layout's free area
    drawn.material.linewidth = C.lineWidth[0]; ghost.material.linewidth = C.lineWidth[1]; measure(); dirty = true; }; // Line2 widths are CSS px
  const v = new THREE.Vector3(), side = new THREE.Vector3();
  const place = (node: HTMLElement, p: THREE.Vector3) => { v.copy(p).project(camera); const vis = v.z < 1 && Math.abs(v.x) < 1.05 && Math.abs(v.y) < 1.05; node.classList.toggle('is-off', !vis);
    if (vis) node.style.transform = `translate3d(${((v.x + 1) / 2 * w).toFixed(1)}px,${((1 - v.y) / 2 * h).toFixed(1)}px,0)`; };
  const frame = (now: N) => { raf = requestAnimationFrame(frame); const dt = Math.min(0.1, (now - last) / 1000); last = now;
    const target = goal(), nF = reduce.matches ? target : F + (target - F) * (1 - Math.exp(-dt * C.damping));
    if (Math.abs(nF - F) > 1e-5) dirty = true; F = Math.abs(target - nF) < 1e-4 ? target : nF;
    if (intro > 0) { intro = Math.max(0, 1 - (now - t0) / C.introMs); dirty = true; }
    if (!dirty) return; dirty = false;
    const [p, l] = pose(F), pull = 1 + C.introPull * (1 - Math.pow(1 - intro, 3)); p.sub(l).multiplyScalar(fit * pull).add(l);
    camera.position.copy(p); camera.lookAt(l); camera.updateMatrixWorld();
    const dk = F > S + 1 ? ease(Math.min(1, F - S - 1)) : 0, t = routeT(F); U.uDusk.value = dk; U.uLight.value.copy(sun).lerp(dusk, dk).normalize(); U.uFlat.value = U.uLight.value.y;
    gl.setClearColor(clear.copy(paper).lerp(warm, dk)); drawn.geometry.instanceCount = Math.round(t * NL);
    labels.forEach((n, i) => { place(n, huts[i]); if (F > 1.2 && F < S + 0.8 && huts[i].distanceTo(p) > C.labelRange) n.classList.add('is-off'); n.classList.toggle('is-reached', t >= R.huts[i] - 1e-4); });
    if (marker) { place(marker, P(t)); if (F < 1 || F > S + 1) marker.classList.add('is-off'); }
    const b = side.setFromMatrixColumn(camera.matrixWorld, 2); root.style.setProperty('--flight-north', `${(Math.atan2(b.x, b.z) * 180 / Math.PI).toFixed(1)}deg`);
    ui(F, t); gl.render(scene, camera); };
  on(window, 'resize', resize); on(reduce, 'change', () => { intro = 0; dirty = true; });
  let alive = true; document.fonts?.ready.then(() => { if (alive) { measure(); dirty = true; } });
  resize(); raf = requestAnimationFrame(frame); root.classList.add('is-3d');
  return () => { alive = false; cancelAnimationFrame(raf); off.forEach((f) => f()); scene.traverse((o) => { const m = o as THREE.Mesh; m.geometry?.dispose(); (m.material as THREE.Material | undefined)?.dispose(); }); gl.dispose(); gl.domElement.remove(); root.classList.remove('is-3d', 'is-flat'); };
}
```

2. **`src/config.ts`**: `import type { FlightConfig } from './flight';` then `export const CONFIG: FlightConfig = { … };` with exactly these values (the terrain and every figure on the page depend on them):

```ts
import type { FlightConfig } from './flight';

export const CONFIG: FlightConfig = {
  relief: { seed: 77, size: 100, grid: 256, height: 15, freq: 0.045, ridge: 0.5, env: [54, 36, 0.25, 0.1], envPow: 0.45, power: 0.9,
    route: [[-41, 29], [-35, 27], [-29, 24], [-24, 15], [-19, 7], [-15, -3], [-9, -15], [0, -17], [9, -13], [17, -12], [24, -8], [21, 0], [13, 4], [6, 7]],
    hutIdx: [3, 6, 10, 13], carve: [0.3, 3], tarn: [1.8, 2.4, 0.12, [1, 1, 1, 1]], base: 1265, vscale: 160 },
  kmPerUnit: 0.4, lift: 0.35, fov: 38, contours: [50, 250],
  colors: { paper: '#EEE7D6', warm: '#EBD9BE', ink: '#1E2B25', accent: '#C9432B' },
  light: [-1, 1.3, -1], duskLight: [-1, 0.45, 0.2],
  poses: { overview: [[4, 148, 72], [4, 0, 4]], final: [[-34, 78, 100], [4, 0, -4]] }, fit: 0.9,
  chase: { back: 16, up: 10, side: 3, clear: 4, ahead: 0.045 },
  damping: 5, introMs: 2400, introPull: 0.12, lineWidth: [3.5, 2], labelRange: 40
};
```

3. **Start** in `App.tsx` on the `#tw` div: `const root = useRef<HTMLDivElement>(null)` and `useEffect(() => initFlight(root.current!, CONFIG), [])` (the cleanup keeps one canvas under StrictMode).
4. **No re-renders on engine nodes.** `App`, `Side`, `Band`, `Chapters` and the labels hold no state: the engine writes classes, `aria-current`, inline styles and SVG attributes that React must never overwrite. Section 5 becomes JSX 1:1 (`className`, `clipPath="url(#tw-done)"`, `data-flight-stage=""`, camelCase SVG attributes; profile paths start with `d=""`).
5. **Fallbacks.** Without WebGL the engine adds `.is-flat` and draws no canvas: the poster stays; index, profile and readouts still follow the scroll.
6. **Menu** (`Header.tsx`, the only state): `open` drives `aria-expanded` on `.tw-menu-btn`, whose click calls `showModal()` on the dialog ref. `.tw-menu-close` and the dialog's links call `close()`; `onClose` sets `open` false and focuses the menu button. Escape and inertness are native. A `matchMedia('(min-width:1024px)')` listener closes it on desktop (removed on unmount).
7. Links and CTAs are plain anchors, animated by `html{scroll-behavior:smooth}` (instant with reduced motion).

## 9. Responsive

| Range | Token overrides | Layout changes |
|---|---|---|
| ≥1920px | `--tn-pad:56px; --tn-side:300px; --tn-card-w:440px; --tn-hero-w:600px; --tn-h1:72px; --tn-h2:46px; --tn-band:124px` | — |
| 1280–1919px | — | — |
| 1024–1279px | `--tn-pad:28px; --tn-side:236px; --tn-card-w:360px; --tn-hero-w:440px; --tn-h1:48px; --tn-h2:34px; --tn-title:22px` | — |
| <1024px (mobile layout) | `--tn-pad:16px; --tn-top:60px; --tn-band:92px; --tn-h1:40px; --tn-h2:30px; --tn-title:22px` | everything below |
| ≤400px | `--tn-h1:34px` | `.tw-ctas{flex-direction:column;align-items:flex-start}`; `.tw-btn{align-self:stretch}` |

Mobile layout (<1024px):
- Header: `.tw-nav` and `.tw-head-cta` hidden; `.tw-menu-btn` shown.
- `.tw-stage{--flight-offset-x:0;--flight-offset-y:.12}` (the massif sits in the upper part, above the cards).
- `.tw-side` becomes a row of stage chips: `position:fixed; top:var(--tn-top); left:16px; right:16px; bottom:auto; width:auto; display:block`. `.tw-index{padding:0;border:0;background:none}`; hide its `.tw-kicker`, `.tw-meta`, the `.tw-name>span` (" Hut") and the `.tw-name::before` square; `ol{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:0}`; each link: `display:flex; justify-content:center; align-items:center; gap:6px; height:36px; padding:0 4px; border:1px solid var(--tn-edge); background:var(--tn-card)`; `.tw-num` Display at 12px ink; `.tw-name` Label at 10px with `font-stretch:100%; letter-spacing:.04em`; active: no `::after` bar, `box-shadow:inset 0 -3px 0 var(--tn-accent)`; done: `.tw-num` muted.
- `.tw-legend`: `position:absolute; right:0; top:48px; padding:6px` (inside `.tw-side`, under the chips); hide its `p` (compass only).
- Chapters: `align-items:flex-end; padding-left:16px; padding-right:16px; padding-bottom:calc(20svh + var(--tn-band) + 16px)` (longhands: each chapter keeps its padding-top). Hero and end: `min-height:calc(100svh + 120px); padding-bottom:calc(var(--tn-band) + 16px)`, so at the top of the page the map shows above the hero card (kicker and h1 visible) and the card finishes rising as you scroll. Cards `width:100%`, padding `18px 18px 20px` (hero and end `22px 20px`).
- `.tw-stats{gap:0 12px}`, `dd` 16px; `.tw-blocks` one column; `.tw-lede` 16px.
- Band: `grid-template-columns:1fr; grid-template-rows:auto 1fr; row-gap:4px; padding:10px 16px 12px`; `.tw-read{flex-wrap:nowrap}` with its kicker `flex-basis:auto` (kicker, km, m on one line), `b` 16px; `.tw-plot` 44px high. Hut labels: `.tw-hut small{display:none}`.

Touch: no hover-only actions; the page scroll drives the map (no gesture captured). No horizontal scroll at **375 · 768 · 1440 · 1920** px.

## 10. Accessibility & performance

- One `<h1>`; each stage card has its `<h2>`; the arrival card has an `<h2>` and two `<h3>`.
- Landmarks: `header`, `nav` "Primary", `nav` "Route stages", `main`; the profile is a `figure` with `role="img"` and its label; the dialog has `aria-label="Menu"`.
- `aria-hidden="true"` on the poster, canvas, hut labels, marker, compass and profile SVG (the index carries the same information).
- Contrast: ink on paper 11.9:1, muted on paper 5.9:1, text on cards ≥ 6.3:1; the accent is never text.

## 11. Acceptance checklist

- [ ] **First paint:** paper and the hero card box (and the poster once loaded), never blank; header, index and band have faded in by 2.4 s.
- [ ] **Entrance:** the map fades in while the camera settles closer; the two h1 lines rise at 0.45s and 0.6s.
- [ ] **Overview:** whole massif and four hut labels visible, between index and hero card (desktop) or above the hero card (mobile); compass tip up.
- [ ] **Flight:** through stages 01–04 the camera follows the dashed trail, the vermilion line reaches the marker, the active row/chip moves 01 → 04 with earlier ones done, the profile fills to its dot; stage 02 card centred → 16.1 km.
- [ ] **Arrival:** warm dusk view, whole line drawn, every hut square filled.
- [ ] **Menu (<1024px):** modal dialog; Escape and Close shut it; focus returns to Menu.
- [ ] **Responsive / reduced motion:** no horizontal scroll at 375, 768, 1440, 1920; with reduced motion no entrance and the camera cuts between chapter views.
- [ ] **No WebGL:** poster visible; index, profile and readouts follow the scroll.
- [ ] `npm run build` passes; one canvas after StrictMode's double mount; zero console errors; only the dependencies of section 1 and the URLs of sections 2–3.
HTML · un archivo459 líneas
Abrir el texto completo
# Build: "Tarnway" — a paper relief map you fly along, hut to hut

## 0. Brief

A scrolling landing page for **Tarnway**, a four-day hut-to-hut traverse of a fictional range, the Ardel massif. The whole page is a 3D relief map printed on cream paper: ink contour lines, a dashed trail and four huts beside small hatched lakes (tarns). Scrolling flies the camera from a high overview down onto the trail and along it, stage by stage, while a vermilion line draws itself up to a position marker, the stage index lights up and the elevation profile at the bottom follows; the last chapter rises to a warm dusk view of the whole line. It must feel calm, precise and printed, never like a map app or a game. The "wow" is the first scroll: the paper map tilts and dives onto the trail.

## 1. Output & hard constraints

- ONE `index.html`: one inline `<style>`, the import map below, one inline `<script type="module">` (engine + config + start, section 8) and one small classic inline `<script>` (menu, section 8), both at the end of `<body>`. No build step.
- External code: `three@0.186.1` only, as ES modules from jsDelivr through this import map, placed in `<head>` before any module script:
  ```html
  <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"}}</script>
  ```
  The engine imports `three`, `three/addons/lines/Line2.js`, `three/addons/lines/LineMaterial.js` and `three/addons/lines/LineGeometry.js`. Serve the page over HTTP(S); ES modules do not run from `file://`.
- `<html lang="en">`. `<head>`: charset utf-8; viewport `width=device-width, initial-scale=1, viewport-fit=cover`; title `Tarnway — Four huts. One line.`; description "A four-day hut-to-hut traverse of the Ardel massif, booked end to end."; `theme-color` `#EEE7D6`.
- Page type: one scrolling page of six chapters over a fixed full-screen 3D map. Exactly two layouts: desktop (≥1024px) and mobile (<1024px).
- Build exactly what this prompt describes. Do NOT add:
  - map controls of any kind (drag, orbit, zoom, pan), real maps, GPS or geographic data, weather, availability;
  - a booking or payment form (the booking CTA is a `mailto:` link), prices, other routes;
  - an automatic tour, a play/pause control, water ripples or any other animation while the page is at rest;
  - a third layout, other sections (pricing, testimonials, gallery, FAQ, footer), extra images, icon fonts, other fonts or libraries.
- Do NOT substitute, rename or invent URLs: section 2 and the import map, verbatim.
- Anti-flash: the FIRST CSS rule is `html,body{background:#EEE7D6!important;color:#1E2B25}`, and `<body style="background:#EEE7D6;color:#1E2B25">`.
- Zero console errors when served over HTTP(S), also with WebGL unavailable (section 8, step 4).

## 2. Assets (exact URLs — the only images)

The terrain, trail and huts are generated in code (section 8): no models, textures or environment files. The only images are the fallback posters, shown under the map until it starts and for good if WebGL is missing.

| ID | Type | URL | Size | Role |
|---|---|---|---|---|
| POSTER_WEBP | image/webp | `https://cdn.orbital.supply/exp-0002/poster.webp` | 1600×1000 | Overview, ≥1024px |
| POSTER_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0002/poster.jpg` | 1600×1000 | `<img src>` fallback |
| POSTER_MOBILE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0002/poster-mobile.webp` | 780×1688 | Overview, <1024px |
| POSTER_MOBILE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0002/poster-mobile.jpg` | 780×1688 | Fallback |

`<picture class="tw-poster" aria-hidden="true">`: `<source media="(max-width: 1023px)">` for the mobile files (webp first; `type="image/webp"` / `type="image/jpeg"`), `<source type="image/webp">` POSTER_WEBP, `<img src="POSTER_JPG" alt="" width="1600" height="1000" decoding="async">`. A failed poster leaves the paper background; nothing moves.

## 3. Typography

```
https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap
```
`<link rel="preconnect">` to `https://fonts.googleapis.com` and `https://fonts.gstatic.com` (crossorigin), then the stylesheet. One family, two widths (`font-stretch` drives the `wdth` axis):

| Role | Settings | Used ONLY on |
|---|---|---|
| Display | `font-stretch:125%`, 800, uppercase | h1, h2, stage titles, brand, big readouts, index numbers, menu links |
| Label | `font-stretch:125%`, 700, uppercase, `letter-spacing:.14em`, 11px (10–12px where stated) | kickers, hut names, stat labels, chips, buttons (112%) |
| Text | `font-stretch:100%`, 400 | paragraphs |
| UI | `font-stretch:100%`, 500–600 | nav links, meta lines, legend |

Stack: `"Archivo", Arial, sans-serif`. Every number uses `font-variant-numeric:tabular-nums`. Global: `-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; font-synthesis:none`. Archivo has ↑ ↓ × and the en dash; it has no → arrow: never use →.

## 4. Design tokens (base viewport 1440px)

```css
:root{
  --tn-paper:#EEE7D6; --tn-card:rgba(244,239,227,.94); --tn-ink:#1E2B25; --tn-ink-2:#33443B; --tn-muted:#4E5A52;
  --tn-accent:#C9432B; --tn-line:rgba(30,43,37,.2); --tn-edge:rgba(30,43,37,.85);
  --tn-pad:40px; --tn-top:72px; --tn-band:112px; --tn-side:272px; --tn-card-w:400px; --tn-hero-w:520px;
  --tn-h1:60px; --tn-h2:40px; --tn-title:26px;
  --tn-ease:cubic-bezier(.22,.8,.24,1);
}
```
Colours come from the paper map: ink on paper 11.9:1, muted on paper 5.9:1. The accent is used ONLY for route marks (trail line, hut squares, position marker and dot, active stage bar, compass tip), never for text.

## 5. Layer stack & DOM

Z-order (back → front): `.tw-poster` z 0 · `.tw-stage` z 1 (canvas) · `.tw-hut` labels and `.tw-marker` z 2 · `main` chapters z 3 · `.tw-side` and `.tw-band` z 5 · `.tw-head` z 10 · `dialog` (top layer).

Exact markup (ids, classes and `data-*` attributes are load-bearing: the engine uses them). Copy is final.

```html
<body style="background:#EEE7D6;color:#1E2B25">
<div class="tw" id="tw">
  <picture class="tw-poster" aria-hidden="true"><!-- section 2 --></picture>
  <div class="tw-stage" data-flight-stage aria-hidden="true"></div>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Oss Hut</b><small>2,030 m</small></span></span>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Brae Hut</b><small>1,950 m</small></span></span>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Kettle Hut</b><small>2,110 m</small></span></span>
  <span class="tw-hut" data-hut-label aria-hidden="true"><i></i><span><b>Skarn Hut</b><small>2,480 m</small></span></span>
  <span class="tw-marker" data-flight-marker aria-hidden="true"></span>

  <header class="tw-head">
    <a class="tw-brand" href="#top"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M2 17c3-6 6-10 9-10s6 4 9 10"/><path d="M6.5 17c1.5-3 3-5 4.5-5s3 2 4.5 5"/></svg>Tarnway</a>
    <nav class="tw-nav" aria-label="Primary"><a href="#stage-1">Route</a><a href="#included">Included</a><a href="#season">Season</a></nav>
    <a class="tw-btn tw-head-cta" href="#book">Book the huts</a>
    <button class="tw-menu-btn" type="button" aria-expanded="false" aria-controls="tw-menu">Menu</button>
  </header>

  <aside class="tw-side">
    <nav class="tw-index" aria-label="Route stages">
      <p class="tw-kicker">The line <span>4 stages · 47.4 km</span></p>
      <ol>
        <li><a href="#stage-1" data-stage-link="1"><span class="tw-num">01</span><span class="tw-name">Oss<span> Hut</span></span><span class="tw-meta">9.4 km · ↑ 900 m · 4 h 45</span></a></li>
        <li><a href="#stage-2" data-stage-link="2"><span class="tw-num">02</span><span class="tw-name">Brae<span> Hut</span></span><span class="tw-meta">13.5 km · ↑ 580 m · 5 h</span></a></li>
        <li><a href="#stage-3" data-stage-link="3"><span class="tw-num">03</span><span class="tw-name">Kettle<span> Hut</span></span><span class="tw-meta">14.3 km · ↑ 920 m · 6 h 30</span></a></li>
        <li><a href="#stage-4" data-stage-link="4"><span class="tw-num">04</span><span class="tw-name">Skarn<span> Hut</span></span><span class="tw-meta">10.2 km · ↑ 670 m · 4 h</span></a></li>
      </ol>
    </nav>
    <div class="tw-legend"><span class="tw-compass" aria-hidden="true"><i></i></span><p>Contour interval 50 m<br>Vertical exaggeration ×2.5</p></div>
  </aside>

  <figure class="tw-band" role="img" aria-label="Elevation profile: 47.4 km from 1,450 m; highest point 2,510 m at Aske col">
    <div class="tw-read"><span class="tw-kicker">Elevation</span><b><span data-readout-km>0.0</span> km</b><b><span data-readout-alt>1,450</span> m</b></div>
    <div class="tw-plot">
      <svg viewBox="0 0 1000 120" preserveAspectRatio="none" aria-hidden="true">
        <defs><clipPath id="tw-done"><rect data-profile-clip x="0" y="0" width="0" height="120"/></clipPath></defs>
        <path class="tw-area" data-profile-area d=""/><path class="tw-full" data-profile-line d=""/><path class="tw-done" data-profile-line clip-path="url(#tw-done)" d=""/>
      </svg>
      <i class="tw-tick" data-profile-tick><span>01</span></i><i class="tw-tick" data-profile-tick><span>02</span></i><i class="tw-tick" data-profile-tick><span>03</span></i><i class="tw-tick" data-profile-tick><span>04</span></i>
      <i class="tw-dot" data-profile-dot></i>
    </div>
  </figure>

  <main class="tw-track">
    <section class="tw-ch tw-ch-hero" id="top" data-f="0">
      <div class="tw-card tw-hero">
        <p class="tw-kicker">Hut-to-hut traverse · Ardel massif</p>
        <h1><span class="tw-mask"><span>Four huts.</span></span><span class="tw-mask"><span>One line.</span></span></h1>
        <p class="tw-lede">Four days and 47 km across the Ardel massif, sleeping beside a high tarn every night. We book the huts and move your bag; you walk the line.</p>
        <div class="tw-ctas"><a class="tw-btn" href="#book">Book the huts</a><a class="tw-link" href="#stage-1">Follow the route ↓</a></div>
      </div>
    </section>
    <!-- stage-1 … stage-4 go here (table below) -->
    <section class="tw-ch tw-ch-end" id="book" data-f="6" aria-labelledby="tw-end">
      <div class="tw-card tw-end">
        <p class="tw-kicker">Arrival · Skarn Hut, 2,480 m</p>
        <h2 id="tw-end">The whole line, drawn.</h2>
        <dl class="tw-stats"><div><dt>Distance</dt><dd>47.4 km</dd></div><div><dt>Ascent</dt><dd>3,070 m</dd></div><div><dt>Descent</dt><dd>2,050 m</dd></div><div><dt>Nights</dt><dd>4</dd></div></dl>
        <div class="tw-blocks">
          <div id="included"><h3>Included</h3><p>Four nights in the huts with dinner and breakfast, your bag moved every morning and a printed relief map of the line.</p></div>
          <div id="season"><h3>Season</h3><p>The huts open from 20 June to 20 September, for groups of two to eight walkers.</p></div>
        </div>
        <div class="tw-ctas"><a class="tw-btn" href="mailto:huts@tarnway.example?subject=Tarnway%20dates">Request dates</a><a class="tw-link" href="#top">Back to the overview ↑</a></div>
      </div>
    </section>
  </main>
</div>
<dialog class="tw-menu" id="tw-menu" aria-label="Menu">
  <div class="tw-menu-top"><span class="tw-brand" aria-hidden="true">Tarnway</span><button class="tw-menu-close" type="button">Close</button></div>
  <nav aria-label="Menu links"><a href="#stage-1">Route</a><a href="#included">Included</a><a href="#season">Season</a></nav>
  <a class="tw-btn" href="#book">Book the huts</a>
</dialog>
<!-- section 8: module script, then classic menu script -->
</body>
```

Each stage chapter, in this order between the hero and `#book` (N = 1…4):
`<section class="tw-ch" id="stage-N" data-f="…" aria-labelledby="tw-sN"><article class="tw-card"><p class="tw-kicker">Kicker</p><h2 class="tw-title" id="tw-sN">Title</h2><dl class="tw-stats">` four `<div><dt>Distance|Ascent|Descent|Time</dt><dd>value</dd></div>` `</dl><p>Text</p></article></section>`

| id | data-f | Kicker | Title | Distance | Ascent | Descent | Time | Text |
|---|---|---|---|---|---|---|---|---|
| stage-1 | 1.5 | Stage 01 · Day 1 | Vell – Oss Hut | 9.4 km | 900 m | 320 m | 4 h 45 | Out of the larch woods and up the long western shoulder. Oss Hut sits on the lip of its tarn at 2,030 m. |
| stage-2 | 2.5 | Stage 02 · Day 2 | Oss Hut – Brae Hut | 13.5 km | 580 m | 670 m | 5 h | Over Aske col at 2,510 m, the highest step of the traverse, then down into the hanging valley of Brae Tarn. |
| stage-3 | 3.5 | Stage 03 · Day 3 | Brae Hut – Kettle Hut | 14.3 km | 920 m | 760 m | 6 h 30 | The longest day: a balcony path under the north face, two short climbs and the quiet basin of Kettle Tarn. |
| stage-4 | 4.5 | Stage 04 · Day 4 | Kettle Hut – Skarn Hut | 10.2 km | 670 m | 300 m | 4 h | The shortest day and the widest view. Skarn Hut, at 2,480 m, looks back along the whole line. |

Every figure on the page (km, ascent, altitudes) is computed from the terrain of section 8: keep them exactly as written.

## 6. Components

Links and buttons: `:focus-visible{outline:2px solid var(--tn-ink);outline-offset:3px}`. Hovers change colour or underline only, never `transform`/`translate` (the entrance animates `translate`).

### 6.1 Page, poster and stage
- `html{scroll-behavior:smooth}`; `body{margin:0;font-family:"Archivo",Arial,sans-serif;font-size:16px;line-height:1.55;font-variant-numeric:tabular-nums}`; `button{font-family:inherit}`; `.tw{position:relative}`.
- `.tw-poster{position:fixed;inset:0;z-index:0}`; its `img{display:block;width:100%;height:100%;object-fit:cover}`. `.tw.is-3d .tw-poster{opacity:0;transition:opacity .8s linear .4s}`.
- `.tw-stage{position:fixed;inset:0;z-index:1;opacity:0;transition:opacity 1.6s var(--tn-ease);--flight-offset-x:.08;--flight-offset-y:0}`; `.tw.is-3d .tw-stage{opacity:1}`; `.tw-stage canvas{display:block;width:100%;height:100%}`. The two offsets move the optical centre so the massif sits in the free area between the index and the cards (the engine reads them on resize).

### 6.2 Hut labels and position marker (placed by the engine)
- `.tw-hut{position:fixed;left:0;top:0;z-index:2;display:flex;align-items:center;gap:6px;height:10px;margin:-5px 0 0 -5px;pointer-events:none;will-change:transform}` (10px high like its square, the text plate overflows evenly: the square's centre sits on the hut).
- `.tw-hut i{width:10px;height:10px;box-sizing:border-box;border:2px solid var(--tn-accent);background:var(--tn-paper)}`; `.tw-hut.is-reached i{background:var(--tn-accent)}`.
- `.tw-hut>span{padding:3px 6px 3px 5px;background:rgba(238,231,214,.9);font-size:10.5px;line-height:1.2;font-weight:500;color:var(--tn-muted);white-space:nowrap}` (tabular); `.tw-hut b`: Label look at 10.5px, `letter-spacing:.12em`, ink, `margin-right:5px`; `.tw-hut small{font-size:inherit}`.
- `.tw-marker{position:fixed;left:0;top:0;z-index:2;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:var(--tn-accent);box-shadow:0 0 0 3px var(--tn-paper),0 0 0 4px var(--tn-ink);pointer-events:none}`.
- `.tw-hut.is-off,.tw-marker.is-off,.tw:not(.is-3d) .tw-hut,.tw:not(.is-3d) .tw-marker{visibility:hidden}` (hidden until the engine places them); `.tw.is-flat .tw-hut,.tw.is-flat .tw-marker{display:none}`.

### 6.3 Header
- `.tw-head{position:fixed;inset:0 0 auto;z-index:10;height:var(--tn-top);display:flex;align-items:center;gap:32px;padding:0 var(--tn-pad);background:linear-gradient(var(--tn-paper) 40%,rgba(238,231,214,0))}`.
- `.tw-brand`: inline-flex, centred, gap 10px, ink, no underline, Display 15px, `letter-spacing:.22em`. `.tw-nav`: `margin-left:auto`, flex, gap 28px, links UI 14px 500 ink, underline on hover (offset 5px).
- `.tw-menu-btn` (mobile only: `display:none` from 1024px, `display:inline-flex; align-items:center` below): `margin-left:auto; height:40px; padding:0 14px; border:1px solid var(--tn-ink); border-radius:2px; background:transparent; color:var(--tn-ink)`, Label style at `font-stretch:112%`.

### 6.4 Buttons and links
- `.tw-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:44px;padding:0 20px;border-radius:2px;background:var(--tn-ink);color:var(--tn-paper);text-decoration:none;font-size:12px;font-stretch:112%;font-weight:700;letter-spacing:.1em;text-transform:uppercase;transition:background-color .3s var(--tn-ease)}`; hover `background:var(--tn-ink-2)`. `.tw-head-cta{height:40px}`.
- `.tw-link{font-size:14px;font-weight:600;color:var(--tn-ink);text-decoration:underline;text-underline-offset:5px;text-decoration-thickness:1px}`; hover thickness 2px.

### 6.5 Side column: stage index and legend (desktop)
- `.tw-side{position:fixed;left:var(--tn-pad);top:calc(var(--tn-top) + 16px);bottom:calc(var(--tn-band) + 24px);z-index:5;width:var(--tn-side);display:flex;flex-direction:column;justify-content:space-between;pointer-events:none}`; its two children `pointer-events:auto`. Its entrance leaves a `translate` on it, so it is the containing box of anything positioned inside it (never give its children `position:fixed`).
- `.tw-index`, `.tw-legend`, `.tw-card` look like map legends: `background:var(--tn-card); border:1px solid var(--tn-edge)`, square, no shadow.
- `.tw-index{padding:16px 18px 4px}`; `.tw-kicker`: Label style, muted, margin 0; inside the index it is flex, `justify-content:space-between`, and its `span` is UI 12px 500 normal case with no letter-spacing.
- `.tw-index ol{list-style:none;margin:10px 0 0;padding:0}`.
- `.tw-index a{position:relative;display:grid;grid-template-columns:34px 1fr;column-gap:10px;padding:12px 0;border-top:1px solid var(--tn-line);color:var(--tn-ink);text-decoration:none}`.
  - `.tw-num`: `grid-row:span 2`, Display 20px/1, tabular, muted.
  - `.tw-name`: Label at 12px, `letter-spacing:.1em`, ink; `::before` an 8px square (`box-sizing:border-box; border:1.5px solid var(--tn-accent)`, inline-block, `margin-right:8px`, `vertical-align:0`).
  - `.tw-meta`: UI 12.5px 500, tabular, muted, `margin-top:3px`.
  - Active (`[aria-current]`): `.tw-num` ink; `::after{content:"";position:absolute;left:-18px;top:12px;bottom:12px;width:3px;background:var(--tn-accent)}`. Active and `.is-done`: the square is filled with the accent.
- `.tw-legend{display:flex;align-items:center;gap:14px;padding:12px 16px}`; `p{margin:0;font-size:12px;line-height:1.45;font-weight:500;color:var(--tn-muted)}`.
- `.tw-compass{flex:none;display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--tn-edge);border-radius:50%}`; `.tw-compass i{width:10px;height:26px;rotate:var(--flight-north,0deg);background:linear-gradient(var(--tn-accent) 50%,var(--tn-ink) 50%);clip-path:polygon(50% 0,100% 50%,50% 100%,0 50%)}` (accent tip = north; the engine writes `--flight-north` on `#tw`).

### 6.6 Chapters and cards
- `.tw-track{position:relative;z-index:3}`.
- `.tw-ch{min-height:140vh;min-height:140svh;display:flex;align-items:center;justify-content:flex-end;padding:0 var(--tn-pad);box-sizing:border-box}`; `.tw-ch-hero,.tw-ch-end{min-height:100vh;min-height:100svh;padding-top:var(--tn-top);padding-bottom:var(--tn-band)}`.
- `.tw-card{box-sizing:border-box;width:var(--tn-card-w);padding:24px 26px 26px}`; `.tw-hero,.tw-end{width:var(--tn-hero-w);padding:30px 32px 32px}`.
- `h1{margin:14px 0 18px;font-size:var(--tn-h1);line-height:.95;letter-spacing:-.01em}` (Display). `.tw-mask{display:block;overflow:hidden;padding-bottom:.04em}`; `.tw-mask>span{display:block}`.
- `.tw-lede{margin:0 0 24px;max-width:42ch;font-size:17px;line-height:1.55}`.
- `.tw-ctas{display:flex;flex-wrap:wrap;align-items:center;gap:14px 22px}`.
- `.tw-title{margin:10px 0 16px;font-size:var(--tn-title);line-height:1.05;letter-spacing:.01em}` (Display). `.tw-end h2{margin:12px 0 18px;font-size:var(--tn-h2);line-height:.98}` (Display).
- `.tw-stats{display:grid;grid-template-columns:repeat(4,auto);justify-content:start;gap:0 18px;margin:0 0 16px;padding:12px 0;border-block:1px solid var(--tn-line)}`; `dt`: Label at 10px with `font-stretch:112%; letter-spacing:.08em`, muted; `dd{margin:4px 0 0;font-size:18px;font-stretch:100%;font-weight:700}`.
- Stage text `.tw-card>p:last-child`: 15.5px/1.55, margin 0 (a broader `.tw-card p` would override `.tw-kicker`).
- `.tw-blocks{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:0 0 22px}`; `h3`: Label style, ink, margin `0 0 6px`; `p{margin:0;font-size:14.5px;line-height:1.5;color:var(--tn-muted)}`. `#included,#season{scroll-margin-top:120px}`.

### 6.7 Elevation band
- `.tw-band{position:fixed;left:0;right:0;bottom:0;z-index:5;box-sizing:border-box;height:var(--tn-band);margin:0;display:grid;grid-template-columns:var(--tn-side) 1fr;column-gap:24px;align-items:center;padding:0 var(--tn-pad);background:var(--tn-paper);border-top:1px solid var(--tn-edge)}` (opaque: cards slide under it).
- `.tw-read{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 18px}`; its kicker `flex-basis:100%` (a row of its own); `b`: Display at 22px/1.1, not uppercase.
- `.tw-plot{position:relative;height:72px}`; its `svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}`; paths: `.tw-area{fill:rgba(30,43,37,.08)}`, `.tw-full{fill:none;stroke:rgba(30,43,37,.45);stroke-width:1.25}`, `.tw-done{fill:none;stroke:var(--tn-accent);stroke-width:2.5}`, all with `vector-effect:non-scaling-stroke`.
- `.tw-tick,.tw-dot{position:absolute;left:calc(var(--x) * 100%);top:calc(var(--y) * 100%);translate:-50% -50%;--x:0;--y:1}` (defaults until the engine writes them). `.tw-tick{width:9px;height:9px;box-sizing:border-box;border:2px solid var(--tn-ink);background:var(--tn-paper)}`; its `span{position:absolute;bottom:calc(100% + 4px);left:50%;translate:-50% 0;font-size:9px;font-style:normal;font-stretch:125%;font-weight:700;color:var(--tn-muted)}`. `.tw-dot{width:12px;height:12px;border-radius:50%;background:var(--tn-accent);box-shadow:0 0 0 2px var(--tn-paper)}`.

### 6.8 Mobile menu (a native `<dialog>`, <1024px only)
- `.tw-menu{box-sizing:border-box;width:100%;max-width:none;height:100%;max-height:none;margin:0;padding:0 16px 24px;border:0;background:var(--tn-paper);color:var(--tn-ink)}` and ONLY `.tw-menu[open]{display:flex;flex-direction:column}` (a `display` rule without `[open]` would show the closed dialog). It covers the whole screen, so it has no visible backdrop.
- `.tw-menu-top`: 60px, flex, `space-between`, centred; `.tw-menu-close` looks like `.tw-menu-btn`. Links: block, Display 34px/1.1, ink, no underline, padding `14px 0`, `border-bottom:1px solid var(--tn-line)`. The `.tw-btn` sits at the bottom (`margin-top:auto`), full width, 48px high.

## 7. Motion (MP-CALM: long, quiet, no bounce)

Entrance. Keyframes animate only `opacity` and `translate` with `animation-fill-mode:both`; resting state is visible.

| # | Element | Motion | Duration | Delay | Easing |
|---|---|---|---|---|---|
| 1 | `.tw-head` | `tn-fade` | 1.2s | .1s | `--tn-ease` |
| 2 | `.tw-hero .tw-kicker` | `tn-fade` | 1.2s | .3s | `--tn-ease` |
| 3 | `h1 .tw-mask:nth-child(1)>span` | `tn-rise` | 1.4s | .45s | `--tn-ease` |
| 4 | `h1 .tw-mask:nth-child(2)>span` | `tn-rise` | 1.4s | .6s | `--tn-ease` |
| 5 | `.tw-lede` | `tn-fade` | 1.2s | .8s | `--tn-ease` |
| 6 | `.tw-hero .tw-ctas` | `tn-fade` | 1.2s | .95s | `--tn-ease` |
| 7 | `.tw-side` | `tn-fade` | 1.2s | 1.1s | `--tn-ease` |
| 8 | `.tw-band` | `tn-fade` | 1.2s | 1.2s | `--tn-ease` |
| 9 | map | the canvas fades in over 1.6s (6.1) while the engine's camera settles from 12% farther away over 2.4s | | | |

```css
@keyframes tn-rise{from{translate:0 105%}to{translate:0 0}}
@keyframes tn-fade{from{opacity:0;translate:0 12px}to{opacity:1;translate:0 0}}
```
Scroll motion belongs to the engine (damped camera). Stage cards simply scroll in; no entrance of their own.

Reduced motion (last rules of the stylesheet):
```css
@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  *,*::before,*::after{animation:none!important;transition:none!important}
}
```
The engine also stops the flight: the camera cuts between the view of each chapter.

## 8. Behaviour (JS)

1. **Module script** (`<script type="module">`, end of `<body>`): the engine core below **verbatim** (it starts with its four `import` lines; keep both functions unchanged), then the configuration below verbatim, then `initFlight(document.getElementById('tw'), CONFIG);`.
2. **Engine core** (the only literal code of this prompt; it builds the terrain, contour shader, trail, camera, profile, labels and compass, and handles touch scroll, reduced motion, the no-WebGL fallback and cleanup):

```js
import * as THREE from 'three';
import { Line2 } from 'three/addons/lines/Line2.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';

// Relief: pure JS, deterministic (same seed = same massif and same figures in every build).
function makeRelief(o) {
  const { seed, size, grid, route } = o, n = grid + 1, step = size / grid;
  const hash = (x, z) => { let h = Math.imul(x, 374761393) ^ Math.imul(z, 668265263) ^ Math.imul(seed, 2246822519); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; };
  const noise = (x, z) => { // value noise [-1,1] + derivatives
    const i = Math.floor(x), j = Math.floor(z), fx = x - i, fz = z - j, u = fx * fx * (3 - 2 * fx), v = fz * fz * (3 - 2 * fz);
    const a = hash(i, j), b = hash(i + 1, j), c = hash(i, j + 1), k = a - b - c + hash(i + 1, j + 1);
    return [2 * (a + (b - a) * u + (c - a) * v + k * u * v) - 1, 12 * fx * (1 - fx) * (b - a + k * v), 12 * fz * (1 - fz) * (c - a + k * u)]; };
  const eroded = (x, z) => { // octaves damped by accumulated slope: sharp ridges, smooth valleys
    let s = 0, amp = 1, dx = 0, dz = 0, px = x * o.freq + 11.3, pz = z * o.freq + 7.9;
    for (let i = 0; i < 7; i++) { let [h, hx, hz] = noise(px, pz);
      if (i) { const r = 1 - o.ridge * (1 + 2 * Math.sign(h)); h += o.ridge * (1 - 2 * Math.abs(h) - h); hx *= r; hz *= r; }
      dx += hx; dz += hz; s += amp * h / (1 + dx * dx + dz * dz); amp *= 0.5; const t = px; px = (0.8 * t - 0.6 * pz) * 2; pz = (0.6 * t + 0.8 * pz) * 2; }
    return s; };
  const envelope = (x, z) => { const r0 = Math.hypot((x - z) * 0.7071 / o.env[0], (x + z) * 0.7071 / o.env[1]);
    const e = Math.min(1, Math.max(0, (1 - r0 - o.env[3] * noise(x * 0.045 + 3.1, z * 0.045 + 8.7)[0] * Math.min(1, r0)) / (1 - o.env[2]))); return e * e * (3 - 2 * e); };
  let lo = 1e9, hi = -1e9, top = 0;
  for (let j = 0; j <= 64; j++) for (let i = 0; i <= 64; i++) { const v = eroded(size * (i / 64 - 0.5), size * (j / 64 - 0.5)); lo = Math.min(lo, v); hi = Math.max(hi, v); }
  const shape = (x, z) => Math.pow(envelope(x, z), o.envPow) * Math.pow(Math.max(0, (eroded(x, z) - lo) / (hi - lo)), o.power);
  for (let j = 0; j <= 128; j++) for (let i = 0; i <= 128; i++) top = Math.max(top, shape(size * (i / 128 - 0.5), size * (j / 128 - 0.5)));
  // route: Catmull-Rom through the control points, parametrised by map (horizontal) distance
  const cr = (a, b, c, d, t) => 0.5 * (2 * b + (c - a) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (3 * b - a - 3 * c + d) * t * t * t), pts = [], acc = [0];
  for (let i = 0; i < route.length - 1; i++) { const [p0, p1, p2, p3] = [route[Math.max(0, i - 1)], route[i], route[i + 1], route[Math.min(route.length - 1, i + 2)]];
    for (let k = 0; k < 40; k++) pts.push([cr(p0[0], p1[0], p2[0], p3[0], k / 40), cr(p0[1], p1[1], p2[1], p3[1], k / 40)]); }
  pts.push(route[route.length - 1]); for (let i = 1; i < pts.length; i++) acc.push(acc[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
  const L = acc[acc.length - 1], huts = o.hutIdx.map((i) => acc[i * 40] / L);
  const at = (t) => { const d = Math.min(1, Math.max(0, t)) * L; let i = 1; while (i < acc.length - 1 && acc[i] < d) i++; const k = (d - acc[i - 1]) / (acc[i] - acc[i - 1] || 1);
    return [pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * k, pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * k]; };
  const seg = Array.from({ length: 65 }, (_, i) => at(i / 64));
  const dist = (x, z) => { let m = 1e9; for (let i = 0; i < 64; i++) { const [ax, az] = seg[i], dx = seg[i + 1][0] - ax, dz = seg[i + 1][1] - az;
    const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz))); m = Math.min(m, Math.hypot(x - ax - dx * t, z - az - dz * t)); } return m; };
  const carved = (x, z) => o.height * shape(x, z) / top * (1 - o.carve[0] * Math.exp(-((dist(x, z) / o.carve[1]) ** 2)));
  const tarns = huts.map((t, i) => { const [x, z] = at(t), [x2, z2] = at(t + 0.004), [x0, z0] = at(t - 0.004), l = Math.hypot(z0 - z2, x2 - x0) || 1, s = o.tarn[3][i] * o.tarn[0];
    const c = { x: x + (z0 - z2) / l * s, z: z + (x2 - x0) / l * s }; c.level = carved(c.x, c.z) - o.tarn[2]; return c; }); // a small flat lake beside each hut
  const H = new Float32Array(n * n), W = new Float32Array(n * n);
  for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) { const x = i * step - size / 2, z = j * step - size / 2; let h = carved(x, z);
    for (const c of tarns) { const r = Math.hypot(x - c.x, z - c.z) / o.tarn[1]; if (r < 1) { const s = Math.min(1, (1 - r) / 0.45); h += (c.level - h) * s * s * (3 - 2 * s); if (r < 0.6) W[j * n + i] = 1; } }
    H[j * n + i] = Math.max(0, h); }
  const sample = (x, z) => { const fx = Math.min(grid - 1e-6, Math.max(0, (x + size / 2) / step)), fz = Math.min(grid - 1e-6, Math.max(0, (z + size / 2) / step)), i = Math.floor(fx), j = Math.floor(fz), u = fx - i, v = fz - j;
    const a = H[j * n + i], b = H[j * n + i + 1], c = H[j * n + n + i], d = H[j * n + n + i + 1]; return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; };
  return { H, W, at, sample, huts, length: L };
}

const VERT = `attribute float aWater; varying float vH, vWater; varying vec3 vN; varying vec2 vXZ;
void main(){ vH = position.y; vN = normal; vWater = aWater; vXZ = position.xz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
// Contours are drawn per pixel from the height; fwidth() keeps them ~1px at any distance and fades minor lines where they would crowd.
const FRAG = `uniform vec3 uPaper, uWarm, uInk, uLight; uniform float uFlat, uBase, uScale, uDusk, uHalf, uMinor, uMajor;
varying float vH, vWater; varying vec3 vN; varying vec2 vXZ;
float iso(float v, float k){ float w = fwidth(v), d = abs(fract(v - 0.5) - 0.5); return 1.0 - smoothstep(w * k * 0.5, w * k * 0.5 + w, d); }
void main(){
  float m = uBase + vH * uScale;
  float lines = max(iso(m / uMinor, 1.0) * (1.0 - smoothstep(0.16, 0.36, fwidth(m / uMinor))) * 0.42, iso(m / uMajor, 1.6) * (1.0 - smoothstep(0.22, 0.45, fwidth(m / uMajor))) * 0.7);
  vec3 paper = mix(uPaper, uWarm, uDusk), col = paper * (1.0 + 0.6 * (dot(normalize(vN), uLight) - uFlat));
  col = mix(col, uInk, lines * (1.0 - vWater) * step(0.03, vH));
  col = mix(col, mix(paper * 0.94, uInk, iso(vXZ.y * 2.4, 1.0) * 0.5), vWater);
  gl_FragColor = vec4(mix(paper, col, 1.0 - smoothstep(uHalf - 10.0, uHalf, max(abs(vXZ.x), abs(vXZ.y)))), 1.0);
}`;

export function initFlight(root, C) {
  const $$ = (s) => [...root.querySelectorAll(s)], $ = (s) => root.querySelector(s), off = [];
  const on = (el, ev, fn, opt) => { el.addEventListener(ev, fn, opt); off.push(() => el.removeEventListener(ev, fn, opt)); };
  const R = makeRelief(C.relief), T = [0, ...R.huts], S = R.huts.length, reduce = matchMedia('(prefers-reduced-motion: reduce)');
  const P = (t) => { const [x, z] = R.at(t); return new THREE.Vector3(x, R.sample(x, z) + C.lift, z); };
  // elevation profile, SVG viewBox 0 0 1000 120
  const NP = 240, alt = Array.from({ length: NP + 1 }, (_, i) => C.relief.base + R.sample(...R.at(i / NP)) * C.relief.vscale), lo = Math.min(...alt), hi = Math.max(...alt);
  const py = (m) => 112 - (m - lo) / (hi - lo) * 96, line = alt.map((m, i) => `${i / NP * 1000},${py(m).toFixed(1)}`).join('L');
  $$('[data-profile-line]').forEach((el) => el.setAttribute('d', 'M' + line));
  $$('[data-profile-area]').forEach((el) => el.setAttribute('d', 'M0,120L' + line + 'L1000,120Z'));
  const pin = (node, t, m) => { node.style.setProperty('--x', t.toFixed(4)); node.style.setProperty('--y', (py(m) / 120).toFixed(4)); }; // HTML marks over the stretched SVG
  $$('[data-profile-tick]').forEach((el, k) => pin(el, R.huts[k], alt[Math.round(R.huts[k] * NP)]));
  const clip = $('[data-profile-clip]'), dot = $('[data-profile-dot]'), km = $('[data-readout-km]'), el = $('[data-readout-alt]'), links = $$('[data-stage-link]'), labels = $$('[data-hut-label]'), marker = $('[data-flight-marker]');
  // timeline: each [data-f] chapter pins value F when its centre meets the viewport centre; 0 overview · 1..S+1 stages · S+2 dusk
  let mids = [];
  const measure = () => { mids = $$('[data-f]').map((c) => { const r = c.getBoundingClientRect(); return [r.top + scrollY + r.height / 2 - innerHeight / 2, +c.dataset.f]; }); };
  const goal = () => { const y = scrollY; if (reduce.matches) return mids.reduce((b, m) => (Math.abs(m[0] - y) < Math.abs(b[0] - y) ? m : b))[1];
    for (let i = 1; i < mids.length; i++) if (y < mids[i][0]) return y <= mids[0][0] ? mids[0][1] : mids[i - 1][1] + (mids[i][1] - mids[i - 1][1]) * (y - mids[i - 1][0]) / (mids[i][0] - mids[i - 1][0]);
    return mids[mids.length - 1][1]; };
  const routeT = (F) => { const k = Math.min(S, Math.max(1, Math.floor(F))); return F <= 1 ? 0 : F >= S + 1 ? 1 : T[k - 1] + (F - k) * (T[k] - T[k - 1]); };
  const ui = (F, t) => { const k = F >= 1 && F < S + 1 ? Math.floor(F) : 0, i = Math.round(t * NP);
    links.forEach((a) => { const s = +a.dataset.stageLink; s === k ? a.setAttribute('aria-current', 'step') : a.removeAttribute('aria-current'); a.classList.toggle('is-done', F >= s + 1); });
    clip?.setAttribute('width', t * 1000); if (dot) pin(dot, t, alt[i]);
    if (km) km.textContent = (t * R.length * C.kmPerUnit).toFixed(1); if (el) el.textContent = (Math.round(alt[i] / 10) * 10).toLocaleString('en-US'); };
  root.classList.remove('is-flat', 'is-3d'); let renderer = null;
  try { if (document.createElement('canvas').getContext('webgl2')) renderer = new THREE.WebGLRenderer({ antialias: true }); } catch (e) { renderer = null; }
  if (!renderer) { // no WebGL: poster stays, index and profile still follow the scroll
    root.classList.add('is-flat'); const flat = () => { const F = goal(); ui(F, routeT(F)); };
    on(window, 'scroll', flat, { passive: true }); on(window, 'resize', () => { measure(); flat(); }); measure(); flat(); return () => { off.forEach((f) => f()); root.classList.remove('is-flat'); }; }
  renderer.outputColorSpace = THREE.LinearSRGBColorSpace; // colours are read as linear: CSS hex in = same pixel out, global ColorManagement untouched
  const col = (h) => new THREE.Color().setStyle(h, THREE.LinearSRGBColorSpace), mount = $('[data-flight-stage]'), scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(C.fov, 1, 0.5, 400);
  mount.appendChild(renderer.domElement);
  const g = new THREE.PlaneGeometry(C.relief.size, C.relief.size, C.relief.grid, C.relief.grid); g.rotateX(-Math.PI / 2);
  for (let i = 0; i < R.H.length; i++) g.attributes.position.setY(i, R.H[i]); g.computeVertexNormals(); g.setAttribute('aWater', new THREE.BufferAttribute(R.W, 1));
  const sun = new THREE.Vector3(...C.light).normalize(), dusk = new THREE.Vector3(...C.duskLight).normalize(), paper = col(C.colors.paper), warm = col(C.colors.warm), clear = new THREE.Color();
  const U = { uPaper: { value: paper }, uWarm: { value: warm }, uInk: { value: col(C.colors.ink) }, uLight: { value: sun.clone() }, uFlat: { value: sun.y }, uBase: { value: C.relief.base },
    uScale: { value: C.relief.vscale }, uDusk: { value: 0 }, uHalf: { value: C.relief.size / 2 }, uMinor: { value: C.contours[0] }, uMajor: { value: C.contours[1] } };
  scene.add(new THREE.Mesh(g, new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: FRAG })));
  const NL = 400, flat = []; for (let i = 0; i <= NL; i++) flat.push(...P(i / NL).toArray());
  const path = (m) => { const lg = new LineGeometry(); lg.setPositions(flat); const l = new Line2(lg, m); l.computeLineDistances(); scene.add(l); return l; };
  const ghost = path(new LineMaterial({ color: col(C.colors.ink), dashed: true, dashSize: 1.1, gapSize: 0.8, transparent: true, opacity: 0.8 })), drawn = path(new LineMaterial({ color: col(C.colors.accent) }));
  const huts = R.huts.map((t) => P(t).setY(P(t).y + 0.6));
  // camera: overview -> chase along the route -> dusk overview
  const V = (a) => new THREE.Vector3(...a), ov = [V(C.poses.overview[0]), V(C.poses.overview[1])], fin = [V(C.poses.final[0]), V(C.poses.final[1])];
  const chase = (t) => { const c = P(t), d = P(t + 0.012).sub(P(t - 0.012)).setY(0).normalize(), p = c.clone().addScaledVector(d, -C.chase.back).add(new THREE.Vector3(-d.z, 0, d.x).multiplyScalar(C.chase.side));
    p.y = Math.max(c.y + C.chase.up, R.sample(p.x, p.z) + C.chase.clear); return [p, P(t + C.chase.ahead)]; };
  const ease = (x) => x * x * x * (x * (x * 6 - 15) + 10), mix = (a, b, k) => [a[0].clone().lerp(b[0], k), a[1].clone().lerp(b[1], k)];
  const pose = (F) => (F <= 1 ? mix(ov, chase(0), ease(Math.max(0, F))) : F < S + 1 ? chase(routeT(F)) : mix(chase(1), fin, ease(Math.min(1, F - S - 1))));
  let w = 1, h = 1, fit = 1, F = 0, dirty = true, t0 = performance.now(), last = t0, raf = 0, intro = reduce.matches ? 0 : 1;
  const resize = () => { w = mount.clientWidth; h = mount.clientHeight; const dpr = Math.min(devicePixelRatio, w < 768 ? 1.5 : 2); renderer.setPixelRatio(dpr); renderer.setSize(w, h, false);
    const cs = getComputedStyle(mount), ox = parseFloat(cs.getPropertyValue('--flight-offset-x')) || 0, oy = parseFloat(cs.getPropertyValue('--flight-offset-y')) || 0;
    camera.aspect = w / h; camera.setViewOffset(w, h, ox * w, oy * h, w, h); fit = camera.aspect < 1 ? Math.pow(1 / camera.aspect, C.fit) : 1; // optical centre follows the free area of the layout; portrait steps back
    drawn.material.linewidth = C.lineWidth[0]; ghost.material.linewidth = C.lineWidth[1]; measure(); dirty = true; }; // Line2 widths are CSS px
  const v = new THREE.Vector3(), side = new THREE.Vector3();
  const place = (node, p) => { v.copy(p).project(camera); const vis = v.z < 1 && Math.abs(v.x) < 1.05 && Math.abs(v.y) < 1.05; node.classList.toggle('is-off', !vis);
    if (vis) node.style.transform = `translate3d(${((v.x + 1) / 2 * w).toFixed(1)}px,${((1 - v.y) / 2 * h).toFixed(1)}px,0)`; };
  const frame = (now) => { raf = requestAnimationFrame(frame); const dt = Math.min(0.1, (now - last) / 1000); last = now;
    const target = goal(), nF = reduce.matches ? target : F + (target - F) * (1 - Math.exp(-dt * C.damping)); // damping smooths wheel steps
    if (Math.abs(nF - F) > 1e-5) dirty = true; F = Math.abs(target - nF) < 1e-4 ? target : nF;
    if (intro > 0) { intro = Math.max(0, 1 - (now - t0) / C.introMs); dirty = true; }
    if (!dirty) return; dirty = false;
    const [p, l] = pose(F), pull = 1 + C.introPull * (1 - Math.pow(1 - intro, 3)); p.sub(l).multiplyScalar(fit * pull).add(l);
    camera.position.copy(p); camera.lookAt(l); camera.updateMatrixWorld();
    const dk = F > S + 1 ? ease(Math.min(1, F - S - 1)) : 0, t = routeT(F); U.uDusk.value = dk; U.uLight.value.copy(sun).lerp(dusk, dk).normalize(); U.uFlat.value = U.uLight.value.y;
    renderer.setClearColor(clear.copy(paper).lerp(warm, dk)); drawn.geometry.instanceCount = Math.round(t * NL);
    labels.forEach((n, i) => { place(n, huts[i]); if (F > 1.2 && F < S + 0.8 && huts[i].distanceTo(p) > C.labelRange) n.classList.add('is-off'); n.classList.toggle('is-reached', t >= R.huts[i] - 1e-4); });
    if (marker) { place(marker, P(t)); if (F < 1 || F > S + 1) marker.classList.add('is-off'); }
    const b = side.setFromMatrixColumn(camera.matrixWorld, 2); root.style.setProperty('--flight-north', `${(Math.atan2(b.x, b.z) * 180 / Math.PI).toFixed(1)}deg`); // compass: rotate an arrow by this
    ui(F, t); renderer.render(scene, camera); };
  on(window, 'resize', resize); on(reduce, 'change', () => { intro = 0; dirty = true; });
  let alive = true; document.fonts?.ready.then(() => { if (alive) { measure(); dirty = true; } });
  resize(); raf = requestAnimationFrame(frame); root.classList.add('is-3d');
  return () => { alive = false; cancelAnimationFrame(raf); off.forEach((f) => f()); scene.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); }); renderer.dispose(); renderer.domElement.remove(); root.classList.remove('is-3d', 'is-flat'); };
}
```

3. **Configuration** (exact; the terrain and every figure on the page depend on it):

```js
const CONFIG = {
  relief: { seed: 77, size: 100, grid: 256, height: 15, freq: 0.045, ridge: 0.5, env: [54, 36, 0.25, 0.1], envPow: 0.45, power: 0.9,
    route: [[-41, 29], [-35, 27], [-29, 24], [-24, 15], [-19, 7], [-15, -3], [-9, -15], [0, -17], [9, -13], [17, -12], [24, -8], [21, 0], [13, 4], [6, 7]],
    hutIdx: [3, 6, 10, 13], carve: [0.3, 3], tarn: [1.8, 2.4, 0.12, [1, 1, 1, 1]], base: 1265, vscale: 160 },
  kmPerUnit: 0.4, lift: 0.35, fov: 38, contours: [50, 250],
  colors: { paper: '#EEE7D6', warm: '#EBD9BE', ink: '#1E2B25', accent: '#C9432B' },
  light: [-1, 1.3, -1], duskLight: [-1, 0.45, 0.2],
  poses: { overview: [[4, 148, 72], [4, 0, 4]], final: [[-34, 78, 100], [4, 0, -4]] }, fit: 0.9,
  chase: { back: 16, up: 10, side: 3, clear: 4, ahead: 0.045 },
  damping: 5, introMs: 2400, introPull: 0.12, lineWidth: [3.5, 2], labelRange: 40
};
```

4. **Fallbacks.** Without WebGL the engine adds `.is-flat` to `#tw` and draws no canvas: the poster stays, and the index, profile and readouts keep following the scroll. If the modules cannot load, the poster, the chapters and every link still work (the classic menu script does not depend on the module).
5. **Menu** (classic inline `<script>`, after the module): the `.tw-menu-btn` click calls `showModal()` on `#tw-menu` and sets `aria-expanded="true"`; `.tw-menu-close` and any link inside the dialog call `close()`; on the dialog's `close` event set `aria-expanded="false"` and focus `.tw-menu-btn`. Escape closes it natively, and the native modal makes the page behind inert. When `(min-width:1024px)` starts matching while it is open, close it.
6. Index links, nav links and CTAs are plain anchors: `html{scroll-behavior:smooth}` animates them (instant with reduced motion). The engine marks the active stage link with `aria-current="step"` and completed ones with `.is-done`.

## 9. Responsive

| Range | Token overrides | Layout changes |
|---|---|---|
| ≥1920px | `--tn-pad:56px; --tn-side:300px; --tn-card-w:440px; --tn-hero-w:600px; --tn-h1:72px; --tn-h2:46px; --tn-band:124px` | — |
| 1280–1919px | — | — |
| 1024–1279px | `--tn-pad:28px; --tn-side:236px; --tn-card-w:360px; --tn-hero-w:440px; --tn-h1:48px; --tn-h2:34px; --tn-title:22px` | — |
| <1024px (mobile layout) | `--tn-pad:16px; --tn-top:60px; --tn-band:92px; --tn-h1:40px; --tn-h2:30px; --tn-title:22px` | everything below |
| ≤400px | `--tn-h1:34px` | `.tw-ctas{flex-direction:column;align-items:flex-start}`; `.tw-btn{align-self:stretch}` |

Mobile layout (<1024px):
- Header: `.tw-nav` and `.tw-head-cta` hidden; `.tw-menu-btn` shown.
- `.tw-stage{--flight-offset-x:0;--flight-offset-y:.12}` (the massif sits in the upper part, above the cards).
- `.tw-side` becomes a row of stage chips: `position:fixed; top:var(--tn-top); left:16px; right:16px; bottom:auto; width:auto; display:block`. `.tw-index{padding:0;border:0;background:none}`; hide its `.tw-kicker`, `.tw-meta`, the `.tw-name>span` (" Hut") and the `.tw-name::before` square; `ol{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:0}`; each link: `display:flex; justify-content:center; align-items:center; gap:6px; height:36px; padding:0 4px; border:1px solid var(--tn-edge); background:var(--tn-card)`; `.tw-num` Display at 12px ink; `.tw-name` Label at 10px with `font-stretch:100%; letter-spacing:.04em`; active: no `::after` bar, `box-shadow:inset 0 -3px 0 var(--tn-accent)`; done: `.tw-num` muted.
- `.tw-legend`: `position:absolute; right:0; top:48px; padding:6px` (inside `.tw-side`, under the chips); hide its `p` (compass only).
- Chapters: `align-items:flex-end; padding-left:16px; padding-right:16px; padding-bottom:calc(20svh + var(--tn-band) + 16px)` (longhands: each chapter keeps its padding-top). Hero and end: `min-height:calc(100svh + 120px); padding-bottom:calc(var(--tn-band) + 16px)`, so at the top of the page the map shows above the hero card (kicker and h1 visible) and the card finishes rising as you scroll. Cards `width:100%`, padding `18px 18px 20px` (hero and end `22px 20px`).
- `.tw-stats{gap:0 12px}`, `dd` 16px; `.tw-blocks` one column; `.tw-lede` 16px.
- Band: `grid-template-columns:1fr; grid-template-rows:auto 1fr; row-gap:4px; padding:10px 16px 12px`; `.tw-read{flex-wrap:nowrap}` with its kicker `flex-basis:auto` (kicker, km, m on one line), `b` 16px; `.tw-plot` 44px high. Hut labels: `.tw-hut small{display:none}`.

Touch: no hover-only actions; the page scroll drives the map (no gesture captured). No horizontal scroll at **375 · 768 · 1440 · 1920** px.

## 10. Accessibility & performance

- One `<h1>`; each stage card has its `<h2>`; the arrival card has an `<h2>` and two `<h3>`.
- Landmarks: `header`, `nav` "Primary", `nav` "Route stages", `main`; the profile is a `figure` with `role="img"` and its label; the dialog has `aria-label="Menu"`.
- Decorative or duplicated layers are `aria-hidden="true"`: poster, canvas, hut labels, marker, compass, profile SVG. The index carries the same information as text.
- Contrast: ink on paper 11.9:1, muted on paper 5.9:1, text on cards ≥ 6.3:1; the accent is never text.
- The engine caps the pixel ratio, renders only on change and builds the terrain once (≈0.3–0.5 s, covered by the poster).

## 11. Acceptance checklist

- [ ] **First paint:** paper and the hero card box (and the poster once loaded), never blank; header, index and band have faded in by 2.4 s.
- [ ] **Entrance:** the map fades in while the camera settles closer; the two h1 lines rise at 0.45s and 0.6s.
- [ ] **Overview:** whole massif and four hut labels visible, between index and hero card (desktop) or above the hero card (mobile); compass tip up.
- [ ] **Flight:** through stages 01–04 the camera follows the dashed trail, the vermilion line reaches the marker, the active row/chip moves 01 → 04 with earlier ones done, the profile fills to its dot; stage 02 card centred → 16.1 km.
- [ ] **Arrival:** warm dusk view, whole line drawn, every hut square filled.
- [ ] **Links:** index links and "Follow the route ↓" reach their stage; nav reaches `#included` and `#season`.
- [ ] **Menu (<1024px):** modal dialog; Escape and Close shut it; focus returns to Menu.
- [ ] **Responsive:** no horizontal scroll at 375, 768, 1440, 1920.
- [ ] **Reduced motion:** no entrance; the camera cuts between chapter views.
- [ ] **No WebGL:** poster visible; index, profile and readouts follow the scroll.
- [ ] Zero console errors; only the import map and the URLs of sections 2–3.
Volver a las plantillas