Codescan
aadmin2026-11-05 发布Site模板1 阅读

Codescan

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

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

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. ES modules inline in a
`<script type="module">`. **three.js and its addons are the only external code**, pulled through an
import map; the spring solver, the shared ticker, the scroll filter, the panel stack, the pixel
wave, the team ring and the scattered text are all written by hand. Hardcode every value given here
as a fixed constant.

## What it is

A **one-page scroll-driven film** for **Codescan**, an eight-person design studio. The whole page is
a single WebGL shot, eight-and-a-bit screens tall, watched through a CRT: a dark room where a figure
stands with its back to you facing a wall of fourteen televisions, each playing its own clip.

Four acts share that scroll:

1. **The room.** The lens drifts after your cursor. Hovering a television **raises it and everything
   stacked on it**, turns it, and **racks focus onto it**; a chip on the cursor types out the name of
   the work on its glass — or says *Coming soon…* over the sets showing noise, without an arrow,
   because there is nothing to explore there.
2. **The flight.** The camera flies level down the room, through the figure (which dissolves), out
   the far side of the wall, and along a corridor of empty fog with **two lines standing in it** —
   the near one large and just off axis, the far one small and dead centre — that lift out of the
   haze one after the other, letter by letter.
3. **The carousel.** Five televisions come at the lens out of the fog and assemble into a **ring**;
   *Previous* / *Next* turn it, and the work's name and tags change with the picture.
4. **The panels and the finale.** Two full-window black panels slide up over the room — the team on a
   draggable 3D card ring, then the studio — the first arriving as a **wave of pixels** and the last
   leaving the same way, uncovering a **closing shot**: the same figure again, close and from behind,
   with one television at its shoulder carrying a *Start a Project* still, the horizon thrown off
   level, and a hairline rule under the words that is **cut exactly where the set begins**.

Over all of it sits a fixed chrome: a translucent nav pill, and a hairline frame with bracketed
corners and four readouts (fps, REC, the date, a running clock).

## Page shell & libraries

- **Import map:**
  ```html
  <script type="importmap">
  {"imports":{
    "three": "https://unpkg.com/three@0.185.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.185.0/examples/jsm/"
  }}
  </script>
  ```
  From `three/addons/` you need: `environments/RoomEnvironment.js`, `loaders/GLTFLoader.js`,
  `loaders/DRACOLoader.js`, and `postprocessing/{EffectComposer,RenderPass,ShaderPass,OutputPass,
  UnrealBloomPass,BokehPass,FilmPass}.js`. The Draco decoder is at
  `https://unpkg.com/three@0.185.0/examples/jsm/libs/draco/gltf/`.
  **Nothing else is a library.**
- **Fonts, from Google Fonts:**
  ```html
  <link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;600&family=Big+Shoulders+Inline:opsz,wght@10..72,400&display=swap" rel="stylesheet">
  ```
  `--font-sans: "Chakra Petch"` for everything; `--font-display: "Big Shoulders Inline"` — an
  **inline** face, its strokes drawn hollow — for headings only.
- **One shared rAF loop.** A `Set` of subscribers, each with its own minimum frame gap in ms read
  live every frame; the loop starts on the first subscriber and `cancelAnimationFrame`s when the
  last leaves. The scene, the preloader, the readouts, the hero copy, the chip, the panel stack, the
  ring and the case panel all subscribe to it. A subscriber that throws is caught and logged.
- **No scrollbar.** `html { scrollbar-width: none }` plus `html::-webkit-scrollbar { display: none }`.
  The page is one continuous shot and the bar is a ruler laid down its right edge — it reads the
  height of the *document*, which here is only a device for driving the scene. Everything still
  scrolls by every means it did; only the drag handle is lost.
- `body { background: #ffffff; color: #171717; min-height: 100lvh }` — inverted to `#0a0a0a` /
  `#ededed` under `prefers-color-scheme: dark`. The canvas fills every pixel, so the page colour is
  only what shows before it.

### The adaptive grid (root font-size ladder)

Reproduce this ladder exactly, in this order:

```css
html { font-size: 16px; }
@media (min-width: 1920.02px) { html { font-size: calc(5.3344px + 0.5555vw); } }
@media (max-width: 1920px)    { html { font-size: 0.833333vw; } }
@media (max-width: 1440px)    { html { font-size: 1.111111vw; } }
@media (max-width: 1023.98px) { html { font-size: max(1.5625vw, 12px); } }
@media (max-width: 639.98px)  { html { font-size: min(4.444444vw, 18px); } }
```

Each range divides 16px by the design base width it was laid out at. **The two lower ranges carry a
floor and a ceiling**, which the plain ladder does not: unclamped they gave a 10px root at 641px (the
whole page at 62%, body copy at ten pixels) and a 28px root at 640px (the phone layout at 1.8×, a nav
pill wider than the window). Below the floor the layout **reflows** instead of going on shrinking.

> The rule above 1920 is the project's `AdaptiveGrid` at its default damping (`coef 0.6666`), i.e.
> `16 * (1 - 0.6666 * (1920 - w) / 1920)` px, written as CSS. It is the same number at every width.

**Breakpoints are Tailwind's defaults**: `sm` 640, `lg` 1024.

## Design tokens

```css
:root {
  --raw-color-white: #ffffff;
  --raw-color-mint-100: #dbfffe;   /* the hero's chrome, Figma node 1097:251 */
  --raw-color-black: #000000;
  --raw-color-neutral-100: #ededed;
  --raw-color-neutral-900: #171717;
  --raw-color-neutral-950: #0a0a0a;
  --duration-fast: 150ms;
  --duration-normal: 250ms;
  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);

  /* Chrome over the scene. The rules are HALF-strength white and the corners are
     FULL — that contrast is the design's, and it is what makes the corners read
     as brackets rather than as part of the box. */
  --hud-rule: color-mix(in srgb, #ffffff 50%, transparent);
  --hud-corner: #ffffff;
  --hud-text: #ffffff;
  --nav-surface: color-mix(in srgb, #ffffff 20%, transparent);
  --nav-text: #ffffff;
  --nav-accent: #dbfffe;
  --nav-accent-ink: #000000;
}
```

## The spring solver

Every reveal is a **physical spring** (tension / friction / mass), advanced once per frame from the
shared ticker. Semi-implicit Euler at a fixed 1/120 s substep; clamp the frame delta to 64 ms so a
backgrounded tab cannot explode it; settled when `|v| < 0.01` and `|x − goal| < 0.0001`. It must
support a **per-start config** and a **per-start delay**. Under `prefers-reduced-motion: reduce`
every spring resolves instantly to its goal.

Configs used, verbatim:

| where | tension | friction | mass |
|---|---|---|---|
| camera parallax | 90 | 34 | 1.4 |
| television rise | 150 | 26 | 1 |
| television release | 110 | 26 | 1 |
| rack focus | 110 | 30 | 1.2 |
| carousel rotation | 85 | 26 | 1 |
| carousel hover lift | 150 | 26 | 1 |
| closing-set hover | 120 | 26 | 1 |
| curtain lift | 82 | 26 | 1 |
| about-panel card trail | 120 | 30 | 1 |
| case-panel fade / swap | 170 | 26 | 1 |
| scattered letter | 42→190 (per letter) | 26 + pace×10 | 1 |

## Scroll progress — read once, filtered once

```js
scrollProgress() = window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)
```

`scrollHeight` is a **layout read**, so cache it and re-measure only on `resize` and on a
`ResizeObserver` over the document element. Then low-pass it **once for the whole page**:

```js
SMOOTHING = 0.2            // retention per 60Hz frame
SNAP      = 0.2            // past this a jump is an anchor or a reload: assign, don't crawl
smoothed += (raw - smoothed) * (1 - Math.pow(1 - SMOOTHING, dt * 60))   // dt clamped to 0.05
```

Frame-rate independent, so a capped tier eases at the same rate in *time*. **One filter upstream**
is the point: the scene used to ease its own copy while the panels and the copy over it read the
value raw, and that difference is exactly where the scroll felt like it was catching.

## Device tiers

```js
hasFinePointer() = matchMedia("(hover: hover) and (pointer: fine)").matches
isMobile()       = matchMedia("(hover: none) and (pointer: coarse)").matches
                   || (innerWidth < 768 && !hasFinePointer())
frameBudget()    = isMobile() ? 1000/30 : 0
```

Width alone is wrong: a desktop browser in a narrow window, or a preview pane inside an editor, is
not a phone, and it was being handed the phone tier — no depth of field, half the bloom, a 30 fps cap
— which reads as the scene being broken in one window and fine in another.

**Pixel ratio is clamped twice** — by tier, and again by a fragment budget:

```js
MAX_FRAGMENTS = 3_200_000
tier  = min(max(devicePixelRatio, 1), isMobile() ? 1 : 1.5)
ratio = max(1, min(tier, sqrt(MAX_FRAGMENTS / (width * height))))
```

The frame goes through bloom (five mip levels), a **41-tap** bokeh, grain and the CRT glass, so its
cost is close to linear in pixels with a large constant. A 2560×1440 window at 1.5× is 8.3M pixels —
three times the design size — and that is what *"the site is smooth but the videos stutter"* looks
like from the inside: the loop lands at 30 fps and every video texture with it. **`min` still wins**:
below one device pixel per CSS pixel the text in the scene's own textures breaks up.

## The scene

### Camera, room, atmosphere

```js
camera:   position [0, 1.42, 5.6], pitch −0.105, fov 40, near 0.1, far 60, rotation.order "YXZ"
parallax: maxYaw 0.2, maxPitch 0.115
environment:
  backgroundGradient ["#aad4d5", "#17242c"], backgroundHorizon 0.45
  keyLight  { position [3.5, 6, 5], intensity 1.09, color "#9fc4d6" }
  environmentIntensity 0.2, exposure 0.8 (ACES filmic)
  fog { color "#17242c", mode "exp2", density 0.075 }     // near 2 / far 16 in linear mode
  floorColor "#0b0f13", floorScale 3, screenGlow 1.44
  spillLights:
    { [-2.8, 1.6, 3.2], "#5fd8ff", 4.5, 9 }
    { [ 2.8, 1.7, 3.0], "#7fe0ff", 4,   9 }
    { [-2.9, 0.6, 3.6], "#ff7a3c", 3.2, 7 }
```

`rotation.order = "YXZ"` applies yaw before pitch; the default XYZ order leaks visible roll into the
frame once both axes are non-zero.

**Three lamps, not fifteen** — every real-time light multiplies the fragment cost of every material.
They stand in the clear band between the figure and the front row (z ≈ 3.2), **never among the sets**:
inside the wall, any television that lifted or drifted near one caught a blown-out patch, which the
bloom then spread into a coloured blob. The fog colour is **lifted off black on purpose**: a
pure-black figure needs something behind it to be a silhouette against.

**The floor** ships white and barely wider than the wall. Darken it to `floorColor` (a clone of its
material — `roughness = min(shipped, 0.55)`, `metalness = 0.15`, so the screens leave a smear on it)
and **scale it ×3 on x and z**: at its shipped size the far edge lands where the fog is only part-way
opaque, and an edge you can still see is a hard line between floor and sky.

### The sky is a DOME, not `scene.background`

A plain texture background is **screen space**: three stretches it across the frame, so its horizon
sits at the same height no matter where the camera looks or flies. Every other thing in the frame
moves; the horizon does not, and the eye reads the difference as *the horizon sliding*. Giving the
texture `EquirectangularReflectionMapping` fixes the direction and quietly sends it through
`PMREMGenerator`, whose result is cached against the texture object — repaint and the sky never
changes again; hand over a new texture per step and every step pays for a PMREM conversion.

So: a `SphereGeometry(camera.far * 0.8, 32, 16)`, `side: BackSide`, `depthWrite: false`,
`depthTest: false`, `toneMapped: false`, `fog: false`, `renderOrder: -1`, `frustumCulled: false`,
its position copied from the camera every frame. **Shaders verbatim:**

```glsl
// vertex
varying vec3 vDirection;
void main() {
  // The dome is a unit sphere, so its own vertex position *is* the direction
  // it is seen in — no need to reconstruct one from the camera.
  vDirection = position;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
```

```glsl
// fragment
uniform vec3 uTop; uniform vec3 uBottom;
uniform float uTopAngle; uniform float uHorizonAngle;
varying vec3 vDirection;
void main() {
  // Latitude, not screen height: this is what ties the gradient to the world.
  float latitude = asin(clamp(normalize(vDirection).y, -1.0, 1.0));
  // Smoothstepped, not linear. A linear ramp meets its own flat bottom band at
  // an angle, and that kink is a visible line across the sky.
  float k = smoothstep(0.0, 1.0,
    clamp((latitude - uHorizonAngle) / max(uTopAngle - uHorizonAngle, 0.001), 0.0, 1.0));
  gl_FragColor = vec4(mix(uBottom, uTop, k), 1.0);
}
```

with `uTopAngle = fov·π/360` and `uHorizonAngle = uTopAngle − horizon·2·uTopAngle`. The lower flat
band is load-bearing: fog colours geometry but never the sky, so where the floor fades out the sky
behind it has to **already be the fog's colour**.

### The composer — scene → defocus → bloom → tone map → grain → CRT

Order is the point. Depth of field runs **before** the bloom so the glow spreads from already-soft
screens rather than being smeared afterwards, and the grain sits **after** the output pass so it lands
in display space the way film grain actually does.

```js
bloom: { strength 0.18, radius 1, threshold 0.6, knee 0.45, fine 0, mobileScale 0.5 }
depthOfField: { focus 5.25, aperture 0.0032, maxBlur 22 /* PIXELS */ }
grain: 0.3
```

- **The threshold is what makes this a bloom and not a veil.** It has to sit above the room and below
  the screens. Letting everything through and compensating with a low strength was tried at length,
  and lifts the blacks across the whole frame instead.
- **`knee` → `bloomPass.highPassUniforms.smoothWidth`.** `UnrealBloomPass` clips at the threshold by
  default (`0.01`), which hands the blur a *hard-edged* cut-out of the bright parts: what comes back
  is a rim tracing the screen rather than a glow spilling off it.
- **`fine` mutes the two sharpest blur levels**: `bloomTintColors[0].setScalar(fine)` and
  `[1].setScalar(0.4 + 0.6·fine)`. The pass adds five blurred copies from half-resolution down; the
  sharp ones are barely blurred at all, so what they contribute is a bright fringe hugging the
  glass — an outline, however the threshold and radius are set. At `0` only the wide, low levels are
  left, which is the part that actually looks like light in a room.
- **`maxblur` is written in pixels of the page and divided by the container width every resize.** The
  shader's own value is in UV, so a fixed one is a *wider* blur the bigger the window, and past about
  ten pixels of radius the 41 taps stop overlapping and a defocused edge breaks into ghost copies of
  itself. In UV that threshold is a different number on every display.
- **Mobile builds no bokeh pass at all** (it costs a second scene render for depth) and halves the
  bloom's strength and radius.
- The composer **owns its own render targets**: call `composer.setPixelRatio(ratio)` as well as the
  renderer's, or the whole DPR saving is thrown away on the post chain, which is where nearly all of
  the fill is.

### The CRT glass (verbatim)

Last, after the tone map, in display space — scanlines are a property of the glass, not of the light
in the room.

```glsl
uniform sampler2D tDiffuse; uniform float uTime; uniform vec2 uResolution;
uniform float uScanline; uniform float uScanCount; uniform float uVignette;
uniform float uRoll; uniform float uRollSpeed; uniform float uChroma;
varying vec2 vUv;

void main() {
  vec2 uv = vUv;
  // Distance from the middle drives the fringing: a lens is sharp where you
  // are looking and splits colour at its edges, never uniformly.
  float edge = length(uv - 0.5);
  vec2 shift = vec2(uChroma * edge, 0.0);

  vec3 color = vec3(
    texture2D(tDiffuse, uv + shift).r,
    texture2D(tDiffuse, uv).g,
    texture2D(tDiffuse, uv - shift).b
  );

  // A COUNT down the frame, the way a tube has a line count — not a pitch in
  // pixels. Capped so the pitch never falls under about two pixels of the page,
  // which is where lines turn into moiré.
  float count = min(uScanCount, uResolution.y / 2.2);
  float scan = 0.5 + 0.5 * cos(uv.y * count * 6.28318531);
  color *= 1.0 - uScanline * scan;

  // The slow bright band an unsynced set drifts down its own picture. Wide and
  // soft: a sharp one is a bug, a broad one is a memory.
  float band = fract(uv.y * 0.6 - uTime * uRollSpeed);
  float glow = smoothstep(0.0, 0.12, band) * (1.0 - smoothstep(0.12, 0.4, band));
  color += glow * uRoll;

  // Elliptical rather than round, so it follows the frame instead of cropping a
  // circle out of it, and squared so only the outer third darkens.
  vec2 fromCentre = (uv - 0.5) * 2.0;
  float corner = dot(fromCentre, fromCentre);
  color *= 1.0 - uVignette * smoothstep(0.35, 1.9, corner);

  gl_FragColor = vec4(color, 1.0);
}
```

`{ scanline 0.16, scanCount 300, vignette 0.78, roll 0.012, rollSpeed 0.06, chroma 0.0008 }`.
**`uResolution` is set in CSS pixels, not device ones**: two device pixels apart is under a pixel of
the page on a dense display, and what should read as lines flattens into a uniform dimming.

### The screens

A screen is matched to its clip **by the name of the texture the model already wears** — `cat.mp4`
replaces the texture named `cat`. That keeps the mapping in the model. A screen is taken if it is
*either* named `screen <n>` *or* wearing a texture a clip is named after: the model has one screen
that kept its exported name when the others were renamed, and a name-only rule left it a still
picture among playing neighbours. One override by screen number (`"12" → noise.mp4`) beats the match.

Screens **share materials** in the export (eight across fourteen), so a screen that gets a video gets
its own **clone**. Playback is shared: one `<video>` and one `VideoTexture` per distinct file.

```js
video: loop, muted, playsInline (+ the attribute), preload "auto", crossOrigin "anonymous"
texture: colorSpace SRGB, flipY = FALSE, name = the clip's stem
material: emissive #ffffff, emissiveMap = map, emissiveIntensity = 1.44,
          roughness 1, metalness 0, envMapIntensity 0, toneMapped true
```

- `flipY = false` because glTF UVs have their origin top-left — a `VideoTexture` defaults the other
  way and plays upside down on these quads.
- **Matte glass.** The screens are lit from inside, so a specular response is pure liability: it
  catches the room's point lights and drops a hard white blob on the picture.
- Every screen glows, video or still — the picture drives its own light, which is what the bloom
  then blooms.
- `setPlaying(false)` **pauses every decoder** whenever the scene stops drawing.

### Hover: the stacks

The GLB names every television with a bare number (`"1"`…`"15"`) and gives the floor (`Plane`) and
the figure real names, so the numbered roots are the pickable units. Which television rests on which
is **derived from world bounding boxes**, never hardcoded:

```js
b rests on a  ⟺  b.min.y > a.min.y
              && |b.min.y − a.max.y| ≤ 0.22          // stack.gap
              && footprintOverlap(a, b) ≥ 0.3        // stack.overlap, share of the SMALLER footprint
```

The relation is **directed and upward only**. A connected-component grouping was tried first and
merged two televisions standing apart on the floor, because a third bridged them by resting on both.
Comparing the signed gap alone would also match two sets standing side by side, which is why the
`min.y > min.y` test is there.

Each target caches `basePosition`, `baseYaw/Pitch/Roll`, a `pivot` (the footprint centre at the
node's own y), a `phase` of `index × 2.399` (irrational, so the phases never line up again a few
televisions later) and a `swing` of `±1`. Its proxy box is grown by `lift × 4` so a raised set stays
pickable.

**Picking is two-phase**: a ray against one world AABB per television narrows the field, then a real
mesh raycast against just those decides. Boxes alone are not enough — they overlap heavily on a wall
this dense. The **figure joins the narrow phase as an occluder**, so a television behind a solid body
must not light up through it.

> **Pick against the wall AT REST.** Write every displaced television back to `steps = 0`, pick, then
> write it back. Picking the animated pose feeds back on itself: a television that rises moves out
> from under the cursor, the next pick lands on whatever was behind it, and the hover hands itself to
> a neighbour — which reads as *"I hovered the top one and the bottom one rose"*.

On hover, walk the stack above and give each one `steps = depth + 1` with `delay = depth × 45 ms`.
**The step count goes inside the spring, not outside it**: moving the pointer up a stack changes a
television's depth, and with the count outside, that switch teleported it down two steps mid-flight.

Per frame, for anything not at rest (`steps ≥ 0.0005`):

```js
raised = min(steps, 1)                       // the turn is done by the first step and holds
clock  = time * 1.05 + phase                 // hover.float.speed
drift  = raised * swing
yaw    = raised * 0.14 + sin(clock) * 0.07 * drift
lift   = steps * 0.26 + sin(clock * 0.83) * 0.075 * raised
position = (basePosition − pivot) rotated by yaw about UP, + pivot, with y = basePosition.y + lift
rotation = (basePitch + sin(clock*0.61)*0.05*drift,  baseYaw + yaw,  baseRoll + cos(clock*0.47)*0.05*drift)
updateMatrixWorld(true)                      // so a raycast in the same frame sees this pose
```

The idle drift is **scaled by how far up the set is**, so it fades in with the rise and is gone by
the time it lands — nothing twitches at rest.

**Rack focus** re-reads its goal every frame rather than setting it on hover: a hovered television is
rising and drifting, so its depth keeps changing while it is held. The goal is the hovered set's
**depth along the camera's view axis** (`(boxCentre − cameraPos) · forward`), not straight-line
distance — that is what the bokeh shader compares its depth buffer against.

### The dust

One `Points` object, one draw call, **all of the motion in the vertex shader**. 9000 motes on
desktop, 35% of that on mobile, in a box of half-extents `[7, 4, 16]`, `size 4.6`, colour `#dcefff`,
`opacity 0.65`, `drift 0.45`, `speed 0.12`. Positions and seeds are **deterministic** —
`sin(i * 12.9898) * 43758.5453` and friends, never `Math.random`, so the same scene loads every time
and a screenshot comparison means something.

```glsl
// vertex
vec3 wander = vec3(
  sin(uTime * 0.7 + aSeed.x * 6.283),
  sin(uTime * 0.5 + aSeed.y * 6.283),
  cos(uTime * 0.6 + aSeed.z * 6.283)
) * uDrift;
vec4 world = modelMatrix * vec4(position + wander, 1.0);
vec4 view  = viewMatrix * world;
vDepth = -view.z;
vFade  = smoothstep(0.4, 2.0, vDepth) * (1.0 - smoothstep(5.0, 13.0, vDepth));
gl_Position = projectionMatrix * view;
gl_PointSize = uSize * aScale * uViewportHeight / max(-view.z, 0.1);
```

```glsl
// fragment
#include <packing>
float d = length(gl_PointCoord - 0.5);
float alpha = (1.0 - smoothstep(0.4, 0.5, d)) * uOpacity * vFade;
#ifdef USE_SCENE_DEPTH
vec2 screen = gl_FragCoord.xy / uResolution;
float scene = -perspectiveDepthToViewZ(
  unpackRGBAToDepth(texture2D(uSceneDepth, screen)), uClip.x, uClip.y);
alpha *= mix(1.0, 1.0 - smoothstep(0.0, 0.3, vDepth - scene), uDepthMix);
#endif
if (alpha < 0.01) discard;
gl_FragColor = vec4(uColor, alpha);
```

- **A crisp disc, not a soft blob**: full opacity to the rim and only the last fraction feathered,
  which is what stops a mote reading as a smudge.
- `gl_PointSize` is scaled by the **drawing buffer's height** (`height / 1000`), or a mote is a fixed
  number of pixels: plainly visible in a small window and invisible in a large one.
- **The cloud lives in a scene of its own and is drawn to the canvas *after* the composer**, with
  `renderer.autoClear` turned off around the call. `BokehPass` blurs on three concentric rings, and a
  mote — a bright dot two pixels wide — is the one input that makes that sampling pattern legible, as
  a rosette of dots around every speck. `depthTest: false`, `AdditiveBlending`, `frustumCulled: false`
  (the shader moves the motes, so the computed bounding sphere is a lie).
- It reads the **bokeh pass's depth texture** to sink behind whatever is in front of it — faded, not
  clipped, or the boundary flickers. `uDepthMix` goes to `0` whenever the bokeh pass is disabled,
  because the depth it renders goes stale with it; a uniform costs a multiply, a `#define` costs a
  recompile mid-scroll.
- Its program is **prewarmed separately** (`compileAsync` on its own scene) — the main scene's
  compile never sees it.

### The signs

Two lines standing in the corridor, as **geometry, not DOM**: the near one passes the lens while the
far one is still ahead of it, and that parallax is the whole effect. A `div` has one depth. Being in
the scene also gets them the fog, the defocus and the grain for nothing.

**Canvas textures, not a text mesh** — a glyph mesh means a font atlas and a loader for it; a 2D
canvas already has the page's own webfont. `MeshBasicMaterial`, `transparent`, `depthWrite: false`,
`renderOrder: -1`, on a shared `PlaneGeometry(1, 1)`.

```js
{ text: "Built to hold attention.",                position: [-0.28, 1.6, -7.6], size: 0.34, fadeIn: [5, 3.2] }
{ text: "A signal in the noise, tuned only for you.", position: [0, 1.18, -9.3], size: 0.13, fadeIn: [3, 1.8] }
signs: { color "#dbfffe", fadeIn [6, 4], fadeOut [1.5, 0.25], clearOf [-3.2, -4.8], opacity 0.92 }
```

Rasterising: measure at 100px, then `font = clamp(24, 190, (2048 / (probe + 100·0.3·2)) · 100)`, canvas
`ceil(probe·scale + font·0.6) × ceil(font·1.5)`, and scale the quad by `(canvas.w / font) × size` and
`(canvas.h / font) × size` — which is what makes `size` mean *em height in world units* rather than
"whatever this string happened to need". **Resizing the canvas resets every bit of context state,
the font included** — set it again after.

The reveal is **per letter**: each letter's ramp starts at `seed × 0.55` and runs over what is left,
so they all finish together and none of them starts in order. Redraw on a **quantised** reveal — 24
steps across the whole arrival — because a canvas repaint per frame for a line that takes half a
second is 24 repaints' worth of work spread over 30. Redraw once more on `document.fonts.ready`: the
first draw can land before the webfont arrives and rasterise the fallback.

The fade is driven by the **camera's z**, not the scroll — what they answer to is being flown past —
and gated a second time on `clearOf`: a line lifts out of the fog six units ahead, and six units
ahead of the wall is still *inside* it.

### The carousel

A **ring**. Each member holds a fixed index; what moves is the ring's rotation, and a member's
**slot** is the signed distance from its index to the front, wrapped into `±count/2`. Turning past
the end wraps to the other side with no special case and no re-ordering — and because the wrap
happens at the ring's far side, buried in the fog, it is never seen.

```js
carousel: { center [0, -0.68, -4.2], spread 3, bulge 0, fogDepth 20, scale 1.6,
            hideBeyond 1.5, entrance [0.133, 0.3], closingIndex 0 }
CURVE_AWAY = [1.5, 2.5]
positionOf(slot):
  away  = smoothstep(clamp01((|slot| − 1.5) / 1.0))
  x = center.x + spread·slot·(1 − away)
  y = center.y
  z = (center.z + bulge·slot²)·(1 − away) + (center.z − fogDepth)·away
  … + the camera's resting place (position + [0, rise, dolly])
```

**`hideBeyond` must not exceed the start of the curve-away.** Drawing a set that has begun curving is
what made the edges twitch on every turn: the curve packs a twenty-unit run into one slot, so a set
on it covers a quarter of a unit per frame — and because it hides and shows on a boolean, it did that
appearing and disappearing mid-frame. Held at 1.5, a set is at x ≈ 4.5 when it goes, which is off the
side of the window on anything narrower than about 21:9.

> **The last shot is built on ONE FIXED member — `closingIndex` — not on whichever the ring was
> left turned to.** The five sets are different televisions, with glass of different sizes and
> shapes, and the epilogue's whole blocking is measured against one of them: how far off the glass
> the lens stands, how big the set reads, where that leaves the figure, whether the closing picture
> fits the screen it is hung on. Left on the live front, the closing shot came out differently for
> every case a visitor happened to stop on — the picture cropped on the widescreen set and the
> figure somewhere else in the frame each time. Passing a `frontPlacement` is what switches which
> member counts as the hero, and `frontHits` / `pickFront` ask the closing set, never the front one.
>
> **And the staged set is never hidden by its slot.** A slot is measured from whichever case the
> ring was turned to, and the closing member is fixed — turn the ring a few times and that member is
> round the back, past `hideBeyond`, so the last shot lost its television, and with it the only
> thing the figure reads as a silhouette against. Its slot means nothing there: it is not standing
> on the ring, it has been placed. Guard the hide test with
> `staged = (index === hero) && frontPlacement !== null`.

Each member is a **clone** of the wall television wearing its `screenTexture`, scaled ×1.6. Cloning
shares geometry and material — including the video texture — so a member costs one more draw call and
nothing else, and it plays in sync with the one it came from.

**Which way it faces is derived from the average world-space normal of its screen**, sampled every
`count/24` vertices. The clone inherits the rotation it had in the wall, where it was aimed at the
middle of the room. The first attempt used the offset between the screen's centre and the set's —
cheaper, and 180° wrong, because a CRT's bulk sits behind the glass and drags the centre with it.

The base rotation is kept as a **quaternion**: three represents this model's node rotations with x
and z at π rather than as a plain turn about Y, so adding a yaw to `rotation.y` and writing the three
angles back flips the set on its back.

Per frame, per member:

```js
start   = entrance.from + delay · (entrance.to − entrance.from) · 0.5
t       = clamp01((progress − start) / max(entrance.to − start, 0.001))   // LINEAR
visible = t > 0                                    // toggled 20 units deep in the murk
position = lerp(slot + fromOffset, slot, t);  position.x += sin(t·π) · arc
settled  = t² · (1 − square)
position.y += sin(clock)·float.amplitude·settled + lift
aim      = atan2(toCamera.x, toCamera.z) − facing
rotation = base ⨯ yaw(aim) ⨯ euler(lerpAngle(startRotation, 0, t) + drift terms)
lerpAngle(from, to, t) = from + (to − from)·t + sin(t·π)·0.12·(1 − t)   // overshoot mid-flight
```

`members` (order is the order round the ring from the front slot):

| texture | fromOffset | startRotation | arc | delay | float {amp, speed, yaw, tilt} |
|---|---|---|---|---|---|
| magic | [0.1, 0.55, −20] | [−0.32, −0.4, 0.18] | 0.35 | 0 | 0.06 / 0.85 / 0.05 / 0.035 |
| hands | [0.6, 0.45, −20] | [−0.2, −0.42, 0.2] | 0.3 | 0.24 | 0.05 / 0.79 / 0.045 / 0.03 |
| chelik | [1, 0.35, −20] | [−0.24, −0.5, 0.26] | 0.42 | 0.44 | 0.055 / 0.68 / 0.04 / 0.03 |
| bridge | [−1, 0.35, −20] | [−0.24, 0.5, −0.26] | −0.42 | 0.4 | 0.055 / 0.63 / 0.04 / 0.03 |
| cat | [−0.6, 0.45, −20] | [−0.2, 0.42, −0.2] | −0.3 | 0.2 | 0.05 / 0.72 / 0.045 / 0.03 |

Every member finishes its entrance at the same moment; a later `delay` only means a later start, so
the assembly closes as one. **The entrance is linear**: these used to ease in and out, which put a
slow-down in the middle of the scroll for every set that had already arrived.

### The scroll's acts

```js
dolly −15, rise 0, flight [0, 0.3]
flightSplit { at: 0.4, travel: 0.68 }
figureFade  [0.004, 0.021]
focusFade   [0.033, 0.133]
skyTop      "#ff7c5c"
finale      { range: [0.358, 0.5], fill: 1.06, blackFade: [0.74, 1] }
epilogue    { hold: [0.5, 0.75], reveal: [0.75, 0.958],
              cameraOffset: [-1.25, -0.045, 3], referenceAspect: 1.78, roll: 0.16,
              figureFollowsFrame: 1, figurePortraitBias: 0,
              television: { position: { from: [0, 0.86, -14.15],   to: [-0.35, 0.76, -14.2] },
                            rotation: { from: [0, 0, 0],           to: [0, -1.42, 0] } },
              // BOTH ENDS THE SAME: he holds still and the lens pulls back past him.
              figure:     { position: { from: [-1.75, -0.18, -11.95], to: [-1.75, -0.18, -11.95] },
                            rotation: { from: [0, 5, 0],           to: [0, 5, 0] } } }
```

**The flight changes gear.** Two-thirds of the dolly — as far as the wall's last row — in the first
40% of the flight's scroll; the remaining five units of empty corridor over the other 60%, which is
what gives the two lines room to lift out of the fog one after the other:

```js
flightTravel(p) = p < at ? travel · (p / at)
                         : travel + (1 − travel) · ((p − at) / (1 − at))
```

**Both legs are linear.** They were smoothstepped, which meant the lens came to a complete stop at
`at` and set off again — an ease that reaches zero velocity at both ends of both legs is a stop in
the middle of the flight. Straight legs leave a change of *speed* there instead.

**The figure dissolves** rather than being steered around: any detour that clears a body at arm's
length is a detour the eye reads as the camera flinching. Its four meshes share one material, so one
`opacity` covers all of them, and `transparent` is switched on **once, at load** — flipping it at
runtime recompiles the material mid-flight. `opacity = max(1 − phase(figureFade), epilogue)` — gone
for the flight and the carousel, and back for the last shot.

**The rack focus leaves with the room it belongs to and comes back for the last shot.** Write it
every frame rather than on a threshold, so scrolling either way brings it back:

```js
fade = max(1 − phase(scroll, focusFade), phase(scroll, epilogue.reveal))
maxblur = maxBlurPixels · fade / containerWidth
bokehPass.enabled = fade > 0.001            // a pass that blurs nothing is still a full-screen pass
dust.setDepthMix(fade > 0.001 ? 1 : 0)
```

**The parallax lets go as the finale takes hold** (`steer = 1 − finale`): the shot ends square to a
screen, and a lens still drifting after the cursor cannot be square to anything.

**The sky drifts** from its opening colour toward `skyTop` by `travel · (1 − staged)` — so by the time
the last shot is visible the turn-over has already been taken back under the black.

### The finale and the epilogue

The finale **does not move the lens**. What is left of the act is the arithmetic — where the front
set's glass is, and how far off it a lens would have to stand to fill the frame:

```js
tangent  = tan(fov · π / 360)
distance = min(height/2 / tangent, width/2 / (tangent · aspect)) / fill
```

**The nearer of the two wins**: fitting only the height leaves the room showing down both sides of a
wide window. `fill` **divides** rather than multiplies, because filling more of the frame means
standing *closer* to it.

The **epilogue is a pull-back, not a cut**. It starts from exactly where the finale left the lens —
hard up against the glass — and draws back and to the left until the set is at the right of the
frame, turned a quarter away, with the figure close at the other edge. **The set never moves; the
lens does**, which is what keeps the two acts reading as one shot even though the black is total
across the join. The figure is *carried* there — the same one from the opening, walked across the
room while nothing can be seen of it — which is why the staging runs over the **hold**, not the
reveal.

Sideways offsets scale by `clamp(aspect / 1.78, 0.5, 1.35)`: a frame's height is fixed by the field
of view but its width is not, so an offset that reads as "at the left edge" on one shape reads as
"off frame" on a narrower one. The **roll** (0.16 rad) is a deliberate dutch tilt and **the only
thing this act does to the framing** — the pitch, the borrowed lamps and the lifted figure and floor
colours all went, because the brief is that the last shot reads as the first one. Parallax is cut to
**a seventh** (`progress × 0.14`): the full drift swings the frame by a quarter of its width, and even
a quarter of that was enough to walk the figure at the left edge in and out of frame as the cursor
moved.

**Nothing in the frame moves but the lens.** Both ends of the figure's track are identical, so he
holds still while the camera pulls back past him — ends that differ walked him across the room while
the camera was already moving, and two things travelling at once in a shot this close reads as drift
rather than as a move. **Stop the animation mixer while the shot is staged** (`mixer.update(staged >=
1 ? 0 : dt)`): he is a silhouette a metre and a half from the lens here, and a walking silhouette
that close changes shape and place on every frame, which is the *scene* moving, not the shot. It is
the same body the opening uses, where the motion belongs. It starts again the moment the scroll
leaves the act, so coming back up finds the room alive.

**His offset from the lens must follow the frame's width.** He is the most shape-sensitive thing in
the shot — the frame widens and narrows with the window and a world offset does not, so the numbers
that put him at the left edge on the design's window put him off a laptop's and clean off a phone's:

```js
// stageTarget is the shot's OWN lens x — the one that does not change. Anchoring to the LIVE lens
// made his world place a function of the scroll: he landed somewhere different every time the act
// was entered and moved under you while you were in it. And the offset is taken from where the lens
// would stand on the DESIGN's window, because the lens's own sideways offset already scales with the
// shape — anchoring to the live one scaled that scaling a second time and walked him into the
// television on 21:9.
const designLens = stageTarget.x − cameraOffset[0] * (spread − 1);
const authored   = stagedFigure.x − designLens;
const follow     = 1 + (spread − 1) * figureFollowsFrame;          // 1 → tracks the frame exactly
const portrait   = (1 − Math.min(spread, 1)) * figurePortraitBias; // 0 at this blocking, measured
stagedFigure.x   = stageTarget.x + authored * follow + portrait;
```

**The closing screen** is swapped under the black — no crossfade, and none is wanted. Load the still
lazily (one image nobody scrolling half the page will reach), `flipY = false`, and hang it on the
**closing set** at **12% of the wall's glow**: this still is nearly white where the videos are mostly
dark, and anywhere near the wall's setting the bloom swallowed the words printed on it whole.
Hovering the set opens it back toward the lens by `lift × 0.16` rad on a spring and lifts the glow by
`+0.07`.

> **Ask the screen whether it reads MIRRORED, and flip the still back if it does.**
> Several televisions in the wall are copies of their neighbours turned or flipped to fill it out,
> and a clone inherits whatever was done to the original. Noise and abstract footage do not care. A
> picture with **words on it** does — the closing screen read `tɔejorꟼ a tratꙄ`.
>
> Ask it of the **image**, not of the transform. A negative scale is only one of the ways to get
> here, and not the one this model uses — the sets that read backwards have a perfectly ordinary
> positive determinant — and every other way (a quad wound the other way, a mirrored UV island, a
> turn that presents the back of the glass) is invisible to a matrix test. What is *not* invisible is
> **handedness on screen**: take three of the screen's own vertices that make a real triangle in UV
> space (skip any pair whose `du` or `dv` is under 0.3, or a strip of the quad's edge gives a
> degenerate sign), project them, and compare the sign of the cross product in NDC with the sign in
> UV. NDC's y runs up and UV's runs down, so a *matching* pair is already one flip apart — **same
> sign means mirrored**.
>
> Flip it back by cloning the texture with `wrapS = ClampToEdgeWrapping`, `repeat.x = -1`,
> `offset.x = 1` — cache that clone per source and dispose it with the set. And **re-ask every frame
> the set is dressed**: the first reading is taken while the lens is still crossing the black and the
> ring has not been placed yet, so it is a frame stale and taken mid-move. Three vertices through a
> matrix is cheap; only write when the answer changes, which in practice is once.

**A set that has been dressed and left behind must be undressed**, or two screens end up carrying the
call to action. And scrolling *up* out of the act has to put the figure back where the opening shot
stands it, or the room comes back with nobody in it.

### Prewarm, and when to draw

```js
// EVERYTHING, including what is hidden: `compile` walks the VISIBLE graph, and half of
// this scene starts invisible — the carousel's clones, the lines in the fog, the dust.
const hidden = []; scene.traverse(c => { if (!c.visible) { hidden.push(c); c.visible = true; } });
await renderer.compileAsync(scene, camera);
composer.render();                 // the passes allocate their targets on FIRST USE
hidden.forEach(c => c.visible = false);
await dust.prewarm(renderer, camera);
renderFrame(performance.now());
markSceneReady();                  // the earliest moment lifting the curtain shows a finished room
```

Measured before this: 36 programs after the loader and 37 by a quarter of the way down the page —
that one extra link is a stall in the middle of a scroll.

Draw only while **on screen** (`IntersectionObserver`, `rootMargin: "100%"`), **uncovered** (the
panel stack says so) and **the tab is in front**. All three also pause every video decoder. Resize is
**coalesced into the frame loop**, never handled inside the event, and is not watched at all on
mobile — iOS Safari fires `resize` every time the URL bar collapses, and rebuilding the framebuffer
mid-scroll reads as a whole-scene flash.

**Pick on pointer movement only**, never while the camera is still easing: the drift slides the wall
out from under a stationary cursor, and a re-pick mid-drift hands the hover to a neighbour.

## The chrome

### Preloader

A field of mint (`--nav-accent`) with the count top-left and a bar of **64 rounded cells** across the
foot. `PACE 0.62` of the bar per second while loading, `FINISH 1.6` once ready, waiting at
`HOLD 0.92`, with `PATIENCE 6` seconds before it goes up regardless — a loader that can hang forever
is worse than one that lets you in early.

The sheet is **clipped away from the bottom up** (`inset(0 0 x% 0)`), on a spring at
`{ tension: 82, friction: 26 }` — an edge that bounces back down over the page it has just revealed
reads as a mistake. Cells are lit by **crossing a threshold**, so all but a couple write nothing on
any given frame.

> **The hero starts with the curtain, not after it.** The sheet is clipped from the bottom, so the
> top of the window — where the headline is — is the *last* thing uncovered; waiting for the curtain
> to be gone meant the letters only began resolving once they were already in view. Fire
> `markIntroDone()` at the moment the lift begins.

Scroll is locked (`overflow: hidden` on `<html>`, `scrollTo(0, 0)`) until then.

Layout: count at `top/left 1.5rem`, display face, `3rem`, tabular; cells `inset-x 1.5rem`,
`bottom 1.5rem`, `height 2rem`, `max-width 0.4375rem`, `min-width 1px`, `flex: 1`, `gap 1px`, rounded.
From `sm`: `2rem` insets, `4.5rem` count, `2.875rem` cells, `gap 0.25rem`. **The gap closes on a phone
rather than the count of cells changing** — 64 cells 4px apart is 316px of bar, wider than the window,
and a flex row that cannot shrink past its floor overflows rather than tightening.

### Scattered text

One spring per letter with a **different config** and a **shared start** — `tension` interpolated
`42 → 190` by the letter's seed, `friction 26 + seed × 10`, `blur(14px) → blur(0)` with opacity. The
seed is `sin((i + 1) · 12.9898) · 43758.5453`, fractional part — never `Math.random`.

**The exit is the entrance inverted**: on the way out each letter's pace is drawn from `1 − seed`, so
the letter that resolved first is the last to go. With the same speed both ways the line simply plays
backwards, which reads as an undo rather than as the words leaving.

Words are kept whole (`white-space: nowrap` per word) so a line breaks between words, and the plain
string stays in the DOM (visually hidden) while the split copy is `aria-hidden`.

Used by: the hero `h1`, *Our cases*, *Meet our team*, *About*, and *Have an Idea?*.

### Nav pill (fixed, `top 0.75rem`, `sm:1rem`)

`pointer-events: none` on the bar, `auto` on the pill — the nav is a full-width strip across the top
and without this it swallows every click along it.

Pill: `2.25rem × 13.75rem`, `border-radius 0.375rem`, **translucent white at 20% with a matching 20%
border and a 3px backdrop blur** — not a solid grey, and not a stadium. `sm: 2.5625rem × 17.4375rem`.
Both labels are placed by their **text centres** (`< >` at `left 2.125rem`, *Contact* at
`calc(50% + 4.8125rem)`, both `translateX(-50%)`, `top 0.5625rem`), which is how the design positions
them, so the pill's spacing holds whatever the strings are. The *Menu* chip is `#dbfffe`, `5.5rem`
wide, at `top -1px`, `left 50%`, with three `1px` rules 3px apart as its icon. Type is Chakra Petch
SemiBold `0.75rem` (`sm: 0.875rem`) throughout. `sm` scales every one of those numbers up.

*Contact* has **no href** — `/contact` was never built, so it renders as a `<span>`: no tab stop, no
pointer, no hover.

### Scene HUD

`pointer-events: none`, `z-30`, Chakra Petch `0.8125rem` (`sm: 1rem`), white.

**Four rules, not a box**: each stops short of the corners, and 32×32 brackets fill the gaps. Rules
inset `0.75rem` and stopping `2.25rem` from each corner; from `sm`, `1rem` and `3rem`. The bracket is
one path flipped four ways, **2px and solid white** against the rules' 1px at half strength — drawing
the frame as one rounded rectangle throws that contrast away:

```
viewBox="0 0 33 33"  M32 0V22C32 27.5228 27.5228 32 22 32H0   stroke-width 2
top-left rotate(180)   top-right scaleY(-1)   bottom-left scaleX(-1)   bottom-right as drawn
```

Readouts: `60 fps` top-left and `REC` top-right (`2rem` in) — **both hidden below `sm`**, because at
390px the nav pill is 240 of them and the corners have about 70px each; the date bottom-left and the
clock bottom-right (`1.5rem`, `sm: 2rem`).

- **fps is counted over a one-second window**, not from the last frame's delta: a per-frame figure
  flickers between 58 and 61 and reads as instability that is not there.
- The clock is `[ mm : ss : cc ]` in **hundredths**, throttled to 30 Hz — three digits at 60 Hz is a
  blur no one can read.
- The date is `[ dd / mm / yy ]`, zero-padded.

### Hero copy — `fixed`, and it does not travel

- `h1`: scattered text, `top 5.25rem`, `left 50%`, `width calc(100% - 3.5rem)`, centred, display face,
  `2rem` / uppercase / `--nav-accent`. `sm: top 6.0625rem, width 33.4375rem, 3rem`. `lg: 4.5rem`.
  The **535px measure** is what breaks the line, not a hand-typed break. It is held **97px** down:
  the nav pill ends at 57 and the brief is a 40px gap under it — measured from the top rather than
  the centre, so that gap is the same on any window height.
- Column, `bottom 4.5rem` (`sm: 3.125rem`), centred: a 32×32 crosshair
  (`M16.5 0V32M32 16.5H0`, stroke 1), the intro at `0.875rem/1.35` on `calc(100% - 3rem)`
  (`sm: 1rem/1.2` on `25.6875rem`), then the two buttons `0.5rem` apart (`sm: 0.75rem`).
- Buttons: `height 2.5rem` (`sm: 2.6875rem`), `radius 0.375rem`, `padding-inline 1rem`,
  `0.875rem/1.2` semibold (`sm: 1rem`). Primary is `--nav-accent` on `--nav-accent-ink`; secondary is
  a `--nav-accent` outline (`sm: width 8.9375rem`). **Neither has an href**, so both are `<span>`s
  with no hover — the hover is the promise, and keeping it on something that does not answer a click
  is the part that reads as broken.
- **The blur and the fade are the column's, not the headline's.** They used to sit on the root, which
  put them over the `h1` as well — so the heading arrived on nothing but its letters and left on its
  letters *plus* a blur the entrance never had. The headline's only exit is the scatter.

```js
LEAVE = [0.012, 0.075]   // share of the page's scroll, LINEAR
gone  = clamp01((progress − 0.012) / 0.063)
column.opacity = 1 − gone;  column.filter = `blur(${gone * 16}px)`
root.visibility = gone < 0.999 ? "visible" : "hidden"
headline.active = introDone && gone < 0.25       // already scattering while the block is still there
```

### The hover chip

**DOM, not a plane in the room.** The label used to hang in the room above the hovered set, turned
the way that set was turned, and every problem it had came from that: foreshortened to a different
size on every television, sized in pixels against the axial depth to stay readable, and buried behind
whatever stood in front of it.

Pill, `radius 9999px`, `padding-block 0.625rem`, `1rem/1.2`, `gap 0.75rem`, `will-change: transform`.
**Two tones**: over a case it is `--nav-accent` on black with a divider and *Explore* + a 16×16 arrow
(`M2.5 8h11M9.5 4l4 4-4 4`, stroke 1.6, round caps), `padding: 1.5rem / 1.25rem`; over a set showing
noise it is **black with a 40% accent border and accent type, and the arrow and divider are gone**,
`padding-inline 1.5rem` — the offer is only ever made where it can be kept.

```js
SPEED = 26 chars/sec, CHASE = 0.22, OFFSET = { x: 18, y: -14 }
at.x += (pointer.x − at.x) * CHASE                 // first placement JUMPS
level += ((wanted ? 1 : 0) − level) * 0.18
transform = translate3d(at.x + 18, at.y − 14, 0) scale(0.9 + level * 0.1)
name.textContent = label.slice(0, floor((now − since) * SPEED))
```

**Do not render it at all without a fine pointer** — the team ring reports a pointer over it from a
finger as readily as from a mouse, which left a label parked at the last place the screen was touched.

### The case panel

`absolute inset-0`, `z-10`, `pointer-events: none` on the section and `auto` on each control — a
faded-out panel that still took a click would eat the hero's buttons, which sit in the same place.
It springs `{opacity 0, y 1rem} → {opacity 1, y 0}`.

Shown while `scroll > carousel.entrance.to − 0.05` **and** the finale has not started. Heading is
scattered text at the same `5.25rem` (`sm: 6.0625rem`) the hero's sits at.

*Previous* / *Next* flank the front set at `left/right 15rem` on the vertical centre from `lg`;
below that they **pair up under the set** at `bottom 13.75rem` (`left 50%` with
`translateX(calc(-100% - 0.25rem))` and `translateX(0.25rem)`) — 240px in from each edge is past the
middle at 390px, so the two crossed over. **One node each either way**: a second pair for small
windows would be a second pair of tab stops.

Bottom column at `bottom 4.5rem` (`sm: 3.125rem`): the work's name in the display face at `1.5rem`
(`sm: 2rem`), three tags `( like this )` wrapping and centred at `0.875rem` (`sm: 1rem`) in
`--hud-text`, then *Explore* as **one call to action in two pieces** — the label button and a
`2.5rem` (`sm: 2.6875rem`) accent tile holding `M13 22H29M22.5 15.5L29 22L22.5 28.5` (viewBox
`0 0 43 43`, stroke 2, round). None of the works has a route, so it is a plain box, not a link.
The name and tags **re-spring on every turn**, keyed on the work, so they change *with* the picture.

Works, in the ring's order: **Stride** (UX / UI · Branding · Web 3.0) · **Halcyon** (Art direction ·
Identity · Motion) · **Kestrel** (Product · UX / UI · Design system) · **Meridian** (Branding · Web ·
3D) · **Marlow** (Campaign · Art direction · Web).

### The panel stack

Two `fixed inset-0 z-25` panels. **One number does all of it**: `offset` runs 0 (all below the
window) through 1 (the first covering) and 2 (the second) to `n + 1` (all gone), and panel `k` sits
at `(k + 1 − offset) × (100% + 294px)`.

```js
RISE  = [finale.from + (finale.to − finale.from)·0.12, finale.to]      // = [0.375, 0.5]
HOLD  = [finale.to, epilogue.reveal.from]                              // = [0.5, 0.75]
LEAVE = [epilogue.reveal.from,
         epilogue.reveal.from + (epilogue.reveal.to − epilogue.reveal.from)·0.66]   // = [0.75, 0.887]
handover(k, n) = [HOLD0 + (HOLD1−HOLD0)·(k−1)/(n−1),  HOLD0 + (HOLD1−HOLD0)·k/(n−1)]
offset = phase(RISE) + Σ phase(handover(k)) + phase(LEAVE)
```

They are laid **end to end**: every version that left a gap between two of them put a *pause* in the
scroll — a panel sitting perfectly still while the wheel turned. **No easing anywhere in this file**:
the panels used to rise, hand over and leave on smoothsteps, which meant the page crept at the start
of every transition, hurried through the middle and crept out again.

**GAP = 294px of black between one panel and the next.** They used to tile exactly — one panel's foot
was the next one's head — which put the team block's tags 147px from the studio's opening line as the
two crossed. What shows in the gap is the finale's black layer.

The first panel **enters on pixels** and the last **exits on pixels**; a dissolving panel does not
travel through that side of its life (clamp `travel` to `home`) and **has no background of its own** —
the wave's squares are the black. Contents fade in on the back half of the dissolve:
`opacity = clamp01((wave − 0.42) / 0.58)`.

`up` is the panel within `0.1` of square (interactive, everything else `inert`); `coming` is within
`0.62` — wide enough to catch a panel that is still moving, so the headings start resolving on the
way in. **Covered** — which stops the scene drawing entirely — is `offset >= 1 && offset <= n`.

### The pixel wave

A grid of `18px` squares filling from the bottom up. Each cell has a **fixed random offset** drawn
once and kept, and turns solid once the wave has passed `its row + its offset`; the randomness makes
the front a scatter of pixels rather than a ruled line, and keeping the offsets means scrubbing back
and forth replays exactly the same dissolve rather than boiling.

`SPREAD = 0.34` of the window's height is the depth of the ragged edge — with no spread the wave is a
straight line sweeping up, with too much it is a slow grey fog.

```js
reach = (clearing ? 1 − progress : progress) * (1 + SPREAD)
per row: at = 1 − (row + 1)/rows;  room = (reach − at) / SPREAD
  room <= 0 → empty on the way in, still solid on the way out
  room >= 1 → solid on the way in, gone on the way out
  else      → per cell: fill when (offset[cell] <= room) !== clearing
```

**Canvas, not elements** — the same thing in DOM is four thousand `div`s to lay out; here it is one
node and a few hundred `fillRect`s, and only the rows inside the band are drawn cell by cell. One
device pixel per CSS pixel: the cells have hard edges and there is nothing for a denser buffer to
resolve. On the way out the wave is handed **what is left** and told it is clearing, so the squares
keep travelling upward instead of the arrival running backwards.

### The team panel and its ring

Heading (scattered) at `5.25rem` / `sm 6.0625rem` / `lg 4.5rem`; the `[ 01 / 08 ]` counter one line
under it at `8.75rem` / `sm 10.5rem` / `lg 12.5625rem`; the front card's name and three tags in the
same bottom column shape the case panel uses.

The ring is a **real cylinder**: each card is `rotateY(--a) translateZ(-r)` on a ring that is itself
`translateZ(push) rotateY(--rot)`, where `push = r · (1 − arcDepth)` carries the ring forward so the
front card ends up **at the viewer** rather than a radius away. That is what makes the arc read as
concave while every card is genuinely turned to face the axis it stands on. Faking it with a
hand-built depth curve and a separate tilt cannot work: the two are one thing on a ring.

```js
slots 14, ratio 1.36 (h/w), radius 3 (× card width), arcDepth 0.7, perspective 1.15 × window width
drag 0.16 deg/px, damp 0.94, rest 0.05, snap 0.14, fadeFrom 58°, fadeTo 92°
STEP = 360 / 14
card width = max(150, min(innerWidth · share, innerHeight · 0.26))
  share = innerWidth < 640 ? 0.52 : innerWidth < 1024 ? 0.3 : 0.17
perspective = max(900, innerWidth · 1.15)
```

- **No upper cap on the card**: with one, a wide window kept 238px cards on a 714px radius and the arc
  stopped short of both edges. The **height** is the binding limit on anything wide, because the
  ring's sides pass near the lens and render half again as tall as the front card. The share of the
  width is not one number — a sixth of the window is a generous card at 1440 and a postage stamp at
  390.
- **Perspective is long, and not for taste**: at a punchy 0.8 the ring's sides ran up behind the
  heading.
- **Drag, throw, snap.** The pointer turns the ring `0.16°` per pixel; letting go leaves it spinning
  and the spin decays by `damp` each frame; under `rest` the nearest card is eased to centre by
  `snap`, and **landed exactly** (`|away| < 0.01` → assign) so a hovered card holds perfectly still
  instead of shivering under the ease. **This one is not a spring** — it is a thrown object with
  friction, and the settle is the tail of that same motion.
- Cards **fade across the last third of their travel** and are only then dropped to
  `visibility: hidden`: the ring used to switch at 96°, and a row of photographs blinking out at both
  edges is the first thing the eye goes to. Dropping them takes them out of the compositor —
  `backface-visibility` stops them being *drawn*, but each is still a layer to hold and transform.
- A **tap** rather than a drag (`|dx| ≤ 6`) brings that card round, hit-tested with
  `elementFromPoint` at the release point — pointer capture has retargeted every event to the stage.
  Arrow keys step by one.
- The **frame round the front card is static** and belongs to the stage, not the ring: `card + 1.75rem`
  on each axis, `radius 1.25rem`, a 1px accent border. The card the ring brings round arrives inside it.
- Card: `radius 1rem`, black, `overflow: hidden`, `backface-visibility: hidden`, a cover image, a
  bottom-up scrim (`black/85 → black/25 at 30% → transparent at 55%`), `hover: scale(1.05)`, and the
  member's name **only for screen readers** — the dock under the ring names whichever is at the front,
  and a handle on every face made the row read as a deck of tickets.
- Pointer over the ring puts the chip up saying *Drag to explore* — the ring is the one thing on the
  page that has to be *told* it can be dragged.

Members, in ring order: **Alex Baron** (Art director · Product designer · 9 years of experience) ·
**Mara Ellis** (Creative lead · 3D generalist · 8) · **Signe Dahl** (Brand designer · Type & systems ·
6) · **Robin Vance** (Motion designer · Art direction · 5) · **Nadia Weiss** (UX designer · Research ·
7) · **Clara Iversen** (Producer · Strategy · 10) · **Jonas Vik** (Front-end engineer · WebGL · 4) ·
**Theo Marchetti** (Copy & narrative · Content design · 6). Images `team/1.png` … `team/8.png`.

### The about panel

**A column, not four absolutely-placed boxes.** Every number is still there — the 97px head, the 60px
margins, the 50px footing, the 331px card row on its 16px gutter — but as padding and a height on a
`justify-between` column. Absolutely placed, the head and foot were pinned to opposite edges of
*whatever shape the window is*: on a 3:4 tablet that left a third of the panel as a black band
through the middle, and on a phone the three cards were 69px wide with 32px of padding in them.

`justify-content: center` until `lg`, `space-between` from there; padding `5.25rem 1.5rem 4.5rem`
(`sm: 6.0625rem 2.5rem 3.125rem`, `lg` side padding `3.75rem`).

Head: a 3-column grid from `sm` — the heading (scattered, display face, `2.25rem` / `sm 3rem` /
`lg 4.5rem`) in the first column and the lead across the other two, `1rem/1.35` (`sm 1.5rem/1.2`,
`lg 2rem`, `max-width 41.6875rem`). The lead is written **in clauses**, alternate ones at half
strength — the design's way of giving a single sentence a rhythm:

> *We are eight people* / who make identity, art direction and websites / *for work that is meant to
> be looked at twice —* / and we finish what we draw.

Cards: one column, three from `sm`, `gap 0.75rem` / `sm 1rem`, `lg height 20.6875rem`. Each is
`--nav-accent`, `radius 1rem`, `padding 1rem` / `sm 1.5rem` / `lg 2rem`; a row (tile beside copy) on a
phone and the design's tile-above-copy from `sm`. Tile `2.5rem` / `sm 3.25rem` / `lg 4.125rem`, black,
`radius 0.5rem`, holding a 36×36 outline at stroke 1.5: a circle `r 16.5`, a triangle
`M18 3.5 34 32.5H2z`, a rounded square `x/y 1.25, 33.5², rx 5`. Copy `0.8125rem/1.3` (`sm 0.9375rem`,
`lg 1rem`, `max-width 21.3125rem`).

They **rise on a trail** — one spring pulling the next, `{opacity 0, y 64px} → {opacity 1, y 0}` at
`{tension 120, friction 30}` — and leave the same way, in the same order. A trail is a run of movement
instead of three things landing on a metronome; the spring is soft because these are big flat slabs
and a bounce on one reads as a wobble in the whole row.

Card copy: *Identity. We find the one idea a name can stand on, then draw the whole system out of
it.* · *Art direction. We decide what the work is going to look like before anyone opens a design
tool.* · *Build. We ship the site ourselves, so what was drawn is what goes live — down to the last
frame.*

### The closing copy

`fixed inset-0 z-15` — **under the panels** in the stacking order, so the block that dissolves off
the top of them passes over these rather than under. Block at `top 50%`, `left 1.5rem` (`sm 2.5rem`,
`lg 3.75rem`), `translateY(-50%)`: *Have an Idea?* in the display face at `2.25rem` / `sm 3rem` /
`lg 4.5rem` (scattered), *Lets collaborate!* under it at `1.25rem/1.2` (`sm 1.5rem`, `lg 2rem`), then
a 1px rule.

```js
IN = [0.12, 0.62]                       // of the epilogue's reveal
level = clamp01((reveal − 0.12) / 0.5); root.opacity = level; headline.active = level > 0.2
```

> **The rule is under the television, and the television is in a canvas.** There is no z-order that
> puts DOM behind an opaque canvas, so the rule is **cut** where the set begins instead: the scene
> projects the set's real outline every frame and the line is drawn exactly that wide. It follows the
> set as the hover turns it, which a fixed width could never do.

The scene answers `edgeAt(y)` — the leftmost crossing of the set's silhouette **at that height**:

- **Not a projected bounding box.** This set stands close enough for a corner to fall behind the near
  plane, and a projected box with one corner behind the camera is turned inside out; the "leftmost
  point" it reports is nonsense.
- A **sweep of 17 rays** across the frame for the first hit, then **three bisections** to sharpen it.
  Twenty rays against one object, on the handful of frames where the shot has moved — cached on
  `(y, scroll, hoverLift)`, and **every path must write the cache**, including the miss.
- **Asked at the rule's own height**, because the set is turned and its near bottom corner reaches
  much further left than its middle.
- `width = max(edgeAt(top) − left + 26, 0)`. The **26px overlap** runs the rule slightly *into* the
  set and its last stretch is faded out (`linear-gradient(to right, white/0.2 0%, white/0.2 82%,
  transparent 100%)`): the rays are cast through the scene, and what you see is that scene put
  through a CRT barrel, so the edge on glass is a few dozen pixels from where the geometry says it is
  — and by a different few dozen at every window size. There is no seam to line up if the line has
  already gone.
- The rule's own box is **measured, not the design's 60px written into the file**: at a phone's 24px
  margin the line started 36px inside its own heading. Cache it and re-measure on resize —
  `getBoundingClientRect` interleaved with the style writes forces layout every frame of the act.

## Copy (verbatim)

**Nav** — `< >` (home) · `Menu` · `Contact` (no link).
**Hero** — h1: *We make the past feel new*. Intro: *We make identity, art direction, and websites
that keep a room quiet — tuned until the last picture holds still.* Buttons: `Start a Project`,
`See the Work` (neither links). Section label: *Interactive 3D scene: a figure facing a wall of
televisions*. Loading label: *Loading the room*. Tally: `REC`.
**Signs** — *Built to hold attention.* / *A signal in the noise, tuned only for you.*
**Chips** — by texture: `magic → Stride`, `hands → Halcyon`, `chelik → Kestrel`, `bridge → Meridian`,
`cat → Marlow`; fallback *Coming soon...*; action *Explore*. Ring hint: *Drag to explore*.
**Cases** — heading *Our cases*, `Previous`, `Next`, `Explore`.
**Team** — heading *Meet our team*.
**About** — heading *About*.
**Closing** — *Have an Idea?* / *Lets collaborate!*; the still's chip says *Something is on next*.

## Fixed parameters (bake these in)

```
scroll section        840lvh, the scene sticky at top with h 100lvh, overflow hidden
root ladder           16px · >1920 calc(5.3344px + 0.5555vw) · ≤1920 0.833333vw · ≤1440 1.111111vw
                      · ≤1023.98 max(1.5625vw,12px) · ≤639.98 min(4.444444vw,18px)
breakpoints           sm 640 · lg 1024
scroll filter         smoothing 0.2/60Hz frame · snap 0.2 · dt clamped 0.05
frame delta           animation mixer clamped to 0.05s
dpr                   [1, 1.5], mobile ceiling 1, fragment budget 3.2M
camera                pos [0,1.42,5.6] pitch −0.105 fov 40 near 0.1 far 60 YXZ
parallax              yaw 0.2 pitch 0.115 · spring 90/34/1.4
hover                 lift 0.26 yaw 0.14 stagger 45ms · rise 150/26/1 · release 110/26/1
                      float amp 0.075 speed 1.05 yaw 0.07 tilt 0.05 · stack gap 0.22 overlap 0.3
bloom                 0.18 / 1 / 0.6, knee 0.45, fine 0, mobile ×0.5
bokeh                 focus 5.25 aperture 0.0032 maxBlur 22px · spring 110/30/1.2
grain                 0.3          crt  0.16 / 300 / 0.78 / 0.012 / 0.06 / 0.0008
dust                  9000 (mobile 35%) area [7,4,16] size 4.6 opacity 0.65 drift 0.45 speed 0.12
flight                dolly −15 rise 0 over [0, 0.3], split at 0.4 / travel 0.68, both legs linear
figureFade            [0.004, 0.021]      focusFade [0.033, 0.133]      skyTop #ff7c5c
carousel              centre [0,−0.68,−4.2] spread 3 scale 1.6 fogDepth 20 hideBeyond 1.5
                      entrance [0.133, 0.3] · curve away [1.5, 2.5] · closingIndex 0
finale                [0.358, 0.5] fill 1.06 blackFade [0.74, 1]
epilogue              hold [0.5, 0.75] reveal [0.75, 0.958] roll 0.16 refAspect 1.78
                      cameraOffset [−1.25, −0.045, 3] · parallax ×0.14
                      figureFollowsFrame 1 · figurePortraitBias 0 · mixer held while staged
panels                rise [0.375, 0.5] hold [0.5, 0.75] leave [0.75, 0.887] · GAP 294px
                      up < 0.1 · coming < 0.62 · contents (wave − 0.42)/0.58
pixel wave            cell 18px spread 0.34
ring                  14 slots ratio 1.36 radius ×3 arcDepth 0.7 perspective ×1.15
                      drag 0.16 damp 0.94 rest 0.05 snap 0.14 fade 58→92°
preloader             64 cells pace 0.62 finish 1.6 hold 0.92 patience 6s · lift 82/26
hero copy             leave [0.012, 0.075] blur 16px
scatter               blur 14px tension 42→190 friction 26+pace×10
chip                  26 chars/s chase 0.22 offset (18, −14)
closing               in [0.12, 0.62] overlap 26px
```

## Assets

All fetched from `https://storage.getlayers.ai/assets/codescan-a4075f0387/`. Set that as one
`ASSETS` constant and `encodeURI` every path — one filename contains a space, and it is load-bearing
(the texture-name match keys off the stem `color noise`).

| file | used by |
|---|---|
| `hero/TV.glb` | the room: fourteen televisions, the floor `Plane`, the skinned figure (`…sketchfab…`) — Draco-compressed |
| `hero/Videoes/bridge.mp4` · `cat.mp4` · `chelik.mp4` · `hands.mp4` · `magic.mp4` | the five works' screens |
| `hero/Videoes/noise.mp4` · `color noise.mp4` | the sets with nothing on them; `noise.mp4` is also screen 12's override |
| `CTA/CTA-TV.png` | the closing screen's *Start a Project* still |
| `team/1.png` … `team/8.png` | the ring's portraits |

Every element that reads pixels from a fetched asset — each `<video>` behind a `VideoTexture`, and
three's `TextureLoader` — must carry **`crossorigin="anonymous"`**. A CORS header on the bucket is
necessary and **not sufficient**; without it the upload throws, the exception is swallowed by the
shared ticker, and the screen silently never paints.

**A failed asset load must be visible.** Wrap every fetch in a `.catch` that puts a banner on the page
naming the URL that failed — a missing asset otherwise renders as a correct DOM over a blank canvas,
with nothing but a console line to explain it. And if WebGL is refused outright, catch it, leave the
box empty rather than take the page down, **and tell the curtain anyway**: a loader that never
finishes is the worse failure.
[/hidecontent]
← 返回首页