0

Saltar al contenido

Dirección de arte, lista para tu IA.

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

TORSION

Escultura 3D guiada por el scroll

Qué incluye

Prompts
HTML · 589 líneas / React · 587 líneas
Medios
Escena WebGL · Modelo 3D (GLB)
Alojamiento
cdn.orbital.supply, ya incluido en el prompt
Tipografías
Barlow Condensed + Manrope · Google Fonts
HTML
Un archivo · Three.js 0.185.0
React
React 19.3.0 · Three.js 0.185.0 · Tailwind 4.3.3 · Vite 8.3.1 · TypeScript 6.0.3
Movimiento
scroll-camera, mask-reveal, light-transition

Probada y documentada

01 de octubre de 2026

  • Reproducida en Claude
  • Reconstruida a ciegas desde el prompt: HTML y React
  • Revisada a 375, 390, 768, 1024, 1280, 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 · Vite587 líneas
Abrir el texto completo
# Build: "TORSION" — a scroll-driven exhibition of one aluminium sculpture

## 0. Brief

TORSION is a fictional collectible-design study, Edition 01: one thick aluminium ribbon folding around a large open centre, shown as a moving exhibition in four acts. Native scroll drives one hosted 3D model through four camera compositions and light states — ivory, near-black, wine red, ivory — while giant condensed type and the object trade prominence. Only camera and light move. The "wow": the first scroll orbits the slender metal edge, crossing a huge TORSION wordmark, open into a monumental loop as the room falls dark.

## 1. Output & hard constraints

**Target: react.** `react@19.3.0`, `react-dom@19.3.0`, `three@0.185.0`, `tailwindcss@4.3.3`, `@tailwindcss/vite@4.3.3`; dev `vite@8.3.1`, `@vitejs/plugin-react@6.1.1`, `typescript@6.0.3`, `@types/three@0.185.4`, `@types/react@19.3.0`, `@types/react-dom@19.3.0`, `@types/node@24.13.3`. Nothing else; only addon `three/addons/loaders/GLTFLoader.js`. `vite.config.ts`: `plugins: [react(), tailwindcss()]`, `build: { chunkSizeWarningLimit: 800 }`. `build` = `tsc -b && vite build` (strict TS) must pass. Files: `index.html` (8.1 script, preconnects to fonts.googleapis.com, fonts.gstatic.com, cdn.orbital.supply; `<body style="background:#EEECE5;color:#111210">`, `#root`), Vite's standard three tsconfig files, `src/{main,App}.tsx`, `src/index.css`, `src/content.ts`, `src/components/{Header,Stage,Chapter,Dialogs,Icons}.tsx`, `src/torsion/{config,math,engine,controller}.ts`.
Both targets: title `TORSION — Edition 01, a study in aluminium`, `lang="en"`, description `TORSION is a fictional design study: one continuous aluminium ribbon, explored in four acts — Form, Space, Surface and Edition.`, `theme-color #EEECE5`, viewport `width=device-width, initial-scale=1, viewport-fit=cover`. One scrolling page: a tall track with a sticky full-viewport stage; the acts are states of one scene.
- Do NOT add: prices, checkout, forms, testimonials, extra sections, a second object, auto-rotation, OrbitControls, scroll snapping or hijacking, smooth-scroll libraries, particles, bloom, audio, custom cursor, or claims of price, quantity, artist or availability.
- Use the section 2 URLs verbatim; no decoders (none needed). Anti-flash: `html`/`body` background `#EEECE5`, text `#111210`.
- Serve over HTTP(S) (`cdn.orbital.supply` sends `Access-Control-Allow-Origin: *`); not `file://`. Zero console errors; only failure paths warn (`[torsion] 3D unavailable: …`, `[torsion] WebGL context lost`).

## 2. Assets (exact URLs — visual resources; code dependencies are declared in section 1)

| ID | Type | URL | Role | Load |
|---|---|---|---|---|
| MODEL | model/gltf-binary | `https://cdn.orbital.supply/exp-0006/torsion-v1.glb` | The sculpture: 2,994,648 B, one mesh, 145,506 tris, `KHR_mesh_quantization`, no textures; loop faces raw +X; flat foot | prefetch → `parseAsync` |
| POSTER_<ACT>_DESKTOP · _MOBILE | image/webp | `https://cdn.orbital.supply/exp-0006/form-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/form-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/space-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/space-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/surface-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/surface-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/edition-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/edition-mobile-v1.webp` | Flow-page image per act (1600×1000 · 780×1688) | lazy |
| LOADING_POSTERS | image/webp (alpha) | `https://cdn.orbital.supply/exp-0006/loading-form-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-form-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-space-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-space-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-surface-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-surface-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-edition-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-edition-mobile-v1.webp` | 3D loading state: the model at that act's camera, transparent, at canvas depth | script |


## 3. Typography

`https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@600;700&family=Manrope:wght@400;500;600&display=swap` (preconnect fonts.googleapis.com and fonts.gstatic.com). Barlow Condensed 700 (`--display`): wordmarks, headings, brand, menu links, dialog title. Manrope 400/500/600 (`--body`): everything else. Stacks in section 6.

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

The `:root` block of section 6 (ivory, ink, near-black, wine for Act 03 only, warm white on dark; 12 columns; gutters 48/32/20px). Script rewrites `--hdr-bg` (`r g b`) and `--hdr-fg`.

## 5. Layer stack & DOM

Stage back → front: `.bg` (ivory/black/red light fields) → `.wordmark-3d` (behind the sculpture) → `.scene-host` (z 2): the controller appends a fresh `<canvas class="scene-canvas" aria-hidden="true">` per 3D attempt → `.stage-poster` → `.scrim` → `.chapters` → `.progress` → `#scene-status` (z-indices in section 6). The 1px anchors `#form/#space/#surface/#edition` sit in `.track` at p = 0/.35/.66/.96 so native hash loads land correctly; **no other element may reuse those ids**.

React differences: `<div class="scene-host" aria-hidden="true">` replaces `<canvas id="scene">`; `#scene-description` uses `sr-only`; `<dt>/<dd>` pairs sit in `<div class="contents">`; the footer `<p>` uses `m-0`; listeners are props; the status content is React state.

Exact `<body>` markup before the scripts (ids, classes, data attributes load-bearing). `{POSTER x}` = `<picture><source media="(max-width: 767px)" srcset="POSTER_X_MOBILE"><img src="POSTER_X_DESKTOP" alt="…" width="1600" height="1000" loading="lazy"></picture>` with the URLs of section 2 and these alts — form: "The aluminium ribbon seen almost edge-on: a tall, slender diagonal blade on an ivory ground."; space: "The whole sculpture against near-black: an asymmetric open loop with a broad white reflection across its face."; surface: "A close view of the inner curve in deep wine-red light, metal running past the edges of the frame."; edition: "The complete sculpture standing on a low plinth on an ivory ground.". `{icon:x}` = `<svg viewBox="0 0 S S" aria-hidden="true"><path d="…" stroke="currentColor" stroke-width="W" fill="none"/></svg>`, listed as S/W/d: menu 18/1.4/`M2 6h14M2 12h14`; down 14/1.3/`M7 1v11M2.5 7.5 7 12l4.5-4.5`; plus 14/1.3/`M7 1.5v11M1.5 7h11`; up 12/1.2/`M6 11V1.5M2 5.5 6 1.5l4 4`; cue 12/1.2/`M6 1v9.5M2 6.5l4 4 4-4` (no aria-hidden); close 16/1.3/`M3 3l10 10M13 3 3 13` (no fill attr).

```html
<a class="skip" href="#chapter-form">Skip to content</a>
<header class="site-header">
<a class="brand chip" href="#form" data-nav="form" aria-label="TORSION, back to the opening">Torsion</a>
<nav class="nav chip" aria-label="Chapters">
<a href="#form" data-nav="form"><span class="n">01</span>Form</a>
<a href="#space" data-nav="space"><span class="n">02</span>Space</a>
<a href="#surface" data-nav="surface"><span class="n">03</span>Surface</a>
<a href="#edition" data-nav="edition"><span class="n">04</span>Edition</a>
</nav>
<button class="menu-btn chip" type="button" aria-haspopup="dialog" aria-controls="menu">
Menu
{icon:menu}
</button>
</header>
<main id="main">
<div class="track" id="track">
<a class="anchor" id="form" style="--p:0" tabindex="-1" aria-hidden="true"></a>
<a class="anchor" id="space" style="--p:.35" tabindex="-1" aria-hidden="true"></a>
<a class="anchor" id="surface" style="--p:.66" tabindex="-1" aria-hidden="true"></a>
<a class="anchor" id="edition" style="--p:.96" tabindex="-1" aria-hidden="true"></a>
<div class="stage" id="stage">
<div class="scene-status" id="scene-status" role="status" aria-live="polite"></div>
<p class="flow-wordmark" aria-hidden="true">Torsion</p>
<div class="bg" aria-hidden="true">
<div class="bg-ivory"></div>
<div class="bg-black"></div>
<div class="bg-red"></div>
</div>
<p class="wordmark-3d" aria-hidden="true"><span>Torsion</span></p>
<canvas id="scene" aria-hidden="true"></canvas>
<img class="stage-poster" alt="" aria-hidden="true" decoding="async">
<div class="scrim" aria-hidden="true"></div>
<p class="visually-hidden" id="scene-description">An interactive digital sculpture: one continuous, thick aluminium ribbon that folds around a large open centre and rests on a low plinth. Scrolling moves the camera from the ribbon’s slender edge, around to its open loop, close to its inner curve, and back to the complete object.</p>
<div class="chapters">
<section class="chapter chapter--form" id="chapter-form" data-i="0" aria-labelledby="h-form">
<figure class="flow-poster">{POSTER form}</figure>
<div class="copy">
<p class="eyebrow">Edition 01 — A study in aluminium</p>
<h1 class="display" id="h-form"><span class="line"><span>Form under</span></span> <span class="line"><span>pressure.</span></span></h1>
<p class="support">One continuous ribbon. A different presence from every angle.</p>
<div class="actions">
<a class="btn btn--ink" href="#space" data-nav="space">Discover the form
{icon:down}</a>
</div>
</div>
</section>
<section class="chapter chapter--space" id="chapter-space" data-i="1" aria-labelledby="h-space">
<figure class="flow-poster">{POSTER space}</figure>
<div class="copy">
<p class="eyebrow">02 / Space</p>
<h2 class="display" id="h-space"><span class="line"><span>Strength takes</span></span> <span class="line"><span>a different</span></span> <span class="line"><span>shape.</span></span></h2>
<p class="support">A fold gives the ribbon its tension. The space inside gives it room to breathe.</p>
</div>
</section>
<section class="chapter chapter--surface" id="chapter-surface" data-i="2" aria-labelledby="h-surface">
<figure class="flow-poster">{POSTER surface}</figure>
<div class="copy">
<p class="eyebrow">03 / Surface</p>
<h2 class="display" id="h-surface"><span class="line"><span>Edges hold light.</span></span></h2>
<p class="support">Soft reflections travel across a surface that stays still.</p>
<ul class="notes" aria-label="Material notes">
<li>Brushed aluminium</li><li>Rounded edge</li><li>Continuous form</li>
</ul>
</div>
</section>
<section class="chapter chapter--edition" id="chapter-edition" data-i="3" aria-labelledby="h-edition">
<figure class="flow-poster">{POSTER edition}</figure>
<div class="copy">
<p class="eyebrow">04 / Edition 01</p>
<h2 class="display" id="h-edition"><span class="line"><span>One</span></span> <span class="line"><span>continuous</span></span> <span class="line"><span>gesture.</span></span></h2>
<p class="support">An exploration of tension, reflection and the space between.</p>
<div class="actions">
<button class="btn btn--ink" type="button" data-open-edition aria-haspopup="dialog" aria-controls="edition-details">Explore Edition 01
{icon:plus}</button>
<a class="link" href="#form" data-nav="form">Replay the study
{icon:up}</a>
</div>
<p class="closing-inline" aria-hidden="true">TORSION — A fictional design study.</p>
</div>
</section>
</div>
<div class="progress" aria-hidden="true">
<span class="label"><span class="num">01</span><span class="name">Form</span></span>
<span class="rule"><span class="fill"></span></span>
<span class="aside">
<span class="cue">Scroll to explore
{icon:cue}</span>
<span class="closing-note">TORSION — A fictional design study.</span>
</span>
</div>
</div>
</div>
</main>
<footer class="site-footer"><p style="margin:0">TORSION — A fictional design study.</p></footer>
<dialog class="sheet" id="edition-details" aria-labelledby="edition-title">
<div class="sheet-top"><span>TORSION</span>
<button class="icon-btn" type="button" data-close aria-label="Close details">
{icon:close}</button>
</div>
<h2 id="edition-title">Edition 01</h2>
<p class="intro">A continuous ribbon, shaped around an open centre.</p>
<dl>
<dt>Collection</dt><dd>TORSION</dd>
<dt>Material study</dt><dd>Brushed aluminium</dd>
<dt>Form</dt><dd>Continuous ribbon</dd>
<dt>Project</dt><dd>Digital sculpture concept</dd>
</dl>
<p class="disclosure">TORSION is a fictional design study. The sculpture is a digital concept and is not available to purchase.</p>
<button class="btn btn--ink" type="button" data-close>Close details</button>
</dialog>
<dialog class="menu-sheet" id="menu" aria-label="Chapters">
<div class="sheet-top"><span class="brand" style="padding:0">Torsion</span>
<button class="icon-btn" type="button" data-close>Close
{icon:close}</button>
</div>
<nav aria-label="Chapters">
<ol>
<li><a href="#form" data-nav="form"><span class="n">01</span>Form</a></li>
<li><a href="#space" data-nav="space"><span class="n">02</span>Space</a></li>
<li><a href="#surface" data-nav="surface"><span class="n">03</span>Surface</a></li>
<li><a href="#edition" data-nav="edition"><span class="n">04</span>Edition</a></li>
</ol>
</nav>
<p class="foot">TORSION — A fictional design study.</p>
</dialog>
```

## 6. Components

`src/index.css`: the Google Fonts `@import url(…)` of section 3, `@import "tailwindcss";`, `@theme` with `--color-ivory/ink/black/wine/warm` and `--font-display/--font-body` (same values), then this CSS verbatim (unlayered):

```css
:root{--ivory:#EEECE5;--ink:#111210;--black:#080909;--wine:#4A111B;--warm:#F7F3E9;--muted-ink:#4b4c47;--muted-warm:#d9d2c6;--display:"Barlow Condensed","Arial Narrow","Roboto Condensed","Helvetica Neue",Arial,sans-serif;--body:"Manrope","Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;--gutter:48px;--gap:24px;--col:calc((100vw - 2 * var(--gutter) - 11 * var(--gap)) / 12);--ease:cubic-bezier(.2,.7,.2,1);--dur:200ms;--track-h:620svh;--hdr-bg:238 236 229;--hdr-fg:var(--ink);--safe-b:env(safe-area-inset-bottom,0px);--safe-t:env(safe-area-inset-top,0px)}
@media (max-width:1023px){
  :root{--gutter:32px}
}
@media (max-width:767px){
  :root{--gutter:20px;--gap:16px}
}
@media (max-width:767px),(max-width:1023px) and (orientation:portrait){
  :root{--track-h:500svh}
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%;background:var(--ivory);scrollbar-gutter:stable}
.mode-flow .chip{background:rgb(238 236 229 / .94)}
html.mode-3d{scroll-behavior:smooth}
body{margin:0;background:var(--ivory);color:var(--ink);font:400 17px/1.55 var(--body);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;overflow-x:clip}
html.is-locked{overflow:hidden}
img{display:block;max-width:100%}
a{color:inherit}
button{font:inherit;color:inherit}
:focus{outline:none}
:focus-visible{outline:2px solid currentColor;outline-offset:3px}
.skip{position:fixed;left:var(--gutter);top:8px;z-index:60;padding:12px 16px;background:var(--ink);color:var(--warm);font:600 14px/1 var(--body);text-decoration:none;transform:translateY(-160%);transition:transform var(--dur) var(--ease)}
.skip:focus-visible{transform:none}
.site-header{position:fixed;inset:0 0 auto 0;z-index:40;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:calc(14px + var(--safe-t)) calc(var(--gutter) - 12px) 0;color:var(--hdr-fg);pointer-events:none;transition:color var(--dur) var(--ease)}
.site-header > *{pointer-events:auto}
.chip{display:inline-flex;align-items:center;min-height:44px;padding:0 12px;background:rgb(var(--hdr-bg) / .66);-webkit-backdrop-filter:blur(10px) saturate(1.1);backdrop-filter:blur(10px) saturate(1.1)}
.brand{font:700 20px/1 var(--display);letter-spacing:.2em;text-decoration:none;text-transform:uppercase;padding-right:9px}
.nav{display:flex;align-items:center;padding:0 4px}
.nav a{position:relative;display:inline-flex;align-items:center;gap:6px;min-height:44px;padding:0 10px;font:500 13px/1 var(--body);letter-spacing:.04em;text-decoration:none}
.nav a .n{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.nav a::after{content:"";position:absolute;left:10px;right:10px;bottom:11px;height:1px;background:currentColor;transform:scaleX(0);transform-origin:left;transition:transform var(--dur) var(--ease)}
.nav a:hover::after,.nav a[aria-current="true"]::after{transform:scaleX(1)}
.menu-btn{display:none;gap:10px;border:0;cursor:pointer;font:500 14px/1 var(--body);letter-spacing:.04em}
.menu-btn svg{width:18px;height:18px}
@media (max-width:767px){
  .nav{display:none}
  .menu-btn{display:inline-flex}
  .brand{font-size:18px}
}
.eyebrow{margin:0 0 18px;font:600 13px/1.3 var(--body);letter-spacing:.16em;text-transform:uppercase}
.display{margin:0;font-family:var(--display);font-weight:700;letter-spacing:-.005em;line-height:.9;text-wrap:balance}
.display .line{display:block}
h1.display{font-size:clamp(64px,7.3vw,132px)}
h2.display{font-size:clamp(56px,6.6vw,124px)}
.support{margin:22px 0 0;max-width:31ch;font-size:18px;line-height:1.5}
.actions{display:flex;flex-wrap:wrap;align-items:center;gap:12px 28px;margin-top:30px}
.btn{display:inline-flex;align-items:center;gap:14px;min-height:48px;padding:0 20px;border:1px solid currentColor;background:transparent;cursor:pointer;text-decoration:none;font:500 14px/1 var(--body);letter-spacing:.02em;transition:background-color var(--dur) var(--ease),color var(--dur) var(--ease)}
.btn svg{width:14px;height:14px;transition:transform var(--dur) var(--ease)}
.btn:hover svg{transform:translate(2px,2px)}
.btn--ink{color:var(--ink);background:var(--ivory)}
.btn--ink:hover{background:var(--ink);color:var(--ivory)}
.link{display:inline-flex;align-items:center;gap:10px;min-height:44px;font:500 14px/1 var(--body);text-decoration:none;background:none;border:0;padding:0;cursor:pointer;background-image:linear-gradient(currentColor,currentColor);background-size:100% 1px;background-repeat:no-repeat;background-position:0 calc(50% + 11px)}
.link svg{width:12px;height:12px}
.notes{display:flex;flex-wrap:wrap;gap:6px 22px;margin:0;padding:14px 0 0;list-style:none;font:500 13px/1.4 var(--body);letter-spacing:.12em;text-transform:uppercase;border-top:1px solid currentColor}
.mode-flow .track{height:auto}
.mode-flow .stage{position:relative;height:auto}
.mode-flow .bg,.mode-flow .scene-host,.mode-flow .stage-poster,.mode-flow .progress,.mode-flow .anchor,.mode-flow .wordmark-3d{display:none}
.mode-flow .chapters{position:static}
.mode-flow .chapter{position:relative;display:grid;grid-template-columns:repeat(12,minmax(0,1fr));column-gap:var(--gap);align-items:end;padding:120px var(--gutter) 88px;min-height:auto}
.mode-flow .chapter > .copy{grid-column:1 / span 5;grid-row:1;position:relative;z-index:1}
.mode-flow .chapter > .flow-poster{grid-column:5 / span 8;grid-row:1;margin:0}
.mode-flow .chapter--space,.mode-flow .chapter--surface{color:var(--warm)}
.mode-flow .chapter--space{background:var(--black)}
.mode-flow .chapter--surface{background:var(--wine)}
.mode-flow .flow-wordmark{display:block;margin:0;padding:104px var(--gutter) 0;font:700 21vw/.8 var(--display);letter-spacing:-.01em;text-transform:uppercase;color:var(--ink);white-space:nowrap;overflow:hidden}
.mode-flow .chapter--form{padding-top:32px}
.mode-flow .site-footer{position:static;padding:28px var(--gutter) calc(28px + var(--safe-b));border-top:1px solid var(--ink);margin:0 var(--gutter);padding-inline:0}
.mode-flow .scene-status{position:sticky;top:calc(66px + var(--safe-t));z-index:30;translate:none;left:auto;bottom:auto;margin:0 var(--gutter);padding:10px 14px;text-align:left;background:rgb(238 236 229 / .96);color:var(--ink);border:1px solid var(--ink)}
.mode-flow .scene-status:not(:empty) + .flow-wordmark{padding-top:24px}
.mode-flow .stage:has(.scene-status:not(:empty)){padding-top:76px}
.flow-poster img{width:100%;height:auto;aspect-ratio:16 / 10;object-fit:cover}
@media (max-width:767px),(max-width:1023px) and (orientation:portrait){
  .mode-flow .chapter{grid-template-columns:1fr;padding:88px var(--gutter) 64px}
  .mode-flow .chapter > .copy,.mode-flow .chapter > .flow-poster{grid-column:1}
  .mode-flow .chapter > .flow-poster{grid-row:1;margin-bottom:28px}
  .mode-flow .chapter > .copy{grid-row:2}
  .flow-poster img{aspect-ratio:9 / 14}
}
.mode-3d .flow-poster,.mode-3d .flow-wordmark{display:none}
.track{position:relative;height:var(--track-h)}
.anchor{position:absolute;left:0;width:1px;height:1px;top:calc(var(--p) * (var(--track-h) - 100svh));scroll-margin-top:0}
.stage{position:sticky;top:0;height:100svh;overflow:visible}
.bg{position:absolute;inset:0 0 calc(100svh - 100lvh) 0;overflow:hidden;z-index:0}
.bg > div{position:absolute;inset:0}
.bg-ivory{background:radial-gradient(120% 70% at 30% 18%,rgb(255 255 252 / .55),transparent 60%),radial-gradient(70% 24% at 62% 104%,rgb(120 112 98 / .18),transparent 70%),linear-gradient(180deg,#F1EFE9 0%,#EEECE5 58%,#E6E3DA 100%)}
.bg-black{opacity:0;background:radial-gradient(52% 64% at 70% 44%,#1d1e1c 0%,#121312 42%,transparent 78%),radial-gradient(40% 40% at 8% 100%,#0d0e0d,transparent 70%),#080909}
.bg-red{opacity:0;background:radial-gradient(58% 70% at 62% 36%,#6b1a29 0%,#561320 38%,transparent 80%),radial-gradient(90% 90% at 50% 50%,transparent 55%,rgb(12 3 5 / .7) 100%),#4A111B}
.wordmark-3d{position:absolute;left:0;right:0;z-index:1;margin:0;text-align:center;pointer-events:none;user-select:none;font:700 var(--wm-size,25vw)/.74 var(--display);letter-spacing:-.012em;text-transform:uppercase;color:var(--ink);bottom:var(--wm-bottom,9svh);will-change:transform,opacity;white-space:nowrap}
.scene-host{position:absolute;inset:0;z-index:2}
.scene-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.stage-poster{position:absolute;inset:0;z-index:2;width:100%;height:100%;object-fit:cover;pointer-events:none;transition:opacity 400ms linear}
.stage-poster.is-hidden{opacity:0}
.chapters{position:absolute;inset:0;z-index:5}
.mode-3d .chapters{pointer-events:none}
.scrim{display:none;position:absolute;inset:auto 0 calc(100svh - 100lvh) 0;height:58%;z-index:4;pointer-events:none;background:linear-gradient(180deg,rgb(var(--hdr-bg) / 0) 0%,rgb(var(--hdr-bg) / .82) 38%,rgb(var(--hdr-bg) / .96) 62%,rgb(var(--hdr-bg)) 100%)}
.closing-inline{display:none}
.mode-3d .chapter{position:absolute;inset:0;padding:0 var(--gutter)}
.mode-3d .chapter:not(.chapter--form) .copy{opacity:0}
.mode-3d .chapter.is-active .copy a,.mode-3d .chapter.is-active .copy button{pointer-events:auto}
.mode-3d .copy{position:absolute;left:var(--gutter);will-change:transform,opacity}
.mode-3d .display .line{overflow:clip;padding:.06em 0 .14em;margin:-.06em 0 -.14em}
.mode-3d .display .line > span{display:block;will-change:transform}
.mode-3d .chapter--form .copy{top:calc(118px + var(--safe-t));width:calc(6 * var(--col) + 5 * var(--gap));color:var(--ink)}
.mode-3d .chapter--space .copy{top:50%;translate:0 -54%;width:calc(5 * var(--col) + 4 * var(--gap));color:var(--warm)}
.mode-3d .chapter--surface .copy{bottom:calc(92px + var(--safe-b));width:calc(6 * var(--col) + 5 * var(--gap));color:var(--warm)}
.chapter--surface .notes{margin-top:30px;max-width:520px}
.mode-3d .chapter--edition .copy{top:50%;translate:0 -52%;width:calc(5 * var(--col) + 4 * var(--gap));color:var(--ink)}
.chapter--space .support,.chapter--surface .support{color:var(--muted-warm)}
.chapter--form .support,.chapter--edition .support{color:var(--muted-ink)}
.progress{position:absolute;z-index:6;left:var(--gutter);right:var(--gutter);bottom:calc(22px + var(--safe-b));display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:18px;color:var(--hdr-fg);transition:color var(--dur) var(--ease);font:500 13px/1 var(--body);letter-spacing:.06em}
.progress .label,.progress .cue,.progress .closing-note{min-height:32px;display:inline-flex;align-items:center;gap:10px;padding:0 10px;background:rgb(var(--hdr-bg) / .55);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.progress .label{margin-left:-10px}
.progress .aside{display:inline-flex;align-items:center;min-height:32px;margin-right:-10px}
.progress .label .num{font-variant-numeric:tabular-nums;opacity:.7}
.progress .rule{position:relative;height:1px;background:color-mix(in srgb,currentColor 28%,transparent)}
.progress .fill{position:absolute;inset:0;background:currentColor;transform-origin:left;transform:scaleX(0)}
.progress .aside{justify-self:end;position:relative}
.cue,.closing-note{transition:opacity var(--dur) var(--ease)}
.closing-note{position:absolute;right:0;white-space:nowrap;opacity:0}
.cue svg{width:12px;height:12px}
.cue.is-hidden{opacity:0}
.scene-status{position:absolute;z-index:7;left:calc(var(--gutter) - 10px);top:calc(66px + var(--safe-t));margin:0;padding:7px 10px;font:500 13px/1.4 var(--body);letter-spacing:.04em;color:var(--hdr-fg);background:rgb(var(--hdr-bg) / .66);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
.scene-status:empty{display:none}
.scene-error{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;justify-content:center}
.mode-flow .scene-error{justify-content:flex-start}
.site-footer{font:500 13px/1.4 var(--body);letter-spacing:.04em;margin:0}
.mode-3d .site-footer{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-width:767px),(max-width:1023px) and (orientation:portrait){
  .mode-3d .copy{left:var(--gutter);right:var(--gutter);width:auto !important;top:auto !important;translate:none !important;bottom:calc(78px + var(--safe-b))}
  .mode-3d .chapter--form .copy{bottom:calc(76px + var(--safe-b))}
  h1.display{font-size:clamp(46px,12.6vw,92px)}
  h2.display{font-size:clamp(46px,12.2vw,88px)}
  .eyebrow{margin-bottom:12px;font-size:13px}
  .support{margin-top:14px;font-size:16px;max-width:34ch}
  .actions{margin-top:20px;gap:6px 22px}
  .chapter--surface .notes{margin-top:18px;font-size:13px}
  .progress{gap:12px;bottom:calc(14px + var(--safe-b));font-size:13px}
  .wordmark-3d{bottom:auto;top:var(--wm-top,36svh)}
  .mode-3d .scrim{display:block}
  .mode-3d .closing-note{display:none}
  .mode-3d .closing-inline{display:block;margin:14px 0 0;font:500 13px/1.4 var(--body);letter-spacing:.04em;color:var(--muted-ink)}
}
@media (min-width:768px) and (max-width:1023px) and (orientation:portrait){
  .mode-3d .copy{right:auto;width:min(560px,calc(100% - 2 * var(--gutter))) !important}
  .support{font-size:17px}
}
@media (max-width:767px) and (max-height:700px){
  h1.display,h2.display{font-size:clamp(42px,11.4vw,60px)}
  .support{font-size:16px;line-height:1.45}
  .eyebrow{margin-bottom:8px}
  .actions{margin-top:14px}
}
dialog{padding:0;border:0;color:var(--ink);background:var(--ivory);max-width:none;max-height:none}
dialog::backdrop{background:rgb(8 9 9 / .5)}
.sheet{position:fixed;inset:0 0 0 auto;margin:0;width:min(600px,100vw);height:100dvh;padding:calc(28px + var(--safe-t)) 48px calc(40px + var(--safe-b));display:none;flex-direction:column;overflow-y:auto;overscroll-behavior:contain}
.sheet[open]{display:flex;animation:torsion-sheet-in 220ms var(--ease)}
@keyframes torsion-sheet-in{from{transform:translateX(24px);opacity:0}}
.sheet-top{display:flex;justify-content:space-between;align-items:center;padding-bottom:18px;border-bottom:1px solid var(--ink);font:600 13px/1 var(--body);letter-spacing:.16em;text-transform:uppercase}
.sheet h2{font:700 clamp(64px,9vw,104px)/.88 var(--display);margin:56px 0 0}
.sheet .intro{font-size:20px;line-height:1.45;margin:22px 0 0;max-width:26ch}
.sheet dl{margin:44px 0 0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr)}
.sheet dt,.sheet dd{margin:0;padding:16px 0;border-top:1px solid rgb(17 18 16 / .22)}
.sheet dt{font:600 13px/1.5 var(--body);letter-spacing:.12em;text-transform:uppercase;color:var(--muted-ink)}
.sheet dd{font-size:17px}
.sheet .disclosure{margin:auto 0 0;padding-top:40px;font-size:15px;line-height:1.55;color:var(--muted-ink);max-width:44ch}
.sheet .btn{margin-top:24px;align-self:flex-start}
.icon-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-width:44px;min-height:44px;padding:0 4px;border:0;background:none;cursor:pointer;font:500 14px/1 var(--body);letter-spacing:.02em;text-transform:none}
.icon-btn svg{width:16px;height:16px}
.menu-sheet{position:fixed;inset:0;margin:0;width:100vw;height:100dvh;padding:calc(14px + var(--safe-t)) var(--gutter) calc(32px + var(--safe-b));display:none;flex-direction:column}
.menu-sheet[open]{display:flex;animation:torsion-fade-in 180ms var(--ease)}
@keyframes torsion-fade-in{from{opacity:0}}
.menu-sheet .sheet-top{border-bottom:0;padding-bottom:0}
.menu-sheet ol{list-style:none;margin:48px 0 0;padding:0;border-top:1px solid var(--ink)}
.menu-sheet li{border-bottom:1px solid rgb(17 18 16 / .22)}
.menu-sheet a{display:flex;align-items:baseline;gap:16px;padding:14px 0;text-decoration:none;font:700 52px/1 var(--display)}
.menu-sheet a .n{font:500 13px/1 var(--body);letter-spacing:.08em;opacity:.6}
.menu-sheet .foot{margin-top:auto;font-size:14px;color:var(--muted-ink)}
@media (max-width:767px){
  .sheet{inset:auto 0 0 0;width:100vw;height:auto;max-height:92dvh;padding:22px var(--gutter) calc(28px + var(--safe-b))}
  @keyframes torsion-sheet-in{from{transform:translateY(24px);opacity:0}}
  .sheet h2{margin-top:36px}
  .sheet .intro{font-size:18px}
  .sheet dl{margin-top:28px;grid-template-columns:1fr}
  .sheet dd{border-top:0;padding-top:0}
}
```

## 7. Motion

One rendered progress `p` drives everything. Target = `progressAt(scrollY, trackTop, trackHeight, stageHeight)`; each frame `p = advanceProgress(p, target, dt)`.

```js
const clamp01 = v => Math.max(0, Math.min(1, v)), lerp = (a, b, t) => a + (b - a) * t;
const ease5 = t => { t = clamp01(t); return t * t * t * (t * (t * 6 - 15) + 10); };
const ramp = (p, a, b) => ease5((p - a) / (b - a)), visibleBetween = (p, a, b, c, d) => ramp(p, a, b) * (1 - ramp(p, c, d));
const progressAt = (y, top, h, vh) => clamp01((y - top) / Math.max(1, h - vh));
function advanceProgress(cur, target, dt) {     // τ = 0.11 s
  const next = cur + (target - cur) * (1 - Math.exp(-Math.min(dt, 0.05) / 0.11));
  return Math.abs(next - target) < 0.0001 ? target : next;
}
const chapterOpacities = p => [1 - ramp(p, .09, .19), visibleBetween(p, .19, .28, .43, .53), visibleBetween(p, .52, .61, .74, .83), ramp(p, .83, .92)];
function backgroundWeights(p) {
  const wB = p < .61 ? ramp(p, .12, .29) : 0, wR = ramp(p, .47, .61) * (1 - ramp(p, .78, .91));
  return { wB, wR, eI: (1 - wB) * (1 - wR), eB: wB * (1 - wR), eR: wR };
}
function monotone(xs, ys) {     // shape-preserving cubic Hermite (Fritsch–Butland): no overshoot
  const n = xs.length, h = [], d = [], m = new Float64Array(n);
  for (let i = 0; i < n - 1; i++) { h[i] = xs[i + 1] - xs[i]; d[i] = (ys[i + 1] - ys[i]) / h[i]; }
  m[0] = d[0]; m[n - 1] = d[n - 2];
  for (let i = 1; i < n - 1; i++) {
    if (d[i - 1] * d[i] <= 0) { m[i] = 0; continue; }
    const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1]; m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]);
  }
  return x => {
    if (x <= xs[0]) return ys[0]; if (x >= xs[n - 1]) return ys[n - 1];
    let i = 0; while (x > xs[i + 1]) i++;
    const t = (x - xs[i]) / h[i], t2 = t * t, t3 = t2 * t;
    return (2*t3 - 3*t2 + 1) * ys[i] + (t3 - 2*t2 + t) * h[i] * m[i] + (-2*t3 + 3*t2) * ys[i + 1] + (t3 - t2) * h[i] * m[i + 1];
  };
}
```

- **Copy:** act i, `v = chapterOpacities(p)[i]`, `dir = p < [0, .355, .675, 1][i] ? 1 : −1`: `.copy` opacity `v` (0 below .002), `translate3d(0,(1−v)·18px·dir,0)`; each `.display .line > span` `translate3d(0,(1−v)·26px·dir,0)`. Controls are focusable/clickable only while `v > .55` (`tabIndex`, `.is-active`); blur focus in a fading act.
- **Wordmark:** `w = ramp(p, 0, .17)`: `translate3d(0, 14%·w, 0)`, opacity `1 − w`.
- **Backgrounds** (designed light changes, never flat crossfades):

```js
const radialMask = (at, a, b, invert) => `radial-gradient(ellipse farthest-corner at ${at}, ${invert ? '#000' : 'transparent'} ${a.toFixed(1)}%, ${invert ? 'transparent' : '#000'} ${b.toFixed(1)}%)`;
// setMask(el, value, opacity): mask-image + -webkit-mask-image (when changed) and opacity
function applyBackgroundMasks(p) {
  const tB = ramp(p, .12, .29), tR = ramp(p, .47, .61), tI = ramp(p, .78, .91);
  const b0 = lerp(160, -75, tB);    // ivory → black: darkness closes in from the edges toward the object
  setMask(bgBlack, tB >= 1 ? 'none' : radialMask('66% 46%', b0, b0 + 75, false), tB > 0 && tR < 1 ? '1' : '0');
  if (tR <= 0) return setMask(bgRed, 'none', '0');
  if (tI <= 0) { const r0 = lerp(-75, 160, tR);     // black → wine: red light blooms from the light field
    setMask(bgRed, tR >= 1 ? 'none' : radialMask('64% 40%', r0, r0 + 75, true), '1'); }
  else { const i0 = lerp(-75, 160, tI);              // wine → ivory: daylight opens from the centre
    setMask(bgRed, tI >= 1 ? 'none' : radialMask('62% 48%', i0, i0 + 75, false), tI >= 1 ? '0' : '1'); }
}
```

- **UI:** `--hdr-bg` = rounded `[238,236,229]·eI + [8,9,9]·eB + [74,17,27]·eR`; `--hdr-fg` warm white if `eB + eR > .5`. `.fill` `scaleX(p)`. Act = p < .18, < .515, < .8, else 4 → `.num`, `.name`, `html[data-chapter]` (`form|space|surface|edition`), `aria-current` on `.nav a` and `.menu-sheet a`. Cue `is-hidden` when target > .03 (and `visibility:hidden` once p > .05). Closing note opacity `ramp(p,.86,.94)`; scrim `1 − .75·ramp(p,.8,.92)`.
- **Camera** (degrees; world units with the sculpture 3.2 tall; `fx/fy` = NDC where the target lands; `ty` = target height), each channel `monotone(P, values)`:

```js
const P = [0, .08, .30, .44, .64, .74, .92, 1];
const TRACKS = {
  wide: { fov: 33, p: P,                                         // ≥768 landscape
    yaw:  [126, 110, 18, -2, -30, -38, -30, -30],    elev: [5, 7, 13, 16, 4, 6, 7, 7],
    dist: [4.4, 4.8, 6.6, 6.4, 2.9, 2.35, 7.4, 7.4], roll: [-20, -14, -4, 0, 4, 6, 0, 0],
    fx:   [.18, .2, .4, .38, .46, .52, .4, .4],      fy:   [0, 0, 0, 0, .02, .04, .04, .04],
    ty:   [.3, .2, 0, 0, .7, .85, -.08, -.08],
    ac:   [.2, .2, .22, .22, .55, .6, .18, .18] },   // fx += ac·(1.6/clamp(aspect,1.2,2.4) − 1)
  stacked: { fov: 41, p: P,                                      // <768, or 768–1023 portrait
    yaw:  [126, 110, 18, -2, -30, -38, -30, -30],    elev: [5, 7, 13, 16, 4, 6, 7, 7],
    fill: [1.33, 1.29, 1.03, 1.06, 2.3, 2.69, .97, .97], off: [-.05, -.03, .04, .04, .02, .02, 0, 0],
    roll: [-12, -9, -3, 0, 3, 4, 0, 0],              fx:   [.06, .05, 0, 0, .04, .06, .1, .1],
    ty:   [.2, .15, 0, 0, .7, .85, -.08, -.08],
    dist: [6.2, 6.4, 7.6, 7.4, 3.4, 2.9, 8.8, 8.8],  fy:   [.24, .26, .3, .3, .28, .28, .32, .32] }, // dist/fy: before the band is measured
};
const KEY_CHAPTER = [0, 0, 1, 1, 2, 2, 3, 3];   // stacked key → chapter band
const STACKED_QUERY = '(max-width: 767px), (max-width: 1023px) and (orientation: portrait)';
```

Expected: Act 01 a slender diagonal blade crossing the wordmark, past top and bottom; Act 02 the whole open loop at the right on near-black, ≈80% of the height; Act 03 inside the upper loop in wine light, metal past the edges; Act 04 the sculpture on its plinth at the right, ≈70% of the height.
- **Light** (`applyLight(bw)`, same p): `envW = (eI, eB, eR)`; metal `envMapIntensity = eI + 1.05·eB + eR`, plinth `.95·eI + .5·eB + .6·eR`; key `.9·eI + 1.4·eB + 1.3·eR`, rim `.5·eI + 2.2·eB + 1.6·eR`, both white lerped to `(1,.93,.88)` by `eR` (rim `.7·eR`); plinth colour `#d6d2c8 × (eI + .05·eB + .16·eR)`; ground shadow opacity `.8·eI`; contact shadow `.35 + .5·eI`.

## 8. Behaviour (JS)

Implement in `src/torsion/*.ts` (type the JS snippets; same behaviour): `createController(elements, onStatus)` → `{ goTo, retry, dispose }`, `createEngine(options)` → `{ render, dispose }`.

**8.0 React lifecycle.** `<App/>` in `<StrictMode>`; `useEffect(() => { const c = createController(els, setStatus); return () => c.dispose(); }, [])`. `dispose()` aborts pending setup (`AbortController` checked after each `await`, passed to `fetch`, cancelling the reader), removes the canvas, cancels the frame, removes all listeners and a `ResizeObserver` on `.stage` (re-measure on resize). One canvas, one loop, no per-frame React state: the controller writes styles, `tabIndex`, text and attributes; components never pass `style`/`tabIndex`/`src` to those elements.

**8.1 Mode before first paint** (classic script in `<head>`; `<html>` starts with class `mode-flow`):

```js
(function () {
  var root = document.documentElement, reduce = matchMedia('(prefers-reduced-motion: reduce)').matches, gl = false;
  try {
    var c = document.createElement('canvas'), ctx = c.getContext('webgl2') || c.getContext('webgl');
    gl = !!ctx; var lose = ctx && ctx.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext();
  } catch (e) { gl = false; }
  root.dataset.webgl = gl ? 'yes' : 'no';
  if (gl && !reduce) {
    root.className = 'mode-3d';
    var url = 'https://cdn.orbital.supply/exp-0006/torsion-v1.glb';   // prefetch in parallel with the modules
    window.__torsionModel = { url: url, response: fetch(url).catch(function (e) { return e; }) };
  }
})();
```

**8.2 Measuring.** `measure()` = `measureBands()`, `trackTop` (document), `trackH = track.offsetHeight`, `vh = stage.offsetHeight`; `scrollYFor(i) = trackTop + [0,.35,.66,.96][i]·(trackH − vh)`.

```js
const bands = { valid: false, version: 0, frac: [0, 0, 0, 0], centre: [0, 0, 0, 0] };
function measureBands() {             // stacked + 3D only; layout positions, unaffected by transforms
  bands.valid = false;
  if (!stackedQuery.matches || mode !== '3d') return;
  const vh = stage.offsetHeight || innerHeight, top = header.offsetHeight + 6;
  copyEls.forEach((copy, i) => {
    const h = copy.querySelector('.display');
    const bottom = (i === 3 ? copy.offsetTop : copy.offsetTop + h.offsetTop) - 10;   // Act 04 stays above its eyebrow
    const band = Math.max(80, bottom - top);
    bands.frac[i] = band / vh; bands.centre[i] = 1 - 2 * ((top + band / 2) / vh);
  });
  bands.valid = true; bands.version++;
}
// sizeWordmark(): set --wm-size from the width of the inner <span> at 100px so TORSION spans 88% of innerWidth,
// capped at stageHeight × (stacked ? .2 : .47); stacked also sets --wm-top = 27% of stageHeight.
```

**8.3 Loop.** `kick()` requests one frame if none is pending, the page is visible and mode is 3D. `frame`: advance `renderP`, `applyUI`, then always `const tilting = engine ? engine.render(renderP, dt, pointer) : false;` (never short-circuit the render) and `kick()` while `tilting || renderP !== targetP`. Idle = no rendering.

**8.4 Navigation.** `a[data-nav]` clicks: `preventDefault`, close the menu (no scroll restore), `goTo(id)`; keyboard activation focuses the act's `.display` (no scroll). `goTo`: 3D → smooth `scrollTo(scrollYFor(i))`; flow → top of `#chapter-<id>` (0 for Form); then `history.pushState(null, '', '#' + id)` **in try/catch** (sandboxed frames may throw). `popstate` → `goTo(hash || 'form')`, no push; unknown hashes do nothing.

**8.5 Dialogs.** Open (`Explore Edition 01` → `#edition-details`, `.menu-btn` → `#menu`): remember opener and `scrollY`, add `html.is-locked`, `showModal()`. On `close`: remove the lock, restore `scrollY`, focus the opener. Backdrop click and close buttons call `close()`.

**8.6 Mode switch.** Keep the current act (flow: last `.chapter` above mid-viewport). Toggle `mode-3d`/`mode-flow`, strip inline styles of `.copy` and line spans. → flow: all acts active, stop the loop, remove `--hdr-bg/--hdr-fg`, scroll to the act. → 3D: measure, size the wordmark, instant scroll to `scrollYFor(act)`, sync `renderP`, full UI update. Reduced-motion change: 3D → flow; back only with WebGL and no pending failure.

**8.7 Loading.** `start3D()`: create the engine with a 25 s timeout; if timeout or error wins, release whatever the attempt created (also a late success) and fail. On success: measure, sync `renderP` to the **current** target, render once (`force`), next frame hide `.stage-poster` (`.is-hidden`), clear the status, set `html[data-scene="ready"]`. Never jump to the top. While loading, `.stage-poster` shows the act's loading poster (`mobile` while `STACKED_QUERY` matches, else `desktop`).

`fetchModel(url)`: use the head prefetch if it is for this URL, unused and not failed, else `fetch(url)` (a failed prefetch is retried once silently); throw on `!ok`; with `Content-Length` stream the body into a `Uint8Array` of that length (status in 5% steps; throw on overflow or truncation), otherwise `arrayBuffer()`.

**8.8 Status** (`#scene-status`): `Loading sculpture…` (`… NN%` in 5% steps), `Preparing sculpture…`, then empty. Failure: `<span class="scene-error">The 3D study could not load. You can still explore the collection below. <button class="btn" type="button">Retry 3D</button></span>`; without WebGL, the same span without the button.

**8.9 Failure & retry.** HTTP error, timeout, parse error or `webglcontextlost` (`preventDefault`): warn, release the engine, `forcedFlow = true`, failure status, flow at the current act. `Retry 3D`: `forcedFlow = false`, drop the prefetch, **fresh canvas** (never reuse a lost context), 3D at the same act, loading poster, `start3D()`.

**8.10 Engine** (`createEngine`). `const THREE = await import('three'); const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');` (dynamic imports). `WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' })`, clear alpha 0, `NeutralToneMapping`, exposure 1; DPR `min(devicePixelRatio, stacked ? 1.4 : 1.75)`; if more than 45 of 90 frames exceed 1/38 s, lower DPR by .25 (min 1). `PerspectiveCamera(33, 1, .05, 60)`. If setup throws after the renderer exists, dispose and rethrow.

Three studio environments, prefiltered once with `pmrem.fromScene(scene, 0.02, 0.1, 100, { size: 256 })` (then dispose scenes and generator): a `SphereGeometry(30, 64, 32)` `BackSide` `ShaderMaterial` (no depth write) coloured `y > 0 ? mix(mid, top, smoothstep(0, .9, y)) : mix(mid, bot, smoothstep(0, .3, −y))` (y of the normalised direction), plus DoubleSide `MeshBasicMaterial` planes `[w, h, x, y, z, r, g, b, i]` (colour `setRGB(r·i, g·i, b·i, LinearSRGBColorSpace)`) facing `lookAt(0, y·.35, 0)`:

| State | top / mid / bot | Planes |
|---|---|---|
| ivory | .78 .77 .75 / .2 .195 .185 / .3 .29 .27 | [9,3.5,0,9,3,1,1,1,3.4] [4,8,−9,2.5,5,1,.99,.97,2.8] [.9,9,8.5,1.5,−3,1,1,1,6] [3,7,3,1.5,−9,1,1,1,1.6] [5,11,7,.5,7,.012,.012,.012,1] [4,11,−6.5,.5,−7,.015,.015,.015,1] [2.5,11,−2,.5,9.5,.03,.03,.03,1] |
| black | .012 ×3 / .006 ×3 / .01 ×3 | [8,6,−7,4.5,6.5,1,1,1,6.5] [.35,10,7.5,1.5,−5.5,1,1,1,16] [.3,9,−8.5,0,−4,1,1,1,6] [5,5,0,10,−2,1,1,1,.45] |
| red | .05 .007 .012 / .12 .015 .028 / .06 .008 .014 | [7.5,5,6.5,5,6,1,.9,.84,5.2] [11,7,−8.5,.5,2,.62,.075,.12,.75] [.35,9,−6,2,−6.5,1,.88,.82,14] [.3,8,4,1,−8,1,.9,.85,8] [6,6,2,−6,6,.5,.06,.1,.35] |

`envs = [ivory, black, red]` are blended per pixel inside the standard shader:

```js
const envW = new THREE.Vector3(1, 0, 0);   // (eI, eB, eR), updated every frame
const envUniforms = { envA: { value: envs[0] }, envB: { value: envs[1] }, envC: { value: envs[2] }, envW: { value: envW } };
const ENV_CHUNK = `uniform sampler2D envA; uniform sampler2D envB; uniform sampler2D envC; uniform vec3 envW;
vec4 torsionEnv( vec3 d, float r ) { vec4 c = vec4( 0.0 );
  if ( envW.x > 0.001 ) c += envW.x * textureCubeUV( envA, d, r );
  if ( envW.y > 0.001 ) c += envW.y * textureCubeUV( envB, d, r );
  if ( envW.z > 0.001 ) c += envW.z * textureCubeUV( envC, d, r );
  return c; }
` + THREE.ShaderChunk.envmap_physical_pars_fragment.replaceAll('textureCubeUV( envMap, ', 'torsionEnv( ');
function useBlendedEnv(mat) {     // the three PMREM maps share one size, so the CUBE_UV defines match
  mat.envMap = envs[0];           // enables the CUBE_UV path; sampling is replaced below
  mat.onBeforeCompile = sh => { Object.assign(sh.uniforms, envUniforms);
    sh.fragmentShader = sh.fragmentShader.replace('#include <envmap_physical_pars_fragment>', ENV_CHUNK); };
  mat.customProgramCacheKey = () => 'torsion-env-blend';
  return mat;
}
```

Materials (both via `useBlendedEnv`): metal `MeshStandardMaterial({ color: linear (.91, .915, .92), metalness: 1, roughness: .3 })`, plinth `({ color: '#d6d2c8', metalness: 0, roughness: .88 })`. Lights: key `DirectionalLight` at (−4, 6, 5), rim at (5, 3, −6). Model: `parseAsync(buffer, '')`, every mesh metal. Groups `pose → norm → calib → gltf.scene`: `calib.rotation.y = −π/2`; from `Box3(calib)`: `norm.scale = 3.2 / height`, `calib.position = −centre` (support y = −1.6). Footprint = mean world x/z of vertices below −1.584. Plinth `CylinderGeometry(.86, .86, .13, 128)`, top at −1.594, on the footprint, in `pose`. Ground shadow: 2.924 plane (rotated −90° on x) just above the plinth base, 256² canvas `createRadialGradient(128, 128, 40, 128, 128, 128)` with stops 0 `rgba(0,0,0,.55)`, .45 `rgba(0,0,0,.22)`, 1 transparent; contact shadow: 1.72 plane at plinth top + .0015 with the baked texture; both `MeshBasicMaterial` with the texture as `map` (renders black, alpha from the texture), transparent, no depth write, `toneMapped: false`.

Contact shadow, baked once from the real mesh: `OrthographicCamera(−.86, .86, .86, −.86, 0, 1.1)`, `up = (0, 0, 1)`, at (foot.x, plinthTop − .0005, foot.z) looking **up** at (foot.x, plinthTop + 1, foot.z), renders `norm.clone(true)` with a double-sided `ShaderMaterial` writing `vec4(0, 0, 0, pow(1 − clamp((worldY − plinthTop) / 1.1, 0, 1), 3))` into a 512² target (no tone mapping, clear alpha 0); then three separable 9-tap Gaussian passes (weights .2270270270, .1945945946, .1216216216, .0540540541, .0162162162; radii 1.2, 2.2, 3.4 texels; ping-pong) on a full-screen quad (`vec4(position.xy, 0, 1)`, `frustumCulled = false`). Restore target and tone mapping; texture `repeat (1, −1)`, `offset (0, 1)` for the −90°-rotated plane.

```js
const D2R = Math.PI / 180, H_EFF = 3.45;   // sculpture + plinth height
function render(p, dt, ptr, force) {         // true while the tilt settles
  resize();
  const L = stackedQuery.matches ? 'stacked' : 'wide', s = splines[L];   // s[ch] = monotone(P, TRACKS[L][ch])
  const act2 = visibleBetween(p, .19, .28, .43, .53), allow = finePointer.matches && !reduceQuery.matches;
  const tx = allow ? ptr.y * 1.5 * act2 : 0, ty = allow ? ptr.x * 1.5 * act2 : 0;
  const k = dt > 0 ? 1 - Math.exp(-dt / 0.35) : 1;
  tilt.x += (tx - tilt.x) * k; tilt.y += (ty - tilt.y) * k;
  const busy = Math.abs(tx - tilt.x) > 0.002 || Math.abs(ty - tilt.y) > 0.002;
  const key = `${L}|${p.toFixed(5)}|${tilt.x.toFixed(3)}|${tilt.y.toFixed(3)}|${w}x${h}|${bands.version}`;
  if (key === lastKey && !force) return busy;
  lastKey = key; if (dt) watch(dt);
  const yaw = s.yaw(p) * D2R, elev = s.elev(p) * D2R, roll = s.roll(p) * D2R;
  let dist = s.dist(p), fx = s.fx(p), fy = s.fy?.(p) ?? 0;
  const band = L === 'stacked' && bands.valid ? bandAt(p) : null;   // monotone(P, KEY_CHAPTER.map(i => bands.frac/centre[i])), rebuilt per bands.version
  if (band) { dist = H_EFF / (2 * Math.tan(s.fov * D2R / 2) * s.fill(p) * band.frac); fy = band.centre + s.off(p); }
  if (s.ac) fx += s.ac(p) * (1.6 / Math.min(2.4, Math.max(1.2, camera.aspect)) - 1);
  target.set(0, s.ty(p), 0);
  camera.position.set(Math.sin(yaw) * Math.cos(elev) * dist, target.y + Math.sin(elev) * dist, Math.cos(yaw) * Math.cos(elev) * dist);
  camera.up.set(0, 1, 0); camera.lookAt(target); camera.rotateZ(-roll);
  camera.fov = s.fov; camera.near = Math.max(0.05, dist - 2.6); camera.far = dist + 6;
  camera.updateProjectionMatrix();
  camera.projectionMatrix.elements[8] = -fx;   // lens shift: the target lands at NDC (fx, fy)
  camera.projectionMatrix.elements[9] = -fy;
  camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
  pose.rotation.set(tilt.x * D2R, tilt.y * D2R, 0);
  applyLight(backgroundWeights(p));
  renderer.render(scene, camera);
  return busy;
}
```

`resize()`: on a canvas size/DPR change → `setPixelRatio`, `setSize(w, h, false)`, `camera.aspect`. `dispose()` frees every GPU resource and the renderer.

**8.11 Events & boot.** `scroll` (passive) → target, `kick()`. `resize` → measure, size the wordmark; on a width change or height change > 160px keep the progress. Mouse `pointermove` → `pointer = (clientX/innerWidth·2−1, clientY/innerHeight·2−1)`. Hidden page → stop the loop. Boot 3D: measure, size the wordmark, honour a hash, sync `renderP`, `applyUI`, loading poster, `start3D()`, re-measure after `document.fonts.ready`. Boot flow: all acts active, honour the hash.

## 9. Responsive

Wide (≥768 landscape): track/FOV of section 7, copy on the left. Stacked (<768, or 768–1023 portrait): object in the upper field fitted to the measured band, copy at the bottom over the scrim, menu button instead of nav (all CSS in section 6). No horizontal scroll at 375 · 390 · 768 · 1024 · 1440 · 1920; on ≈390×667 the sculpture and plinth stay above the Act 04 eyebrow. Touch: no tilt.

## 10. Accessibility & performance

One `<h1>`; skip link; decorative layers `aria-hidden`, `#scene-description` for the object; visible focus, ≥44px targets, dialogs return focus, inactive acts not focusable. Render on demand, one loop, paused while hidden; the model is fetched once.

## 11. Acceptance checklist

- [ ] First paint: Act 01, wordmark, loading poster, `Loading sculpture…`; then the scene at the current progress (also `/#surface`).
- [ ] Within the first half-screen the blade orbits open and the wordmark fades; four distinct compositions; at rest only one act's copy shows.
- [ ] Links, Discover, Replay, hashes and back/forward reach the right act, also in an `allow-scripts allow-popups` sandboxed iframe; the dialog closes by Escape, backdrop and buttons and returns focus.
- [ ] Model blocked or context lost → flow page, failure sentence, `Retry 3D` restores 3D at the same act; no WebGL → flow page with the sentence.
- [ ] Reduced motion → flow page; three and the model are not downloaded.
- [ ] Zero console errors · only section 1 dependencies · only section 2 URLs.
HTML · un archivo589 líneas
Abrir el texto completo
# Build: "TORSION" — a scroll-driven exhibition of one aluminium sculpture

## 0. Brief

TORSION is a fictional collectible-design study, Edition 01: one thick aluminium ribbon folding around a large open centre, shown as a moving exhibition in four acts. Native scroll drives one hosted 3D model through four camera compositions and light states — ivory, near-black, wine red, ivory — while giant condensed type and the object trade prominence. Only camera and light move. The "wow": the first scroll orbits the slender metal edge, crossing a huge TORSION wordmark, open into a monumental loop as the room falls dark.

## 1. Output & hard constraints

**Target: html.** ONE `index.html`: one `<style>`, one classic `<script>` in `<head>` (8.1), the import map, one `<script type="module">` at the end of `<body>`. No build step; fonts, modules and assets are remote. Library: **three 0.185.0 only** (core and addon pinned to the same version):

```html
<script type="importmap">{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/" } }</script>
```

Import only `three` and `three/addons/loaders/GLTFLoader.js`. Preconnect (`crossorigin`) to the jsDelivr and cdn.orbital.supply origins.
Both targets: title `TORSION — Edition 01, a study in aluminium`, `lang="en"`, description `TORSION is a fictional design study: one continuous aluminium ribbon, explored in four acts — Form, Space, Surface and Edition.`, `theme-color #EEECE5`, viewport `width=device-width, initial-scale=1, viewport-fit=cover`. One scrolling page: a tall track with a sticky full-viewport stage; the acts are states of one scene.
- Do NOT add: prices, checkout, forms, testimonials, extra sections, a second object, auto-rotation, OrbitControls, scroll snapping or hijacking, smooth-scroll libraries, particles, bloom, audio, custom cursor, or claims of price, quantity, artist or availability.
- Use the section 2 URLs verbatim; no decoders (none needed). Anti-flash: `html`/`body` background `#EEECE5`, text `#111210`.
- Serve over HTTP(S) (`cdn.orbital.supply` sends `Access-Control-Allow-Origin: *`); not `file://`. Zero console errors; only failure paths warn (`[torsion] 3D unavailable: …`, `[torsion] WebGL context lost`).

## 2. Assets (exact URLs — visual resources; code dependencies are declared in section 1)

| ID | Type | URL | Role | Load |
|---|---|---|---|---|
| MODEL | model/gltf-binary | `https://cdn.orbital.supply/exp-0006/torsion-v1.glb` | The sculpture: 2,994,648 B, one mesh, 145,506 tris, `KHR_mesh_quantization`, no textures; loop faces raw +X; flat foot | prefetch → `parseAsync` |
| POSTER_<ACT>_DESKTOP · _MOBILE | image/webp | `https://cdn.orbital.supply/exp-0006/form-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/form-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/space-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/space-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/surface-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/surface-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/edition-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/edition-mobile-v1.webp` | Flow-page image per act (1600×1000 · 780×1688) | lazy |
| LOADING_POSTERS | image/webp (alpha) | `https://cdn.orbital.supply/exp-0006/loading-form-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-form-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-space-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-space-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-surface-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-surface-mobile-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-edition-desktop-v1.webp` · `https://cdn.orbital.supply/exp-0006/loading-edition-mobile-v1.webp` | 3D loading state: the model at that act's camera, transparent, at canvas depth | script |


## 3. Typography

`https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@600;700&family=Manrope:wght@400;500;600&display=swap` (preconnect fonts.googleapis.com and fonts.gstatic.com). Barlow Condensed 700 (`--display`): wordmarks, headings, brand, menu links, dialog title. Manrope 400/500/600 (`--body`): everything else. Stacks in section 6.

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

The `:root` block of section 6 (ivory, ink, near-black, wine for Act 03 only, warm white on dark; 12 columns; gutters 48/32/20px). Script rewrites `--hdr-bg` (`r g b`) and `--hdr-fg`.

## 5. Layer stack & DOM

Stage back → front: `.bg` (ivory/black/red light fields) → `.wordmark-3d` (behind the sculpture) → `#scene` canvas (z 2): transparent WebGL (clear alpha 0) → `.stage-poster` → `.scrim` → `.chapters` → `.progress` → `#scene-status` (z-indices in section 6). The 1px anchors `#form/#space/#surface/#edition` sit in `.track` at p = 0/.35/.66/.96 so native hash loads land correctly; **no other element may reuse those ids**.

Exact `<body>` markup before the scripts (ids, classes, data attributes load-bearing). `{POSTER x}` = `<picture><source media="(max-width: 767px)" srcset="POSTER_X_MOBILE"><img src="POSTER_X_DESKTOP" alt="…" width="1600" height="1000" loading="lazy"></picture>` with the URLs of section 2 and these alts — form: "The aluminium ribbon seen almost edge-on: a tall, slender diagonal blade on an ivory ground."; space: "The whole sculpture against near-black: an asymmetric open loop with a broad white reflection across its face."; surface: "A close view of the inner curve in deep wine-red light, metal running past the edges of the frame."; edition: "The complete sculpture standing on a low plinth on an ivory ground.". `{icon:x}` = `<svg viewBox="0 0 S S" aria-hidden="true"><path d="…" stroke="currentColor" stroke-width="W" fill="none"/></svg>`, listed as S/W/d: menu 18/1.4/`M2 6h14M2 12h14`; down 14/1.3/`M7 1v11M2.5 7.5 7 12l4.5-4.5`; plus 14/1.3/`M7 1.5v11M1.5 7h11`; up 12/1.2/`M6 11V1.5M2 5.5 6 1.5l4 4`; cue 12/1.2/`M6 1v9.5M2 6.5l4 4 4-4` (no aria-hidden); close 16/1.3/`M3 3l10 10M13 3 3 13` (no fill attr).

```html
<a class="skip" href="#chapter-form">Skip to content</a>
<header class="site-header">
<a class="brand chip" href="#form" data-nav="form" aria-label="TORSION, back to the opening">Torsion</a>
<nav class="nav chip" aria-label="Chapters">
<a href="#form" data-nav="form"><span class="n">01</span>Form</a>
<a href="#space" data-nav="space"><span class="n">02</span>Space</a>
<a href="#surface" data-nav="surface"><span class="n">03</span>Surface</a>
<a href="#edition" data-nav="edition"><span class="n">04</span>Edition</a>
</nav>
<button class="menu-btn chip" type="button" aria-haspopup="dialog" aria-controls="menu">
Menu
{icon:menu}
</button>
</header>
<main id="main">
<div class="track" id="track">
<a class="anchor" id="form" style="--p:0" tabindex="-1" aria-hidden="true"></a>
<a class="anchor" id="space" style="--p:.35" tabindex="-1" aria-hidden="true"></a>
<a class="anchor" id="surface" style="--p:.66" tabindex="-1" aria-hidden="true"></a>
<a class="anchor" id="edition" style="--p:.96" tabindex="-1" aria-hidden="true"></a>
<div class="stage" id="stage">
<div class="scene-status" id="scene-status" role="status" aria-live="polite"></div>
<p class="flow-wordmark" aria-hidden="true">Torsion</p>
<div class="bg" aria-hidden="true">
<div class="bg-ivory"></div>
<div class="bg-black"></div>
<div class="bg-red"></div>
</div>
<p class="wordmark-3d" aria-hidden="true"><span>Torsion</span></p>
<canvas id="scene" aria-hidden="true"></canvas>
<img class="stage-poster" alt="" aria-hidden="true" decoding="async">
<div class="scrim" aria-hidden="true"></div>
<p class="visually-hidden" id="scene-description">An interactive digital sculpture: one continuous, thick aluminium ribbon that folds around a large open centre and rests on a low plinth. Scrolling moves the camera from the ribbon’s slender edge, around to its open loop, close to its inner curve, and back to the complete object.</p>
<div class="chapters">
<section class="chapter chapter--form" id="chapter-form" data-i="0" aria-labelledby="h-form">
<figure class="flow-poster">{POSTER form}</figure>
<div class="copy">
<p class="eyebrow">Edition 01 — A study in aluminium</p>
<h1 class="display" id="h-form"><span class="line"><span>Form under</span></span> <span class="line"><span>pressure.</span></span></h1>
<p class="support">One continuous ribbon. A different presence from every angle.</p>
<div class="actions">
<a class="btn btn--ink" href="#space" data-nav="space">Discover the form
{icon:down}</a>
</div>
</div>
</section>
<section class="chapter chapter--space" id="chapter-space" data-i="1" aria-labelledby="h-space">
<figure class="flow-poster">{POSTER space}</figure>
<div class="copy">
<p class="eyebrow">02 / Space</p>
<h2 class="display" id="h-space"><span class="line"><span>Strength takes</span></span> <span class="line"><span>a different</span></span> <span class="line"><span>shape.</span></span></h2>
<p class="support">A fold gives the ribbon its tension. The space inside gives it room to breathe.</p>
</div>
</section>
<section class="chapter chapter--surface" id="chapter-surface" data-i="2" aria-labelledby="h-surface">
<figure class="flow-poster">{POSTER surface}</figure>
<div class="copy">
<p class="eyebrow">03 / Surface</p>
<h2 class="display" id="h-surface"><span class="line"><span>Edges hold light.</span></span></h2>
<p class="support">Soft reflections travel across a surface that stays still.</p>
<ul class="notes" aria-label="Material notes">
<li>Brushed aluminium</li><li>Rounded edge</li><li>Continuous form</li>
</ul>
</div>
</section>
<section class="chapter chapter--edition" id="chapter-edition" data-i="3" aria-labelledby="h-edition">
<figure class="flow-poster">{POSTER edition}</figure>
<div class="copy">
<p class="eyebrow">04 / Edition 01</p>
<h2 class="display" id="h-edition"><span class="line"><span>One</span></span> <span class="line"><span>continuous</span></span> <span class="line"><span>gesture.</span></span></h2>
<p class="support">An exploration of tension, reflection and the space between.</p>
<div class="actions">
<button class="btn btn--ink" type="button" data-open-edition aria-haspopup="dialog" aria-controls="edition-details">Explore Edition 01
{icon:plus}</button>
<a class="link" href="#form" data-nav="form">Replay the study
{icon:up}</a>
</div>
<p class="closing-inline" aria-hidden="true">TORSION — A fictional design study.</p>
</div>
</section>
</div>
<div class="progress" aria-hidden="true">
<span class="label"><span class="num">01</span><span class="name">Form</span></span>
<span class="rule"><span class="fill"></span></span>
<span class="aside">
<span class="cue">Scroll to explore
{icon:cue}</span>
<span class="closing-note">TORSION — A fictional design study.</span>
</span>
</div>
</div>
</div>
</main>
<footer class="site-footer"><p style="margin:0">TORSION — A fictional design study.</p></footer>
<dialog class="sheet" id="edition-details" aria-labelledby="edition-title">
<div class="sheet-top"><span>TORSION</span>
<button class="icon-btn" type="button" data-close aria-label="Close details">
{icon:close}</button>
</div>
<h2 id="edition-title">Edition 01</h2>
<p class="intro">A continuous ribbon, shaped around an open centre.</p>
<dl>
<dt>Collection</dt><dd>TORSION</dd>
<dt>Material study</dt><dd>Brushed aluminium</dd>
<dt>Form</dt><dd>Continuous ribbon</dd>
<dt>Project</dt><dd>Digital sculpture concept</dd>
</dl>
<p class="disclosure">TORSION is a fictional design study. The sculpture is a digital concept and is not available to purchase.</p>
<button class="btn btn--ink" type="button" data-close>Close details</button>
</dialog>
<dialog class="menu-sheet" id="menu" aria-label="Chapters">
<div class="sheet-top"><span class="brand" style="padding:0">Torsion</span>
<button class="icon-btn" type="button" data-close>Close
{icon:close}</button>
</div>
<nav aria-label="Chapters">
<ol>
<li><a href="#form" data-nav="form"><span class="n">01</span>Form</a></li>
<li><a href="#space" data-nav="space"><span class="n">02</span>Space</a></li>
<li><a href="#surface" data-nav="surface"><span class="n">03</span>Surface</a></li>
<li><a href="#edition" data-nav="edition"><span class="n">04</span>Edition</a></li>
</ol>
</nav>
<p class="foot">TORSION — A fictional design study.</p>
</dialog>
```

## 6. Components

The complete stylesheet (verbatim, in `<style>`):

```css
:root{--ivory:#EEECE5;--ink:#111210;--black:#080909;--wine:#4A111B;--warm:#F7F3E9;--muted-ink:#4b4c47;--muted-warm:#d9d2c6;--display:"Barlow Condensed","Arial Narrow","Roboto Condensed","Helvetica Neue",Arial,sans-serif;--body:"Manrope","Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;--gutter:48px;--gap:24px;--col:calc((100vw - 2 * var(--gutter) - 11 * var(--gap)) / 12);--ease:cubic-bezier(.2,.7,.2,1);--dur:200ms;--track-h:620svh;--hdr-bg:238 236 229;--hdr-fg:var(--ink);--safe-b:env(safe-area-inset-bottom,0px);--safe-t:env(safe-area-inset-top,0px)}
@media (max-width:1023px){
  :root{--gutter:32px}
}
@media (max-width:767px){
  :root{--gutter:20px;--gap:16px}
}
@media (max-width:767px),(max-width:1023px) and (orientation:portrait){
  :root{--track-h:500svh}
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%;background:var(--ivory);scrollbar-gutter:stable}
.mode-flow .chip{background:rgb(238 236 229 / .94)}
html.mode-3d{scroll-behavior:smooth}
body{margin:0;background:var(--ivory);color:var(--ink);font:400 17px/1.55 var(--body);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;overflow-x:clip}
html.is-locked{overflow:hidden}
img{display:block;max-width:100%}
a{color:inherit}
button{font:inherit;color:inherit}
.visually-hidden{position:absolute !important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
:focus{outline:none}
:focus-visible{outline:2px solid currentColor;outline-offset:3px}
.skip{position:fixed;left:var(--gutter);top:8px;z-index:60;padding:12px 16px;background:var(--ink);color:var(--warm);font:600 14px/1 var(--body);text-decoration:none;transform:translateY(-160%);transition:transform var(--dur) var(--ease)}
.skip:focus-visible{transform:none}
.site-header{position:fixed;inset:0 0 auto 0;z-index:40;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:calc(14px + var(--safe-t)) calc(var(--gutter) - 12px) 0;color:var(--hdr-fg);pointer-events:none;transition:color var(--dur) var(--ease)}
.site-header > *{pointer-events:auto}
.chip{display:inline-flex;align-items:center;min-height:44px;padding:0 12px;background:rgb(var(--hdr-bg) / .66);-webkit-backdrop-filter:blur(10px) saturate(1.1);backdrop-filter:blur(10px) saturate(1.1)}
.brand{font:700 20px/1 var(--display);letter-spacing:.2em;text-decoration:none;text-transform:uppercase;padding-right:9px}
.nav{display:flex;align-items:center;padding:0 4px}
.nav a{position:relative;display:inline-flex;align-items:center;gap:6px;min-height:44px;padding:0 10px;font:500 13px/1 var(--body);letter-spacing:.04em;text-decoration:none}
.nav a .n{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.nav a::after{content:"";position:absolute;left:10px;right:10px;bottom:11px;height:1px;background:currentColor;transform:scaleX(0);transform-origin:left;transition:transform var(--dur) var(--ease)}
.nav a:hover::after,.nav a[aria-current="true"]::after{transform:scaleX(1)}
.menu-btn{display:none;gap:10px;border:0;cursor:pointer;font:500 14px/1 var(--body);letter-spacing:.04em}
.menu-btn svg{width:18px;height:18px}
@media (max-width:767px){
  .nav{display:none}
  .menu-btn{display:inline-flex}
  .brand{font-size:18px}
}
.eyebrow{margin:0 0 18px;font:600 13px/1.3 var(--body);letter-spacing:.16em;text-transform:uppercase}
.display{margin:0;font-family:var(--display);font-weight:700;letter-spacing:-.005em;line-height:.9;text-wrap:balance}
.display .line{display:block}
h1.display{font-size:clamp(64px,7.3vw,132px)}
h2.display{font-size:clamp(56px,6.6vw,124px)}
.support{margin:22px 0 0;max-width:31ch;font-size:18px;line-height:1.5}
.actions{display:flex;flex-wrap:wrap;align-items:center;gap:12px 28px;margin-top:30px}
.btn{display:inline-flex;align-items:center;gap:14px;min-height:48px;padding:0 20px;border:1px solid currentColor;background:transparent;cursor:pointer;text-decoration:none;font:500 14px/1 var(--body);letter-spacing:.02em;transition:background-color var(--dur) var(--ease),color var(--dur) var(--ease)}
.btn svg{width:14px;height:14px;transition:transform var(--dur) var(--ease)}
.btn:hover svg{transform:translate(2px,2px)}
.btn--ink{color:var(--ink);background:var(--ivory)}
.btn--ink:hover{background:var(--ink);color:var(--ivory)}
.link{display:inline-flex;align-items:center;gap:10px;min-height:44px;font:500 14px/1 var(--body);text-decoration:none;background:none;border:0;padding:0;cursor:pointer;background-image:linear-gradient(currentColor,currentColor);background-size:100% 1px;background-repeat:no-repeat;background-position:0 calc(50% + 11px)}
.link svg{width:12px;height:12px}
.notes{display:flex;flex-wrap:wrap;gap:6px 22px;margin:0;padding:14px 0 0;list-style:none;font:500 13px/1.4 var(--body);letter-spacing:.12em;text-transform:uppercase;border-top:1px solid currentColor}
.mode-flow .track{height:auto}
.mode-flow .stage{position:relative;height:auto}
.mode-flow .bg,.mode-flow #scene,.mode-flow .stage-poster,.mode-flow .progress,.mode-flow .anchor,.mode-flow .wordmark-3d{display:none}
.mode-flow .chapters{position:static}
.mode-flow .chapter{position:relative;display:grid;grid-template-columns:repeat(12,minmax(0,1fr));column-gap:var(--gap);align-items:end;padding:120px var(--gutter) 88px;min-height:auto}
.mode-flow .chapter > .copy{grid-column:1 / span 5;grid-row:1;position:relative;z-index:1}
.mode-flow .chapter > .flow-poster{grid-column:5 / span 8;grid-row:1;margin:0}
.mode-flow .chapter--space,.mode-flow .chapter--surface{color:var(--warm)}
.mode-flow .chapter--space{background:var(--black)}
.mode-flow .chapter--surface{background:var(--wine)}
.mode-flow .flow-wordmark{display:block;margin:0;padding:104px var(--gutter) 0;font:700 21vw/.8 var(--display);letter-spacing:-.01em;text-transform:uppercase;color:var(--ink);white-space:nowrap;overflow:hidden}
.mode-flow .chapter--form{padding-top:32px}
.mode-flow .site-footer{position:static;padding:28px var(--gutter) calc(28px + var(--safe-b));border-top:1px solid var(--ink);margin:0 var(--gutter);padding-inline:0}
.mode-flow .scene-status{position:sticky;top:calc(66px + var(--safe-t));z-index:30;translate:none;left:auto;bottom:auto;margin:0 var(--gutter);padding:10px 14px;text-align:left;background:rgb(238 236 229 / .96);color:var(--ink);border:1px solid var(--ink)}
.mode-flow .scene-status:not(:empty) + .flow-wordmark{padding-top:24px}
.mode-flow .stage:has(.scene-status:not(:empty)){padding-top:76px}
.flow-poster img{width:100%;height:auto;aspect-ratio:16 / 10;object-fit:cover}
@media (max-width:767px),(max-width:1023px) and (orientation:portrait){
  .mode-flow .chapter{grid-template-columns:1fr;padding:88px var(--gutter) 64px}
  .mode-flow .chapter > .copy,.mode-flow .chapter > .flow-poster{grid-column:1}
  .mode-flow .chapter > .flow-poster{grid-row:1;margin-bottom:28px}
  .mode-flow .chapter > .copy{grid-row:2}
  .flow-poster img{aspect-ratio:9 / 14}
}
.mode-3d .flow-poster,.mode-3d .flow-wordmark{display:none}
.track{position:relative;height:var(--track-h)}
.anchor{position:absolute;left:0;width:1px;height:1px;top:calc(var(--p) * (var(--track-h) - 100svh));scroll-margin-top:0}
.stage{position:sticky;top:0;height:100svh;overflow:visible}
.bg{position:absolute;inset:0 0 calc(100svh - 100lvh) 0;overflow:hidden;z-index:0}
.bg > div{position:absolute;inset:0}
.bg-ivory{background:radial-gradient(120% 70% at 30% 18%,rgb(255 255 252 / .55),transparent 60%),radial-gradient(70% 24% at 62% 104%,rgb(120 112 98 / .18),transparent 70%),linear-gradient(180deg,#F1EFE9 0%,#EEECE5 58%,#E6E3DA 100%)}
.bg-black{opacity:0;background:radial-gradient(52% 64% at 70% 44%,#1d1e1c 0%,#121312 42%,transparent 78%),radial-gradient(40% 40% at 8% 100%,#0d0e0d,transparent 70%),#080909}
.bg-red{opacity:0;background:radial-gradient(58% 70% at 62% 36%,#6b1a29 0%,#561320 38%,transparent 80%),radial-gradient(90% 90% at 50% 50%,transparent 55%,rgb(12 3 5 / .7) 100%),#4A111B}
.wordmark-3d{position:absolute;left:0;right:0;z-index:1;margin:0;text-align:center;pointer-events:none;user-select:none;font:700 var(--wm-size,25vw)/.74 var(--display);letter-spacing:-.012em;text-transform:uppercase;color:var(--ink);bottom:var(--wm-bottom,9svh);will-change:transform,opacity;white-space:nowrap}
#scene{position:absolute;inset:0;z-index:2;width:100%;height:100%;display:block}
.stage-poster{position:absolute;inset:0;z-index:2;width:100%;height:100%;object-fit:cover;pointer-events:none;transition:opacity 400ms linear}
.stage-poster.is-hidden{opacity:0}
.chapters{position:absolute;inset:0;z-index:5}
.mode-3d .chapters{pointer-events:none}
.scrim{display:none;position:absolute;inset:auto 0 calc(100svh - 100lvh) 0;height:58%;z-index:4;pointer-events:none;background:linear-gradient(180deg,rgb(var(--hdr-bg) / 0) 0%,rgb(var(--hdr-bg) / .82) 38%,rgb(var(--hdr-bg) / .96) 62%,rgb(var(--hdr-bg)) 100%)}
.closing-inline{display:none}
.mode-3d .chapter{position:absolute;inset:0;padding:0 var(--gutter)}
.mode-3d .chapter:not(.chapter--form) .copy{opacity:0}
.mode-3d .chapter.is-active .copy a,.mode-3d .chapter.is-active .copy button{pointer-events:auto}
.mode-3d .copy{position:absolute;left:var(--gutter);will-change:transform,opacity}
.mode-3d .display .line{overflow:clip;padding:.06em 0 .14em;margin:-.06em 0 -.14em}
.mode-3d .display .line > span{display:block;will-change:transform}
.mode-3d .chapter--form .copy{top:calc(118px + var(--safe-t));width:calc(6 * var(--col) + 5 * var(--gap));color:var(--ink)}
.mode-3d .chapter--space .copy{top:50%;translate:0 -54%;width:calc(5 * var(--col) + 4 * var(--gap));color:var(--warm)}
.mode-3d .chapter--surface .copy{bottom:calc(92px + var(--safe-b));width:calc(6 * var(--col) + 5 * var(--gap));color:var(--warm)}
.chapter--surface .notes{margin-top:30px;max-width:520px}
.mode-3d .chapter--edition .copy{top:50%;translate:0 -52%;width:calc(5 * var(--col) + 4 * var(--gap));color:var(--ink)}
.chapter--space .support,.chapter--surface .support{color:var(--muted-warm)}
.chapter--form .support,.chapter--edition .support{color:var(--muted-ink)}
.progress{position:absolute;z-index:6;left:var(--gutter);right:var(--gutter);bottom:calc(22px + var(--safe-b));display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:18px;color:var(--hdr-fg);transition:color var(--dur) var(--ease);font:500 13px/1 var(--body);letter-spacing:.06em}
.progress .label,.progress .cue,.progress .closing-note{min-height:32px;display:inline-flex;align-items:center;gap:10px;padding:0 10px;background:rgb(var(--hdr-bg) / .55);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.progress .label{margin-left:-10px}
.progress .aside{display:inline-flex;align-items:center;min-height:32px;margin-right:-10px}
.progress .label .num{font-variant-numeric:tabular-nums;opacity:.7}
.progress .rule{position:relative;height:1px;background:color-mix(in srgb,currentColor 28%,transparent)}
.progress .fill{position:absolute;inset:0;background:currentColor;transform-origin:left;transform:scaleX(0)}
.progress .aside{justify-self:end;position:relative}
.cue,.closing-note{transition:opacity var(--dur) var(--ease)}
.closing-note{position:absolute;right:0;white-space:nowrap;opacity:0}
.cue svg{width:12px;height:12px}
.cue.is-hidden{opacity:0}
.scene-status{position:absolute;z-index:7;left:calc(var(--gutter) - 10px);top:calc(66px + var(--safe-t));margin:0;padding:7px 10px;font:500 13px/1.4 var(--body);letter-spacing:.04em;color:var(--hdr-fg);background:rgb(var(--hdr-bg) / .66);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
.scene-status:empty{display:none}
.scene-error{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;justify-content:center}
.mode-flow .scene-error{justify-content:flex-start}
.site-footer{font:500 13px/1.4 var(--body);letter-spacing:.04em;margin:0}
.mode-3d .site-footer{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-width:767px),(max-width:1023px) and (orientation:portrait){
  .mode-3d .copy{left:var(--gutter);right:var(--gutter);width:auto !important;top:auto !important;translate:none !important;bottom:calc(78px + var(--safe-b))}
  .mode-3d .chapter--form .copy{bottom:calc(76px + var(--safe-b))}
  h1.display{font-size:clamp(46px,12.6vw,92px)}
  h2.display{font-size:clamp(46px,12.2vw,88px)}
  .eyebrow{margin-bottom:12px;font-size:13px}
  .support{margin-top:14px;font-size:16px;max-width:34ch}
  .actions{margin-top:20px;gap:6px 22px}
  .chapter--surface .notes{margin-top:18px;font-size:13px}
  .progress{gap:12px;bottom:calc(14px + var(--safe-b));font-size:13px}
  .wordmark-3d{bottom:auto;top:var(--wm-top,36svh)}
  .mode-3d .scrim{display:block}
  .mode-3d .closing-note{display:none}
  .mode-3d .closing-inline{display:block;margin:14px 0 0;font:500 13px/1.4 var(--body);letter-spacing:.04em;color:var(--muted-ink)}
}
@media (min-width:768px) and (max-width:1023px) and (orientation:portrait){
  .mode-3d .copy{right:auto;width:min(560px,calc(100% - 2 * var(--gutter))) !important}
  .support{font-size:17px}
}
@media (max-width:767px) and (max-height:700px){
  h1.display,h2.display{font-size:clamp(42px,11.4vw,60px)}
  .support{font-size:16px;line-height:1.45}
  .eyebrow{margin-bottom:8px}
  .actions{margin-top:14px}
}
dialog{padding:0;border:0;color:var(--ink);background:var(--ivory);max-width:none;max-height:none}
dialog::backdrop{background:rgb(8 9 9 / .5)}
.sheet{position:fixed;inset:0 0 0 auto;margin:0;width:min(600px,100vw);height:100dvh;padding:calc(28px + var(--safe-t)) 48px calc(40px + var(--safe-b));display:none;flex-direction:column;overflow-y:auto;overscroll-behavior:contain}
.sheet[open]{display:flex;animation:torsion-sheet-in 220ms var(--ease)}
@keyframes torsion-sheet-in{from{transform:translateX(24px);opacity:0}}
.sheet-top{display:flex;justify-content:space-between;align-items:center;padding-bottom:18px;border-bottom:1px solid var(--ink);font:600 13px/1 var(--body);letter-spacing:.16em;text-transform:uppercase}
.sheet h2{font:700 clamp(64px,9vw,104px)/.88 var(--display);margin:56px 0 0}
.sheet .intro{font-size:20px;line-height:1.45;margin:22px 0 0;max-width:26ch}
.sheet dl{margin:44px 0 0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr)}
.sheet dt,.sheet dd{margin:0;padding:16px 0;border-top:1px solid rgb(17 18 16 / .22)}
.sheet dt{font:600 13px/1.5 var(--body);letter-spacing:.12em;text-transform:uppercase;color:var(--muted-ink)}
.sheet dd{font-size:17px}
.sheet .disclosure{margin:auto 0 0;padding-top:40px;font-size:15px;line-height:1.55;color:var(--muted-ink);max-width:44ch}
.sheet .btn{margin-top:24px;align-self:flex-start}
.icon-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-width:44px;min-height:44px;padding:0 4px;border:0;background:none;cursor:pointer;font:500 14px/1 var(--body);letter-spacing:.02em;text-transform:none}
.icon-btn svg{width:16px;height:16px}
.menu-sheet{position:fixed;inset:0;margin:0;width:100vw;height:100dvh;padding:calc(14px + var(--safe-t)) var(--gutter) calc(32px + var(--safe-b));display:none;flex-direction:column}
.menu-sheet[open]{display:flex;animation:torsion-fade-in 180ms var(--ease)}
@keyframes torsion-fade-in{from{opacity:0}}
.menu-sheet .sheet-top{border-bottom:0;padding-bottom:0}
.menu-sheet ol{list-style:none;margin:48px 0 0;padding:0;border-top:1px solid var(--ink)}
.menu-sheet li{border-bottom:1px solid rgb(17 18 16 / .22)}
.menu-sheet a{display:flex;align-items:baseline;gap:16px;padding:14px 0;text-decoration:none;font:700 52px/1 var(--display)}
.menu-sheet a .n{font:500 13px/1 var(--body);letter-spacing:.08em;opacity:.6}
.menu-sheet .foot{margin-top:auto;font-size:14px;color:var(--muted-ink)}
@media (max-width:767px){
  .sheet{inset:auto 0 0 0;width:100vw;height:auto;max-height:92dvh;padding:22px var(--gutter) calc(28px + var(--safe-b))}
  @keyframes torsion-sheet-in{from{transform:translateY(24px);opacity:0}}
  .sheet h2{margin-top:36px}
  .sheet .intro{font-size:18px}
  .sheet dl{margin-top:28px;grid-template-columns:1fr}
  .sheet dd{border-top:0;padding-top:0}
}
```

## 7. Motion

One rendered progress `p` drives everything. Target = `progressAt(scrollY, trackTop, trackHeight, stageHeight)`; each frame `p = advanceProgress(p, target, dt)`.

```js
const clamp01 = v => Math.max(0, Math.min(1, v)), lerp = (a, b, t) => a + (b - a) * t;
const ease5 = t => { t = clamp01(t); return t * t * t * (t * (t * 6 - 15) + 10); };
const ramp = (p, a, b) => ease5((p - a) / (b - a)), visibleBetween = (p, a, b, c, d) => ramp(p, a, b) * (1 - ramp(p, c, d));
const progressAt = (y, top, h, vh) => clamp01((y - top) / Math.max(1, h - vh));
function advanceProgress(cur, target, dt) {     // τ = 0.11 s
  const next = cur + (target - cur) * (1 - Math.exp(-Math.min(dt, 0.05) / 0.11));
  return Math.abs(next - target) < 0.0001 ? target : next;
}
const chapterOpacities = p => [1 - ramp(p, .09, .19), visibleBetween(p, .19, .28, .43, .53), visibleBetween(p, .52, .61, .74, .83), ramp(p, .83, .92)];
function backgroundWeights(p) {
  const wB = p < .61 ? ramp(p, .12, .29) : 0, wR = ramp(p, .47, .61) * (1 - ramp(p, .78, .91));
  return { wB, wR, eI: (1 - wB) * (1 - wR), eB: wB * (1 - wR), eR: wR };
}
function monotone(xs, ys) {     // shape-preserving cubic Hermite (Fritsch–Butland): no overshoot
  const n = xs.length, h = [], d = [], m = new Float64Array(n);
  for (let i = 0; i < n - 1; i++) { h[i] = xs[i + 1] - xs[i]; d[i] = (ys[i + 1] - ys[i]) / h[i]; }
  m[0] = d[0]; m[n - 1] = d[n - 2];
  for (let i = 1; i < n - 1; i++) {
    if (d[i - 1] * d[i] <= 0) { m[i] = 0; continue; }
    const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1]; m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]);
  }
  return x => {
    if (x <= xs[0]) return ys[0]; if (x >= xs[n - 1]) return ys[n - 1];
    let i = 0; while (x > xs[i + 1]) i++;
    const t = (x - xs[i]) / h[i], t2 = t * t, t3 = t2 * t;
    return (2*t3 - 3*t2 + 1) * ys[i] + (t3 - 2*t2 + t) * h[i] * m[i] + (-2*t3 + 3*t2) * ys[i + 1] + (t3 - t2) * h[i] * m[i + 1];
  };
}
```

- **Copy:** act i, `v = chapterOpacities(p)[i]`, `dir = p < [0, .355, .675, 1][i] ? 1 : −1`: `.copy` opacity `v` (0 below .002), `translate3d(0,(1−v)·18px·dir,0)`; each `.display .line > span` `translate3d(0,(1−v)·26px·dir,0)`. Controls are focusable/clickable only while `v > .55` (`tabIndex`, `.is-active`); blur focus in a fading act.
- **Wordmark:** `w = ramp(p, 0, .17)`: `translate3d(0, 14%·w, 0)`, opacity `1 − w`.
- **Backgrounds** (designed light changes, never flat crossfades):

```js
const radialMask = (at, a, b, invert) => `radial-gradient(ellipse farthest-corner at ${at}, ${invert ? '#000' : 'transparent'} ${a.toFixed(1)}%, ${invert ? 'transparent' : '#000'} ${b.toFixed(1)}%)`;
// setMask(el, value, opacity): mask-image + -webkit-mask-image (when changed) and opacity
function applyBackgroundMasks(p) {
  const tB = ramp(p, .12, .29), tR = ramp(p, .47, .61), tI = ramp(p, .78, .91);
  const b0 = lerp(160, -75, tB);    // ivory → black: darkness closes in from the edges toward the object
  setMask(bgBlack, tB >= 1 ? 'none' : radialMask('66% 46%', b0, b0 + 75, false), tB > 0 && tR < 1 ? '1' : '0');
  if (tR <= 0) return setMask(bgRed, 'none', '0');
  if (tI <= 0) { const r0 = lerp(-75, 160, tR);     // black → wine: red light blooms from the light field
    setMask(bgRed, tR >= 1 ? 'none' : radialMask('64% 40%', r0, r0 + 75, true), '1'); }
  else { const i0 = lerp(-75, 160, tI);              // wine → ivory: daylight opens from the centre
    setMask(bgRed, tI >= 1 ? 'none' : radialMask('62% 48%', i0, i0 + 75, false), tI >= 1 ? '0' : '1'); }
}
```

- **UI:** `--hdr-bg` = rounded `[238,236,229]·eI + [8,9,9]·eB + [74,17,27]·eR`; `--hdr-fg` warm white if `eB + eR > .5`. `.fill` `scaleX(p)`. Act = p < .18, < .515, < .8, else 4 → `.num`, `.name`, `html[data-chapter]` (`form|space|surface|edition`), `aria-current` on `.nav a` and `.menu-sheet a`. Cue `is-hidden` when target > .03 (and `visibility:hidden` once p > .05). Closing note opacity `ramp(p,.86,.94)`; scrim `1 − .75·ramp(p,.8,.92)`.
- **Camera** (degrees; world units with the sculpture 3.2 tall; `fx/fy` = NDC where the target lands; `ty` = target height), each channel `monotone(P, values)`:

```js
const P = [0, .08, .30, .44, .64, .74, .92, 1];
const TRACKS = {
  wide: { fov: 33, p: P,                                         // ≥768 landscape
    yaw:  [126, 110, 18, -2, -30, -38, -30, -30],    elev: [5, 7, 13, 16, 4, 6, 7, 7],
    dist: [4.4, 4.8, 6.6, 6.4, 2.9, 2.35, 7.4, 7.4], roll: [-20, -14, -4, 0, 4, 6, 0, 0],
    fx:   [.18, .2, .4, .38, .46, .52, .4, .4],      fy:   [0, 0, 0, 0, .02, .04, .04, .04],
    ty:   [.3, .2, 0, 0, .7, .85, -.08, -.08],
    ac:   [.2, .2, .22, .22, .55, .6, .18, .18] },   // fx += ac·(1.6/clamp(aspect,1.2,2.4) − 1)
  stacked: { fov: 41, p: P,                                      // <768, or 768–1023 portrait
    yaw:  [126, 110, 18, -2, -30, -38, -30, -30],    elev: [5, 7, 13, 16, 4, 6, 7, 7],
    fill: [1.33, 1.29, 1.03, 1.06, 2.3, 2.69, .97, .97], off: [-.05, -.03, .04, .04, .02, .02, 0, 0],
    roll: [-12, -9, -3, 0, 3, 4, 0, 0],              fx:   [.06, .05, 0, 0, .04, .06, .1, .1],
    ty:   [.2, .15, 0, 0, .7, .85, -.08, -.08],
    dist: [6.2, 6.4, 7.6, 7.4, 3.4, 2.9, 8.8, 8.8],  fy:   [.24, .26, .3, .3, .28, .28, .32, .32] }, // dist/fy: before the band is measured
};
const KEY_CHAPTER = [0, 0, 1, 1, 2, 2, 3, 3];   // stacked key → chapter band
const STACKED_QUERY = '(max-width: 767px), (max-width: 1023px) and (orientation: portrait)';
```

Expected: Act 01 a slender diagonal blade crossing the wordmark, past top and bottom; Act 02 the whole open loop at the right on near-black, ≈80% of the height; Act 03 inside the upper loop in wine light, metal past the edges; Act 04 the sculpture on its plinth at the right, ≈70% of the height.
- **Light** (`applyLight(bw)`, same p): `envW = (eI, eB, eR)`; metal `envMapIntensity = eI + 1.05·eB + eR`, plinth `.95·eI + .5·eB + .6·eR`; key `.9·eI + 1.4·eB + 1.3·eR`, rim `.5·eI + 2.2·eB + 1.6·eR`, both white lerped to `(1,.93,.88)` by `eR` (rim `.7·eR`); plinth colour `#d6d2c8 × (eI + .05·eB + .16·eR)`; ground shadow opacity `.8·eI`; contact shadow `.35 + .5·eI`.

## 8. Behaviour (JS)

One `<script type="module">`, in this order.

**8.1 Mode before first paint** (classic script in `<head>`; `<html>` starts with class `mode-flow`):

```js
(function () {
  var root = document.documentElement, reduce = matchMedia('(prefers-reduced-motion: reduce)').matches, gl = false;
  try {
    var c = document.createElement('canvas'), ctx = c.getContext('webgl2') || c.getContext('webgl');
    gl = !!ctx; var lose = ctx && ctx.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext();
  } catch (e) { gl = false; }
  root.dataset.webgl = gl ? 'yes' : 'no';
  if (gl && !reduce) {
    root.className = 'mode-3d';
    var url = 'https://cdn.orbital.supply/exp-0006/torsion-v1.glb';   // prefetch in parallel with the modules
    window.__torsionModel = { url: url, response: fetch(url).catch(function (e) { return e; }) };
  }
})();
```

**8.2 Measuring.** `measure()` = `measureBands()`, `trackTop` (document), `trackH = track.offsetHeight`, `vh = stage.offsetHeight`; `scrollYFor(i) = trackTop + [0,.35,.66,.96][i]·(trackH − vh)`.

```js
const bands = { valid: false, version: 0, frac: [0, 0, 0, 0], centre: [0, 0, 0, 0] };
function measureBands() {             // stacked + 3D only; layout positions, unaffected by transforms
  bands.valid = false;
  if (!stackedQuery.matches || mode !== '3d') return;
  const vh = stage.offsetHeight || innerHeight, top = header.offsetHeight + 6;
  copyEls.forEach((copy, i) => {
    const h = copy.querySelector('.display');
    const bottom = (i === 3 ? copy.offsetTop : copy.offsetTop + h.offsetTop) - 10;   // Act 04 stays above its eyebrow
    const band = Math.max(80, bottom - top);
    bands.frac[i] = band / vh; bands.centre[i] = 1 - 2 * ((top + band / 2) / vh);
  });
  bands.valid = true; bands.version++;
}
// sizeWordmark(): set --wm-size from the width of the inner <span> at 100px so TORSION spans 88% of innerWidth,
// capped at stageHeight × (stacked ? .2 : .47); stacked also sets --wm-top = 27% of stageHeight.
```

**8.3 Loop.** `kick()` requests one frame if none is pending, the page is visible and mode is 3D. `frame`: advance `renderP`, `applyUI`, then always `const tilting = engine ? engine.render(renderP, dt, pointer) : false;` (never short-circuit the render) and `kick()` while `tilting || renderP !== targetP`. Idle = no rendering.

**8.4 Navigation.** `a[data-nav]` clicks: `preventDefault`, close the menu (no scroll restore), `goTo(id)`; keyboard activation focuses the act's `.display` (no scroll). `goTo`: 3D → smooth `scrollTo(scrollYFor(i))`; flow → top of `#chapter-<id>` (0 for Form); then `history.pushState(null, '', '#' + id)` **in try/catch** (sandboxed frames may throw). `popstate` → `goTo(hash || 'form')`, no push; unknown hashes do nothing.

**8.5 Dialogs.** Open (`Explore Edition 01` → `#edition-details`, `.menu-btn` → `#menu`): remember opener and `scrollY`, add `html.is-locked`, `showModal()`. On `close`: remove the lock, restore `scrollY`, focus the opener. Backdrop click and close buttons call `close()`.

**8.6 Mode switch.** Keep the current act (flow: last `.chapter` above mid-viewport). Toggle `mode-3d`/`mode-flow`, strip inline styles of `.copy` and line spans. → flow: all acts active, stop the loop, remove `--hdr-bg/--hdr-fg`, scroll to the act. → 3D: measure, size the wordmark, instant scroll to `scrollYFor(act)`, sync `renderP`, full UI update. Reduced-motion change: 3D → flow; back only with WebGL and no pending failure.

**8.7 Loading.** `start3D()`: create the engine with a 25 s timeout; if timeout or error wins, release whatever the attempt created (also a late success) and fail. On success: measure, sync `renderP` to the **current** target, render once (`force`), next frame hide `.stage-poster` (`.is-hidden`), clear the status, set `html[data-scene="ready"]`. Never jump to the top. While loading, `.stage-poster` shows the act's loading poster (`mobile` while `STACKED_QUERY` matches, else `desktop`).

`fetchModel(url)`: use the head prefetch if it is for this URL, unused and not failed, else `fetch(url)` (a failed prefetch is retried once silently); throw on `!ok`; with `Content-Length` stream the body into a `Uint8Array` of that length (status in 5% steps; throw on overflow or truncation), otherwise `arrayBuffer()`.

**8.8 Status** (`#scene-status`): `Loading sculpture…` (`… NN%` in 5% steps), `Preparing sculpture…`, then empty. Failure: `<span class="scene-error">The 3D study could not load. You can still explore the collection below. <button class="btn" type="button">Retry 3D</button></span>`; without WebGL, the same span without the button.

**8.9 Failure & retry.** HTTP error, timeout, parse error or `webglcontextlost` (`preventDefault`): warn, release the engine, `forcedFlow = true`, failure status, flow at the current act. `Retry 3D`: `forcedFlow = false`, drop the prefetch, **fresh canvas** (never reuse a lost context), 3D at the same act, loading poster, `start3D()`.

**8.10 Engine** (`createEngine`). `const THREE = await import('three'); const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');` (dynamic imports). `WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' })`, clear alpha 0, `NeutralToneMapping`, exposure 1; DPR `min(devicePixelRatio, stacked ? 1.4 : 1.75)`; if more than 45 of 90 frames exceed 1/38 s, lower DPR by .25 (min 1). `PerspectiveCamera(33, 1, .05, 60)`. If setup throws after the renderer exists, dispose and rethrow.

Three studio environments, prefiltered once with `pmrem.fromScene(scene, 0.02, 0.1, 100, { size: 256 })` (then dispose scenes and generator): a `SphereGeometry(30, 64, 32)` `BackSide` `ShaderMaterial` (no depth write) coloured `y > 0 ? mix(mid, top, smoothstep(0, .9, y)) : mix(mid, bot, smoothstep(0, .3, −y))` (y of the normalised direction), plus DoubleSide `MeshBasicMaterial` planes `[w, h, x, y, z, r, g, b, i]` (colour `setRGB(r·i, g·i, b·i, LinearSRGBColorSpace)`) facing `lookAt(0, y·.35, 0)`:

| State | top / mid / bot | Planes |
|---|---|---|
| ivory | .78 .77 .75 / .2 .195 .185 / .3 .29 .27 | [9,3.5,0,9,3,1,1,1,3.4] [4,8,−9,2.5,5,1,.99,.97,2.8] [.9,9,8.5,1.5,−3,1,1,1,6] [3,7,3,1.5,−9,1,1,1,1.6] [5,11,7,.5,7,.012,.012,.012,1] [4,11,−6.5,.5,−7,.015,.015,.015,1] [2.5,11,−2,.5,9.5,.03,.03,.03,1] |
| black | .012 ×3 / .006 ×3 / .01 ×3 | [8,6,−7,4.5,6.5,1,1,1,6.5] [.35,10,7.5,1.5,−5.5,1,1,1,16] [.3,9,−8.5,0,−4,1,1,1,6] [5,5,0,10,−2,1,1,1,.45] |
| red | .05 .007 .012 / .12 .015 .028 / .06 .008 .014 | [7.5,5,6.5,5,6,1,.9,.84,5.2] [11,7,−8.5,.5,2,.62,.075,.12,.75] [.35,9,−6,2,−6.5,1,.88,.82,14] [.3,8,4,1,−8,1,.9,.85,8] [6,6,2,−6,6,.5,.06,.1,.35] |

`envs = [ivory, black, red]` are blended per pixel inside the standard shader:

```js
const envW = new THREE.Vector3(1, 0, 0);   // (eI, eB, eR), updated every frame
const envUniforms = { envA: { value: envs[0] }, envB: { value: envs[1] }, envC: { value: envs[2] }, envW: { value: envW } };
const ENV_CHUNK = `uniform sampler2D envA; uniform sampler2D envB; uniform sampler2D envC; uniform vec3 envW;
vec4 torsionEnv( vec3 d, float r ) { vec4 c = vec4( 0.0 );
  if ( envW.x > 0.001 ) c += envW.x * textureCubeUV( envA, d, r );
  if ( envW.y > 0.001 ) c += envW.y * textureCubeUV( envB, d, r );
  if ( envW.z > 0.001 ) c += envW.z * textureCubeUV( envC, d, r );
  return c; }
` + THREE.ShaderChunk.envmap_physical_pars_fragment.replaceAll('textureCubeUV( envMap, ', 'torsionEnv( ');
function useBlendedEnv(mat) {     // the three PMREM maps share one size, so the CUBE_UV defines match
  mat.envMap = envs[0];           // enables the CUBE_UV path; sampling is replaced below
  mat.onBeforeCompile = sh => { Object.assign(sh.uniforms, envUniforms);
    sh.fragmentShader = sh.fragmentShader.replace('#include <envmap_physical_pars_fragment>', ENV_CHUNK); };
  mat.customProgramCacheKey = () => 'torsion-env-blend';
  return mat;
}
```

Materials (both via `useBlendedEnv`): metal `MeshStandardMaterial({ color: linear (.91, .915, .92), metalness: 1, roughness: .3 })`, plinth `({ color: '#d6d2c8', metalness: 0, roughness: .88 })`. Lights: key `DirectionalLight` at (−4, 6, 5), rim at (5, 3, −6). Model: `parseAsync(buffer, '')`, every mesh metal. Groups `pose → norm → calib → gltf.scene`: `calib.rotation.y = −π/2`; from `Box3(calib)`: `norm.scale = 3.2 / height`, `calib.position = −centre` (support y = −1.6). Footprint = mean world x/z of vertices below −1.584. Plinth `CylinderGeometry(.86, .86, .13, 128)`, top at −1.594, on the footprint, in `pose`. Ground shadow: 2.924 plane (rotated −90° on x) just above the plinth base, 256² canvas `createRadialGradient(128, 128, 40, 128, 128, 128)` with stops 0 `rgba(0,0,0,.55)`, .45 `rgba(0,0,0,.22)`, 1 transparent; contact shadow: 1.72 plane at plinth top + .0015 with the baked texture; both `MeshBasicMaterial` with the texture as `map` (renders black, alpha from the texture), transparent, no depth write, `toneMapped: false`.

Contact shadow, baked once from the real mesh: `OrthographicCamera(−.86, .86, .86, −.86, 0, 1.1)`, `up = (0, 0, 1)`, at (foot.x, plinthTop − .0005, foot.z) looking **up** at (foot.x, plinthTop + 1, foot.z), renders `norm.clone(true)` with a double-sided `ShaderMaterial` writing `vec4(0, 0, 0, pow(1 − clamp((worldY − plinthTop) / 1.1, 0, 1), 3))` into a 512² target (no tone mapping, clear alpha 0); then three separable 9-tap Gaussian passes (weights .2270270270, .1945945946, .1216216216, .0540540541, .0162162162; radii 1.2, 2.2, 3.4 texels; ping-pong) on a full-screen quad (`vec4(position.xy, 0, 1)`, `frustumCulled = false`). Restore target and tone mapping; texture `repeat (1, −1)`, `offset (0, 1)` for the −90°-rotated plane.

```js
const D2R = Math.PI / 180, H_EFF = 3.45;   // sculpture + plinth height
function render(p, dt, ptr, force) {         // true while the tilt settles
  resize();
  const L = stackedQuery.matches ? 'stacked' : 'wide', s = splines[L];   // s[ch] = monotone(P, TRACKS[L][ch])
  const act2 = visibleBetween(p, .19, .28, .43, .53), allow = finePointer.matches && !reduceQuery.matches;
  const tx = allow ? ptr.y * 1.5 * act2 : 0, ty = allow ? ptr.x * 1.5 * act2 : 0;
  const k = dt > 0 ? 1 - Math.exp(-dt / 0.35) : 1;
  tilt.x += (tx - tilt.x) * k; tilt.y += (ty - tilt.y) * k;
  const busy = Math.abs(tx - tilt.x) > 0.002 || Math.abs(ty - tilt.y) > 0.002;
  const key = `${L}|${p.toFixed(5)}|${tilt.x.toFixed(3)}|${tilt.y.toFixed(3)}|${w}x${h}|${bands.version}`;
  if (key === lastKey && !force) return busy;
  lastKey = key; if (dt) watch(dt);
  const yaw = s.yaw(p) * D2R, elev = s.elev(p) * D2R, roll = s.roll(p) * D2R;
  let dist = s.dist(p), fx = s.fx(p), fy = s.fy?.(p) ?? 0;
  const band = L === 'stacked' && bands.valid ? bandAt(p) : null;   // monotone(P, KEY_CHAPTER.map(i => bands.frac/centre[i])), rebuilt per bands.version
  if (band) { dist = H_EFF / (2 * Math.tan(s.fov * D2R / 2) * s.fill(p) * band.frac); fy = band.centre + s.off(p); }
  if (s.ac) fx += s.ac(p) * (1.6 / Math.min(2.4, Math.max(1.2, camera.aspect)) - 1);
  target.set(0, s.ty(p), 0);
  camera.position.set(Math.sin(yaw) * Math.cos(elev) * dist, target.y + Math.sin(elev) * dist, Math.cos(yaw) * Math.cos(elev) * dist);
  camera.up.set(0, 1, 0); camera.lookAt(target); camera.rotateZ(-roll);
  camera.fov = s.fov; camera.near = Math.max(0.05, dist - 2.6); camera.far = dist + 6;
  camera.updateProjectionMatrix();
  camera.projectionMatrix.elements[8] = -fx;   // lens shift: the target lands at NDC (fx, fy)
  camera.projectionMatrix.elements[9] = -fy;
  camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
  pose.rotation.set(tilt.x * D2R, tilt.y * D2R, 0);
  applyLight(backgroundWeights(p));
  renderer.render(scene, camera);
  return busy;
}
```

`resize()`: on a canvas size/DPR change → `setPixelRatio`, `setSize(w, h, false)`, `camera.aspect`. `dispose()` frees every GPU resource and the renderer.

**8.11 Events & boot.** `scroll` (passive) → target, `kick()`. `resize` → measure, size the wordmark; on a width change or height change > 160px keep the progress. Mouse `pointermove` → `pointer = (clientX/innerWidth·2−1, clientY/innerHeight·2−1)`. Hidden page → stop the loop. Boot 3D: measure, size the wordmark, honour a hash, sync `renderP`, `applyUI`, loading poster, `start3D()`, re-measure after `document.fonts.ready`. Boot flow: all acts active, honour the hash.

## 9. Responsive

Wide (≥768 landscape): track/FOV of section 7, copy on the left. Stacked (<768, or 768–1023 portrait): object in the upper field fitted to the measured band, copy at the bottom over the scrim, menu button instead of nav (all CSS in section 6). No horizontal scroll at 375 · 390 · 768 · 1024 · 1440 · 1920; on ≈390×667 the sculpture and plinth stay above the Act 04 eyebrow. Touch: no tilt.

## 10. Accessibility & performance

One `<h1>`; skip link; decorative layers `aria-hidden`, `#scene-description` for the object; visible focus, ≥44px targets, dialogs return focus, inactive acts not focusable. Render on demand, one loop, paused while hidden; the model is fetched once.

## 11. Acceptance checklist

- [ ] First paint: Act 01, wordmark, loading poster, `Loading sculpture…`; then the scene at the current progress (also `/#surface`).
- [ ] Within the first half-screen the blade orbits open and the wordmark fades; four distinct compositions; at rest only one act's copy shows.
- [ ] Links, Discover, Replay, hashes and back/forward reach the right act, also in an `allow-scripts allow-popups` sandboxed iframe; the dialog closes by Escape, backdrop and buttons and returns focus.
- [ ] Model blocked or context lost → flow page, failure sentence, `Retry 3D` restores 3D at the same act; no WebGL → flow page with the sentence.
- [ ] Reduced motion → flow page; three and the model are not downloaded.
- [ ] Zero console errors · only section 1 dependencies · only section 2 URLs.
Volver a las plantillas