Aerra
aadmin2026-11-05 发布Site模板3 阅读

Aerra

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

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

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">`. The only external code is **three.js** (for one small 3D mark) pulled
through an import map; everything else — the spring engine, the word-split text reveal, the smooth
scroll, the pixel-reveal shader, the turntable — is written by hand in the file. Hardcode every
value given here as a fixed constant. Rebuild each block below as a section of the one file.

## What it is

A single-page property landing site for **AERRA**, a finished 240 m² house standing alone on a
south-facing mountain slope. Six sections scroll past on a white ground: a full-bleed dusk hero
where the brand name is set **801 px tall** behind the building; an about block with a drag-to-turn
turntable of the facade; three counting statistics; a night photograph of the ridge that you
**paint a site plan into with the cursor**; a 3×3 grid of frosted glass cards with a matte-black
3D mark travelling the whole height of the section behind them; and a contact block with an
underlined three-field form on a frosted panel.

The page opens behind a **white curtain**: a percentage counter runs 0 → 100 along the bottom-left,
then a rounded window is cut out of the curtain and grows until it clears the viewport. Only once
that window starts opening does the hero play its entrance — the wordmark writes itself in letter
by letter, then the headline arrives word by word, then the subtitle, the button and the caption.

Everything is authored against a **1440 px** Figma frame ("Concept 3", node 1469:1302) and every
desktop coordinate below is that frame's, expressed in `rem` at 16 px = 1 rem. The root font-size
tracks the viewport, so the whole composition stays proportional at any width.

## Page shell & libraries

- **Import map, three.js only:**
  ```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>
  ```
  You need `three`, `GLTFLoader`, `DRACOLoader` and `RoomEnvironment` from `three/addons/`. The
  Draco decoder is served from `https://unpkg.com/three@0.185.0/examples/jsm/libs/draco/gltf/`.
  **Nothing else is a library.** If you reach for GSAP, react-spring, Lenis or a text-animation
  package you have misread the brief — each is reproduced below as a few dozen lines.
- **One shared rAF loop.** Build a tiny ticker: 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 one leaves. The 3D mark, the pixel reveal, the turntable,
  the curtain and every scroll-scrubbed element subscribe to it. **An idle page must cost zero
  rAF.** A subscriber that throws is caught and logged, never allowed to kill the loop.
- **Box sizing:** `*, *::before, *::after { box-sizing: border-box; }`.
- `body { background: #ffffff; color: #000000; }`, `main { width: 100%; overflow-x: clip; }`.
  Never `width: 100vw` — it ignores the scrollbar gutter and forces a horizontal bar.
- **Smooth scroll.** The project runs Lenis with `smoothWheel: true` over **native window scroll**
  (no transformed wrapper, which is why `position: fixed` behaves normally under it). Reproduce it
  as a small wheel-interception loop or accept native scrolling; what must not change is that
  `fixed` elements stay fixed and `getBoundingClientRect()` is the source of scroll progress.
- **Reduced motion.** Under `prefers-reduced-motion: reduce`, every spring resolves **instantly** to
  its end state — the page renders fully settled rather than animating. The 3D mark draws one frame
  and stops. Do not simply disable the reveals: elements must end up visible.

### Fonts — Google Sans Flex, self-hosted

The design is set in **Google Sans Flex**, in two different cuts, and the difference is load-bearing:

- **Body / UI** uses the **static 24 pt instances** at weights 100 / 300 / 400 / 500. A static
  instance is drawn for one optical size, and the 24 pt cut thickens its strokes so they hold up at
  24 pt.
- **The wordmark** uses the **variable** file, which carries the `opsz` axis. Browsers apply
  `font-optical-sizing: auto` by default, so at 801 px `opsz` lands on the axis maximum and the
  strokes thin right out. The static 24 pt Thin cannot do this and renders visibly heavier.

Both are fetched from the project's asset folder (see **Assets**). Declare them as two families:

```css
@font-face { font-family: "Google Sans Flex"; font-weight: 100; font-style: normal; font-display: swap;
             src: url("<ASSETS>/GoogleSansFlex_24pt-Thin.ttf") format("truetype"); }
@font-face { font-family: "Google Sans Flex"; font-weight: 300; ... -Light.ttf ... }
@font-face { font-family: "Google Sans Flex"; font-weight: 400; ... -Regular.ttf ... }
@font-face { font-family: "Google Sans Flex"; font-weight: 500; ... -Medium.ttf ... }
@font-face { font-family: "Google Sans Flex Display"; font-weight: 1 1000; font-style: normal;
             font-display: swap;
             src: url("<ASSETS>/GoogleSansFlex-Variable-latin.woff2") format("woff2"); }
```

Body: `font-family: "Google Sans Flex", ui-sans-serif, system-ui, sans-serif`, with
`-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale`.
The wordmark alone: `font-family: "Google Sans Flex Display", "Google Sans Flex", sans-serif`.

## Design tokens (bake these in)

Three tiers in the project; flatten them to CSS custom properties on `:root`, but keep the roles —
a retint should only ever touch the first block.

```css
:root {
  /* primitives */
  --white: #ffffff;
  --white-85: rgba(255,255,255,0.85);   /* contact panel */
  --white-50: rgba(255,255,255,0.5);    /* dimmed display heading */
  --black: #000000;
  --black-50: rgba(0,0,0,0.5);          /* card index numerals */
  --black-10: rgba(0,0,0,0.1);          /* hairline divider */
  --grey-100: #f4f4f4;                  /* icon tiles, media placeholder */
  --grey-150-80: rgba(243,243,243,0.8); /* nav bar + glass cards + scrubber */

  /* hero sky — Figma "image 643" (1469:1300), a top-to-bottom ramp */
  --backdrop-hero: linear-gradient(to bottom,
    rgb(26,80,109) 0%,
    rgb(50,109,143) 21.154%,
    rgb(84,136,161) 49.529%,
    rgb(111,159,181) 59.513%,
    rgb(138,182,200) 69.496%,
    rgb(192,227,239) 89.464%);

  /* wordmark fill — white fading to nothing at 82.23% down the letters */
  --wordmark-fill: linear-gradient(to bottom,
    rgba(255,255,255,1) 0%, rgba(255,255,255,0) 82.23%);

  /* type scale — 16 / 20 / 36 / 80 / 801.395 px in the Figma frame */
  --text-body: 1rem;
  --text-lead: 1.25rem;
  --text-title: 2.25rem;
  --text-display: 5rem;
  --text-wordmark: 50.0872rem;

  --leading-flat: 1;      /* display headings */
  --leading-body: 1.2;    /* every other text node in the frame */

  --radius-control: 0.375rem;  /* 6px  — logo tile, arrow tile */
  --radius-button: 0.5rem;     /* 8px  — nav bar, CTA pill, quote card */
  --radius-card: 0.625rem;     /* 10px — photographs, glass cards, panels */

  --blur-nav: 0.625rem;     /* 10px   — nav + glass cards */
  --blur-panel: 2.34375rem; /* 37.5px — contact panel */

  --duration-fast: 150ms;
  --duration-normal: 250ms;
  --duration-slow: 700ms;   /* long enough for the CTA sheen to read as a sweep */
  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
}
```

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

The layout is authored in `rem` against a 1440 px base where 1 rem = 16 px, and the root font-size
tracks the viewport. Reproduce this ladder exactly, in this order:

```css
html { font-size: 1.111111vw; }                                  /* 16 * 100 / 1440 */
@media (max-width: 1279px) { html { font-size: 16px; } }          /* tablet: fixed root, fluid layout */
@media (max-width: 539px)  { html { font-size: 3.72093vw; } }     /* phones re-base to 430 */
```

> The project writes `html { font-size: 16px }` and overrides it at `max-width: 1440px` with
> `1.111111vw`, then a JS `AdaptiveGrid` at `coef: 1` sets `16 * innerWidth / 1440` px above 1440.
> That expression **is** `1.111111vw`, so the single unconditional rule above is byte-identical at
> every width and needs no JS. Do not add the script.

The tablet range deliberately does **not** scale: it spans a 2.4× width ratio, so any single base
puts one end of it wrong — the 1440 composition at 834 px measured 9.3 px body copy. A constant
16 px root with a genuinely fluid stacked layout is the answer there.

**Breakpoints** — three designs, three ranges:
`(default) phone < 540` · `tablet: 540–1279` · `lg: ≥ 1280` (the Figma composition).
`md:` is never used for layout, so a stray one can never half-apply the desktop board.

### The text trim

Figma trims every text box to the cap/alphabetic edges, so its y-positions measure **ink**, not the
line box. Porting the layout 1:1 means reproducing that trim. `text-box: trim-both cap alphabetic`
would say it directly but is Chromium-only and — decisively — has **no effect on a flex container**,
which is what the word-split engine renders. Do it geometrically instead, measured from Google Sans
Flex (ascent .9625em, descent .2875em, cap height .725em):

```css
.trim-flat { margin-block: -0.1125em -0.1625em; }  /* pairs with line-height: 1   */
.trim-body { margin-block: -0.2125em -0.2625em; }  /* pairs with line-height: 1.2 */
```

Apply the one matching the element's own line-height. Getting this wrong shifts every measured
`top` in the frame by a fraction of a line.

## The spring engine

Every reveal in the page is a **physical spring**, not an easing curve. Write one solver and use it
everywhere:

```js
// tension k, friction c, mass m. Semi-implicit Euler, fixed 1/120 s substeps,
// clamp the frame delta to 64ms so a backgrounded tab does not explode it.
// Settled when |v| < 0.001 and |x - target| < 0.0001 — then snap to target.
```

The five presets, verbatim:

| name | tension | friction | mass | used by |
|---|---|---|---|---|
| `reveal` | 120 | 26 | 1 | blocks, media, headings, the nav |
| `snappy` | 170 | 24 | 1 | labels, buttons, small items |
| `soft` | 72 | 30 | 1.1 | the staged card grid, the wordmark letters |
| `counter` | 60 | 34 | 1.1 | the counting statistics |
| `curtain-count` | 90 | 26 | 1 | the preloader's percentage |
| `curtain-open` | 70 | 26 | 1.1 | the preloader's window |

The reveal presets, also verbatim:

```js
fade         = { from: { opacity: 0 },                                to: { opacity: 1 } }
fadeUp       = { from: { opacity: 0, y: 32 },                         to: { opacity: 1, y: 0 } }
fadeUpShort  = { from: { opacity: 0, y: 16 },                         to: { opacity: 1, y: 0 } }
fadeUpBlur   = { from: { opacity: 0, y: 36, filter: "blur(0.75rem)" },to: { opacity: 1, y: 0, filter: "blur(0rem)" } }
```

`y` values are **pixels** unless written with a unit. `fadeUpBlur` must interpolate the number
*inside* the `blur()` string and both ends must be written the same way — `blur(0rem)`, never a bare
`none`. `rem` keeps the blur proportional under the adaptive grid.

Three trigger kinds, all reading from the shared ticker:

1. **on mount, after a delay** — the hero's entrance and the nav.
2. **in view, once** — an `IntersectionObserver` with default options; fires the first time the
   element intersects and never plays backwards.
3. **scrubbed by scroll** — progress is a pure function of `getBoundingClientRect()`:
   `start`/`end` are written as `"<element edge> <viewport edge>"` (`"top bottom"`, `"bottom top"`,
   `"top top"`), each resolving to a scroll offset; progress is that pair linearly mapped and
   clamped to 0–1. Scrubbed values are **not** springs — they track the scroll exactly.

### The word-split reveal

Headings arrive **word by word**. Reproduce the engine, not a library:

- Split the heading's text into words, wrap each in an inline-block span, and lay the spans out in a
  **flex row with `flex-wrap: wrap`** — this is why the geometric text trim above is required, and
  why `text-align` alone cannot centre these headings: pair `text-center` with
  `justify-content: center`.
- **Column gap `0.2245em`.** The engine's natural default is `0.3em`, which is wider than Google
  Sans Flex's own space (measured 0.2245em) and pushes two of these headings onto an extra line.
  Use the measured advance and the Figma line breaks come back.
- Each word springs from `{ opacity: 0, y: <wordOut> }` to `{ opacity: 1, y: 0 }` on the `reveal`
  preset, staggered by `wordStagger` ms in reading order.
- `overflow` clips to the line-height box, so never combine a clipped reveal with a line-height
  below 1.1.
- The animated copy is `aria-hidden`; render a plain, visually-hidden copy of the same string for
  assistive technology, or keep the words as real text and only animate their transforms.

Per heading:

| heading | wordStagger | wordOut y |
|---|---|---|
| hero `h1` | 45 ms | 40 px |
| about `h2` | 22 ms | 28 px |
| numbers `h2` | 35 ms | 28 px |
| location `h2` | 18 ms | 28 px |
| contact `h2` | 45 ms | 40 px |

**Below `lg:` the animated version is not used at all.** The engine positions its split children
absolutely, so they never take part in flex wrapping — a heading that fits at 1440 simply runs off a
narrow screen and no CSS reaches it. Render the same words as ordinary wrapping text under 1280 px
and swap to the animated version from 1280 up. One `<h1>`, two renderings.

## The preloader

A white curtain over everything, `position: fixed; inset: 0; z-index: 100; background: #ffffff;
pointer-events: none`, present from the first paint.

**Timing, exactly:**

```js
PRELOADER_MINIMUM_MS = 1500   // shortest the curtain stays up, even on a warm cache
PRELOADER_HOLD_MS    = 160    // after the count finishes, before the window starts opening
PRELOADER_EXIT_MS    = 1200   // long enough for the window to clear the edges; then unmount
PRELOADER_REVEAL_AT  = 1660   // = MINIMUM + HOLD — the earliest anything behind is visible
```

**The count.** On each frame: `byTime = min(1, elapsed / 1500)`,
`loaded = document.readyState === "complete" ? 1 : 0.85`, `progress = min(byTime, loaded)` — so the
number means something rather than being a fixed animation pretending to measure. Feed `progress`
into the `curtain-count` spring. When it reaches 1, schedule `opening = true` at `+160 ms` and the
curtain's removal at `+1360 ms`.

**The counter's position** — a box `left: 2.5rem; right: 2.5rem; bottom: 2.5rem; height: 5rem;
overflow: hidden`, and inside it a span at `position: absolute; bottom: 0; white-space: nowrap`, set
in `--text-display` (5 rem), weight 300, line-height 1, black. Its label is
`${Math.round(v * 100)}%`. Two springs drive **one** transform:

```js
left      = `${(count * 100).toFixed(2)}%`
transform = `translate(${(-count * 100).toFixed(2)}%, ${(Math.min(1, open * 1.6) * 100).toFixed(2)}%)`
```

so it travels left-to-right as the number climbs, and drops straight down out of the box as the
window opens. The `overflow: hidden` box **is** the mask it leaves through — it is cut off by the
edge, never faded. A fade would read as the screen giving up rather than the counter leaving.

**The reveal is a rounded hole cut out of the curtain**, not a fade and not a transform on the
curtain. Every frame, write a `clip-path` onto the curtain:

```js
const t = Math.min(1, open * 1.06);          // overshoot: a spring settles asymptotically, and at
                                             // open = 0.99 a hairline of curtain still frames the view
const w = vw * t, h = vh * t;
const x = (vw - w) / 2, y = (vh - h) / 2;
const r = Math.min(6 * remPx * (1 - t), w / 2, h / 2);   // 6rem, clamped by the half-extents, so a
                                                          // small window is a pill and the corners
                                                          // square off exactly at the viewport edge
node.style.clipPath = `path(evenodd, "M0,0 H${vw} V${vh} H0 Z ` +
  `M${x+r},${y} H${x+w-r} A${r},${r} 0 0 1 ${x+w},${y+r} ` +
  `V${y+h-r} A${r},${r} 0 0 1 ${x+w-r},${y+h} ` +
  `H${x+r} A${r},${r} 0 0 1 ${x},${y+h-r} ` +
  `V${y+r} A${r},${r} 0 0 1 ${x+r},${y} Z"`)
```

The outer viewport rect plus an inner rounded rect, `evenodd` making the inner one a hole. Write
`t = 0` before the first frame so the curtain is solid while the counter runs. Read `remPx` from
`getComputedStyle(document.documentElement).fontSize` — the radius is a rem value and the root
font-size is viewport-derived, so it cannot be assumed to be 16.

**Do not clip the page instead.** `clip-path` percentages resolve against the element, and the
scroll wrapper is the height of the whole document, so `inset(19%)` removes the entire first screen;
and any `clip-path` on an ancestor establishes a containing block that re-anchors the fixed header.

**Scroll is locked** while the curtain is up (`overflow: hidden` on `<html>`, and
`window.scrollTo(0, 0)` on mount) and released the moment `opening` becomes true — not when the
curtain unmounts.

## Entrance choreography

Offsets from `PRELOADER_REVEAL_AT` (1660 ms), measured from page load, so changing the curtain's
length moves the hero with it:

| element | delay | spring | from → to |
|---|---|---|---|
| nav | 200 ms | `reveal` | `{opacity:0, y:-16}` → `{opacity:1, y:0}` |
| wordmark, letter *i* | 1780 + i × 90 ms | `soft` | `{opacity:0, y:"0.22em", filter:"blur(0.03em)"}` → `{opacity:1, y:"0em", filter:"blur(0em)"}` |
| hero `h1` (words) | 1920 ms | `reveal` | word split, 45 ms stagger |
| hero subtitle | 2080 ms | `reveal` | `fade` |
| hero CTA | 2200 ms | `reveal` | `{opacity:0, y:16}` → `{opacity:1, y:0}` |
| hero caption | 2320 ms | `reveal` | `fade` |

The wordmark's entrance travel is in **`em`**, so it keeps its proportions at every grid width: at
801 px type, `0.22em` is a 176 px rise — about a third of the cap height, enough to read as the word
lifting into place rather than simply fading — and `0.03em` is a 24 px blur.

**Nothing of the wordmark is shown until the webfont is in place.** At this size the
metric-adjusted fallback is nowhere near the real face: measured, `aerra` sets **1909 px** in the
fallback against **1627 px** in Google Sans Flex. Fading in over the swap made the letters appear
bunched and then jump. Await `document.fonts.ready`, then start the entrance at
`max(0, 1780 - performance.now())` — so it still lands on schedule when the font resolves early and
starts immediately rather than late when it resolves after. Until then render the identical box at
`opacity: 0`; the placeholder must keep the same structure or swapping it in is itself a reflow.

## Layout — desktop (≥ 1280 px), the 1440 Figma board

Every section is `position: relative` at its Figma height, and its children are absolutely placed at
their Figma coordinates. All values below are **rem** (÷16 for the Figma pixel).

Section heights, in order: hero `94.625` (1514 px) · about `83.4375` (1335) · numbers `35.75` (572) ·
location `59.875` (958) · audience `105.875` (1694) · contact `70.375` (1126). Total 7199 px.

### Nav (fixed, over everything)

`position: fixed; left: 50%; top: 0.75rem; transform: translateX(-50%); z-index: 50`. Inside, a row:
`border-radius: var(--radius-button); background: var(--grey-150-80);
backdrop-filter: blur(var(--blur-nav)); padding: 0.25rem; gap: 2.5rem; align-items: center`.

- **Logo tile** — `2.75rem` square, `border-radius: var(--radius-control)`, white, centring the
  brand mark at `1.25rem` wide. Hover `scale(1.05)` over `--duration-fast` on `--ease-entrance`.
- **Links** — `gap: 2rem`, `--text-body`, weight 300, black, line-height 1.2, with `.trim-body`.
  Hover drops opacity to `0.6` over `--duration-fast`. Labels and targets under **Copy**.
- **Contact pill** — `height: 2.75rem`, `border-radius: var(--radius-control)`, white,
  `padding: 0.75rem 1.25rem`, `gap: 0.5rem`; a `0.5rem` black dot then the label at
  `--text-body`, weight 500. Hover `scale(1.03)`.
- Focus-visible everywhere: `outline: 2px solid #000; outline-offset: 2px` (`4px` on the links).

Below `lg:` the row becomes `width: calc(100% - 1.5rem)`, `justify-content: space-between`, the links
and the pill are hidden, and a **burger** appears: a `2.75rem` white tile holding two `1px × 1.25rem`
black bars `0.375rem` apart. Open crosses them — `translateY(±3.5px) rotate(±45deg)` over
`--duration-fast`. The panel below it is the same glass at `margin-top: 0.5rem`, `padding: 1.5rem`,
`gap: 1.25rem`, springing `{opacity:0,y:-8}` → `{opacity:1,y:0}` on `snappy`. `Escape` closes it, and
so does following any link in it.

### Hero (0 → 1514)

`min-height: 100svh`, `background-image: var(--backdrop-hero)`, `overflow: hidden`.

Three stacked layers, bottom to top: the sky ramp, the oversized wordmark, then the cut-out house
PNG that the wordmark passes **behind**. Copy sits above all three at `z-index: 20`.

- **Wordmark** — `left: -7.125rem; top: 27.9375rem; width: 104.3125rem; text-align: center;
  pointer-events: none`. Set the word `aerra` at `font-size: var(--text-wordmark)` (50.0872rem),
  weight **100**, line-height 1, `white-space: nowrap`, `.trim-flat`, in the *Display* family.
  Split it into five letters (see **The split wordmark** below). Hidden below `lg:` — at phone width
  it is far wider than the viewport and reads as stray fragments, not a word.
- **`h1`** — `left: 19.6875rem; top: 9.625rem; width: 50.625rem`, centred,
  `font-size: var(--text-display)` (5rem), weight 300, line-height 1, `.trim-flat`. The first
  clause is `--white-50`, the second is white. Word-split, 45 ms stagger.
- **Subtitle** — `left: 28.4375rem; top: 21.1875rem; width: 33.125rem`, centred,
  `--text-lead`, line-height 1.2, white.
- **CTA** — `left: 38.5625rem; top: 26.6875rem; width: 12.875rem`.
- **Caption** — `left: 31.625rem; top: 85.375rem; width: 26.75rem`, centred, `--text-lead`, white,
  two lines separated by a `<br>`.

**The house photograph** is a parallax layer (see below) with `anchor: "start-raised"`,
`start: "top top"`, `end: "bottom top"`. The hero is on screen before any scrolling, so it must open
on the exact Figma framing: `top top` puts progress at 0 on load, and the raised anchor makes that
the neutral position rather than one end of the travel.

### About (1514 → 2849)

- **Eyebrow** — `left: 2.5rem; top: 6.25rem; width: 10.375rem`, `--text-lead`, black.
  `fadeUpShort` on `snappy`.
- **`h2`** — `left: 38.1875rem; top: 6.25rem; width: 49.3125rem`, `--text-title` (2.25rem),
  weight 300, line-height 1.2, `.trim-body`. Word-split, 22 ms stagger.
- **Divider** — `left: 2.5rem; top: 22.375rem; width: 85rem; height: 1px;
  background: var(--black-10)`. `fade` on `reveal`.
- **Highlight 1** — `left: 2.5rem; top: 26.125rem; width: 17.625rem`. `fadeUp` on `reveal`.
- **Highlight 2** — `left: 38.1875rem; top: 26.125rem; width: 14.6875rem`, `delayIn: 90 ms`.
- **CTA** — `left: 66.75rem; top: 33.875rem; width: 20.75rem`. `fadeUpShort` on `snappy`.
- **Photograph** — `left: 2.5rem; top: 40.75rem; height: 42.6875rem; width: 35.0625rem`,
  `border-radius: var(--radius-card)`. A parallax layer, default `center` anchor. `fade` on `reveal`.
  - **Quote card** — inset 30 px from the photograph's left and bottom edges:
    `left: 1.875rem; bottom: 1.875rem; height: 8.125rem; width: 31.375rem; padding: 1.875rem;
    border-radius: var(--radius-button); background: #ffffff`. Inside, a blockquote at
    `width: 22.5rem` (widened from Figma's 282 px, which only fitted the old two-word placeholder —
    a real sentence needs to clear the glyph and stay on one line): the quote at `--text-lead`, the
    attribution `margin-top: 1rem` at `--text-body` weight 300. The quote glyph sits at
    `right: 1.875rem; top: 1.875rem`, `height: 0.875rem; width: 1.1875rem` — **set both axes**, an
    inline SVG given only a width falls back to 150 px tall instead of honouring its viewBox.
- **Turntable panel** — `left: 38.1875rem; top: 40.75rem; height: 42.6875rem; width: 49.3125rem`,
  `delayIn: 90 ms`. This is the drag-to-rotate video (below).

**A highlight tile.** Circle variant: a `4.5rem` square, `border-radius: var(--radius-card)`,
`background: var(--grey-100)`, holding the circle glyph at `inset: 0; width: 100%`. Triangle
variant: a `4.1875rem` square with the triangle glyph at `left: 0.75rem; top: 0.75rem;
width: 2.6875rem` — Figma draws the two tiles at slightly different sizes (72 px and 67 px) and pads
them so both captions still start on the same line; keep it. Title: `margin-top: 2rem` (circle) or
`2.3125rem` (triangle), `--text-lead`. Description: `margin-top: 1rem`, `--text-body`, weight 300.

### Numbers (2849 → 3421)

- **Eyebrow** — centred, `top: 6.25rem`. `fadeUpShort` on `snappy`.
- **`h2`** — `left: 23.125rem; top: 9.75rem; width: 43.8125rem`, `--text-title`, weight 300,
  centred, `.trim-body`, and **`flex-wrap: nowrap !important`** — it is a single unbroken line in
  Figma (1476:1527). Word-split, 35 ms stagger.
- **Subtitle** — `left: 33.1875rem; top: 14.375rem; width: 23.6875rem`, `--text-body`, weight 300,
  centred, `delayIn: 120 ms`.
- **Statistics** — a `<dl>` at `left: 6.875rem; top: 20.25rem; width: 76.25rem`, three items in a
  row with `gap: 0.625rem`, each `width: 25rem`, centred, `gap: 1.5rem`. Reveal `fadeUp` on
  `reveal`, `delayIn: index × 110 ms`.
  - `<dt>` is the **counting value**: `--text-display` (5rem), weight 300, `.trim-body`.
  - `<dd>` is the label: `--text-lead`.

**The counter.** When the element first enters view, run the `counter` spring 0 → 1 once. Render
`(target * p).toFixed(decimals)` — decimals taken from the authored string, so `"3.2"` counts with
one and `"240"` with none. The blur is derived from the **same** spring so the two can never
disagree — the digits sharpen exactly as they stop moving:

```js
remaining = 1 - p
filter  = remaining < 0.01 ? "none" : `blur(${(remaining * 0.75).toFixed(4)}rem)`
opacity = Math.min(1, 0.15 + p * 1.6)
```

A non-numeric value renders as plain text, already settled.

### Location (3421 → 4379)

Full-bleed night photograph as a parallax layer (default `center` anchor), with the **pixel reveal**
canvas stacked over it **inside the same parallax layer** — so the two drift as one rather than
sliding against each other — and under the copy.

- **Eyebrow** — centred, `top: 6.25rem`, white.
- **`h2`** — `left: 5.125rem; top: 9.75rem; width: 79.8125rem`, `--text-title`, weight 300, centred,
  white, `.trim-body`. Word-split, 18 ms stagger.
- **CTA** — `left: 38rem; top: 50.625rem; width: 14rem`.

Section is `overflow: hidden`.

### Audience (4379 → 6073)

`overflow: hidden` — this is what makes the travelling 3D mark appear from beneath the location block
and disappear under the contact block: its canvas fills this section, so both edges clip it.

- **The mark's canvas** — `position: absolute; inset-block: 0; left: 50%; transform: translateX(-50%);
  width: 30rem; z-index: 0; pointer-events: none`. Not a grid cell. It is a **centred 480 px
  column**, not the full section width: the mark travels vertically down the middle and is only
  ~215 px across, so a full-width canvas rasterises roughly three times the pixels it needs — every
  frame, with six backdrop-blurred cards sampling the result.
- **Eyebrow** (as the section's real `h2`) — `left: 2.5rem; top: 6.25rem; width: 9.0625rem`.
- **Intro** — `left: 59.5rem; top: 6.25rem; width: 27.9375rem`, `--text-body`, weight 300,
  `delayIn: 100 ms`.
- **The grid** — `left: 2.5rem; top: 14.25rem; height: 85.375rem; width: 85rem`, `z-index: 10`,
  `display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr);
  gap: 0.5rem`. That is 3 × 448 + 2 × 8 = 1360 wide and 3 × 450 + 2 × 8 = 1366 tall.

**Six of the nine cells carry a card**; cells **2, 4 and 6** (0-indexed: top-right, centre,
bottom-left) stay empty — that gap *is* the layout, and the centre is where the travelling mark
reads clearest. Card order by cell: `0, 1, 3, 5, 7, 8`.

**A card** — `border-radius: var(--radius-card); background: var(--grey-150-80);
backdrop-filter: blur(var(--blur-nav)); padding: 1.875rem`. Glass rather than solid, so the mark
travelling behind stays visible as it passes under each one. Contents: the index at `--text-lead`
in `--black-50`; the title at `--text-title`, weight 300, `.trim-body`; then, pushed to the bottom,
the description at `--text-lead`. Column layout, `gap: 1.5rem` between index and title,
`justify-content: space-between`.

> **The reveal goes on the card's *contents*, never on the card or an ancestor of it.** A `filter` —
> or an `opacity` below 1 — on any ancestor establishes a **backdrop root**, which cuts the glass off
> from everything painted behind it: `backdrop-filter` then samples an empty backdrop, costing full
> GPU work and rendering nothing at all. Springs also settle asymptotically, so a blur reveal leaves
> `filter: blur(0.02px)` on the element permanently and the effect never comes back on its own.

Reveal: `fadeUpBlur` on `soft`, `delayIn = <position among the six cards that carry a card> × 110 ms`
— staged in reading order across the cells that carry one, so the empty ones don't leave a hole in
the rhythm.

### Contact (6073 → 7199)

Full-bleed photograph as a parallax layer (default `center`), `overflow: hidden`.

- **Eyebrow** — centred, `top: 5.625rem`, `--text-body`, white.
- **`h2`** — `left: 16.125rem; top: 8.625rem; width: 57.75rem`, `--text-display` (5rem), weight 300,
  line-height 1, centred, `.trim-flat`; first clause `--white-50`, second white. Word-split,
  45 ms stagger, 40 px travel.
- **Panel** — `left: 2.5rem; top: 46.875rem; height: 21.5rem; width: 85rem; padding: 1.875rem;
  border-radius: var(--radius-card); background: var(--white-85);
  backdrop-filter: blur(var(--blur-panel))`. `fadeUp` on `reveal`.
  - Column, `gap: 6.25rem`.
  - **Body** — `width: 63.5625rem`, `--text-title`, weight 300, `.trim-body`.
  - **Form** — `width: 81.25rem`, column, `gap: 1.875rem`. A row of three fields with
    `gap: 1.875rem`, each `flex: 1`, `border-bottom: 1px solid #000`, `padding-bottom: 0.75rem`;
    the input is transparent, `--text-lead`, no outline, and its **placeholder is the field name in
    solid black** — the design shows only the name sitting on a rule, so the name doubles as the
    placeholder while a real visually-hidden `<label>` stays in the accessibility tree. On focus the
    placeholder drops to `--black-50`.
  - **Submit** — the CTA button, full width.
  - The form posts to `/api/contact`; in a single file, prevent the default and do nothing (or wire
    it to whatever endpoint the rebuild has).

### The CTA button

One shape, four widths. `height: 3rem; border-radius: var(--radius-button); background: #000;
padding: 0.25rem 0.25rem 0.25rem 1.5rem; gap: 1.5rem; overflow: hidden; position: relative;
display: flex; align-items: center; justify-content: space-between`.

- **Label** — `--text-body`, weight 500, white, `white-space: nowrap`, `.trim-body`, `position: relative`.
- **Tile** — `2.5rem` square, `border-radius: var(--radius-control)`, white, `overflow: hidden`,
  centring **two** arrows at `width: 1.125rem`: the first translates `+220%` on hover, the second
  starts at `-220%` and arrives at `0` — so the icon reads as advancing rather than sliding back.
  Both over `--duration-normal` on `--ease-entrance`.
- **Sheen** — `position: absolute; inset-block: 0; left: -33.333%; width: 33.333%;
  transform: skewX(-12deg); background: rgba(255,255,255,0.2); filter: blur(4px);
  pointer-events: none`, moving to `translateX(400%)` on hover over `--duration-slow`. The shell
  clips it, so nothing escapes the rounded corners.
- **Whole pill** — `translateY(-1px)` on hover over `--duration-normal`.

The hover is CSS `transition`, not a spring: it is a discrete state change on colour, opacity and a
small nudge.

## Parallax media

A photograph that drifts against the scroll. The **outer** element is the clip box and keeps the
layout size the design asks for; an **inner** layer holds the image and is what scrubs.

```
DRIFT = 10                                     // % of the clip box — clearly readable without
                                               // throwing the crop off the Figma framing
CENTERED_TRAVEL = 10 / (100 + 2*10) * 100      // = 8.3333%
LIFT            = 20
RAISED_TRAVEL   = 10 / (100 + 20) * 100        // = 8.3333%
```

`translateY` percentages resolve against the **moving layer's own height**, so the centred variant —
whose layer is 120 % tall — needs its travel rescaled; the anchored variant's layer is exactly the
box, so it does not.

| anchor | layer geometry | scrubbed from → to |
|---|---|---|
| `center` (default) | `position: absolute; top: -10%; bottom: -10%; left: 0; width: 100%` | `-8.3333%` → `8.3333%` |
| `start` | `inset-block: 0; left: 0; width: 100%` | `0%` → `10%` |
| `start-raised` | `top: -20%; bottom: 0; left: 0; width: 100%` | `0%` → `8.3333%` |

- `center` is 20 % taller than the box so it always covers, and drifts through the neutral framing
  mid-scroll. The cost is that `object-fit: cover` scales the image up by that 20 %, so the crop is
  tighter than the Figma one. Right for anything you scroll *into*.
- `start-raised` is required for the hero. The hero photograph is a cut-out whose aspect matches its
  section exactly, so `object-fit: cover` has no slack and `object-position` cannot move it. Sliding
  it up would lift the terrain off the bottom edge and show a band of bare sky; growing the layer
  **from the bottom** raises the subject instead, and the overflow is clipped at the section's top
  where there is only sky anyway.

The outer box is `position: absolute; inset: 0; overflow: hidden`; the image inside is
`width: 100%; height: 100%; object-fit: cover`. Default trigger range is `"top bottom"` →
`"bottom top"`; the hero overrides it to `"top top"` → `"bottom top"`.

## The split wordmark

The word `aerra`, split so **alternating letters climb at different rates** as the page scrolls.

- Each letter is its own scroll-scrubbed span, `start: "top top"`, `end: "bottom top"`, scrubbing
  `y: 0rem` → `y: -<travel>rem`. Odd-indexed letters (1, 3) travel **14 rem**; even-indexed
  (0, 2, 4) travel **5 rem**. Kept modest on purpose: at a 50 rem type size a large gap stops
  reading as "some letters lead" and starts reading as the word coming apart. The word has to stay
  a word.
- **The gradient goes on each letter, not the wrapper.** `background-image: var(--wordmark-fill);
  -webkit-background-clip: text; background-clip: text; color: transparent` on the letter's inner
  span. On the wrapper it masks to the glyphs the *wrapper* paints, and a transformed child is
  composited separately — so the letters fall straight out of the mask and render as nothing at all.
- **No `will-change` on the letters.** Promoting each to its own layer is exactly what drops it out
  of a `background-clip: text` mask, and five 800 px layers is real memory for no measured gain.
- The entrance **wraps** the scroll-scrubbed span rather than nesting inside it. Two reasons: the
  scrub is driven by scroll position and has no way to also play a one-shot, and — decisively — the
  gradient lives on the inner span, so putting a `filter` *below* it would composite the glyphs out
  of the mask and render nothing. Above it, the mask is untouched.
- The wrapper carries `role="img"` and `aria-label="aerra"`; the letters are decorative fragments
  and must not be read out one at a time.

## The turntable (drag-to-rotate video)

A video advanced **frame by frame** rather than played. The clip is never `play()`ed: a phase is
advanced on the shared ticker and mapped onto `currentTime`, so the file behaves like an image
sequence while staying a single compressed asset.

> This requires an **all-keyframe encode**. With sparse keyframes the browser snaps to the nearest
> one and the rotation looks steppy rather than continuous.

Constants, verbatim:

```js
KEY_STEP         = 1 / 24        // one arrow-key press — a 15° notch
FLICK_DAMPING    = 0.14          // how much release velocity carries into the glide
SEEK_EPSILON     = 1 / 240       // don't re-seek for less than a quarter-frame at 60fps
SEEK_INTERVAL_MS = 1000 / 30     // minimum gap between seeks — roughly the clip's frame rate
MAX_DELTA_MS     = 100           // ignore absurd frame deltas (tab was backgrounded)
AUTO_END         = 0.9995        // where the single playthrough stops
PLAY_SPEED       = 2.2           // multiplier on the clip's authored speed
drag spring      = { tension: 210, friction: 30, mass: 0.8 }
wrap(v)          = ((v % 1) + 1) % 1
```

`AUTO_END` is just short of a whole turn because the phase is wrapped before it becomes a timestamp
and `wrap(1)` is 0 — landing on exactly 1 would snap back to the first frame instead of resting on
the last one.

**Behaviour.** It plays itself through **once**, starting when it scrolls into view, and is
draggable throughout — by the surface itself or by the scrubber, which are two views of one phase
value. Taking hold of either **cancels** the playthrough rather than fighting it, so the two inputs
never argue over the same value and the handle never jumps.

Per frame:

```js
delta = last ? Math.min(now - last, MAX_DELTA_MS) : 0
if (autoPhase < AUTO_END && scrubPhase === null)
  autoPhase = Math.min(AUTO_END, autoPhase + (rate * PLAY_SPEED * (delta/1000)) / duration)

// a held scrubber is authoritative: no spring between pointer and frame, and CLAMPED rather than
// wrapped, so the ends are walls instead of a jump back to the far side
phase = scrubPhase !== null ? scrubPhase : wrap(autoPhase + dragSpring.value)

handle.style.left = `${(phase * 100).toFixed(3)}%`   // every frame, unthrottled

if (now - lastSeek < seekInterval) return             // only the SEEK is throttled
lastSeek = now
if (video.seeking) return                             // never queue a second seek — that is what
                                                      // makes scrubbing stutter; always chase the
                                                      // latest value, never a backlog
const next = phase * duration
if (Math.abs(video.currentTime - next) > SEEK_EPSILON) video.currentTime = next
```

Writing the handle's position is a style assignment costing nothing; inheriting the seek's 30 Hz
budget made it step visibly behind a 60 Hz cursor, which is what read as a choppy scrubber.

**Dragging the surface:** `turns = dx / (width * widthsPerTurn)` with `widthsPerTurn = 1`, and
`target -= turns` — drag right rotates the subject right, i.e. runs the sequence backwards. Track
velocity as `turns / dt`; on release, `target -= velocity * FLICK_DAMPING * 1000` so a flick keeps
spinning and the same spring decelerates it. Use pointer capture, and `touch-action: pan-y` so
vertical page scrolling still works while a horizontal drag rotates.

**On release of the scrubber**, rebase: `autoPhase = landed - dragSpring.value`, so the value is
handed back to the spring path without moving it. Skipping this is what would make the frame jump.

**The scrubber** — `position: absolute; left: 1.6875rem; right: 1.6875rem; bottom: 1.875rem;
height: 2.1875rem; border-radius: 9999px; background: var(--grey-150-80);
backdrop-filter: blur(var(--blur-nav)); padding-inline: 1.5625rem; display: flex; align-items:
center; cursor: ew-resize`. Inside, the **track** is `height: 1px; width: 100%; background: #fff` —
it is the measured element — and the **handle** rides it: `position: absolute; top: 50%;
height: 0.6875rem; width: 2.375rem; border-radius: 9999px; border: 1px solid #fff; background: #000;
transform: translate(-50%, -50%)`. `left`, not `translateX`: a percentage translate resolves against
the handle's own width, not the track it is supposed to travel.

`role="slider"`, `aria-valuemin=0`, `aria-valuemax=100`, `aria-valuenow` updated **on release**, not
per frame. Arrow keys step `±KEY_STEP`, `Home` returns to 0.

The `<video>` is `muted playsinline preload="auto" aria-hidden="true" tabindex="-1"
disablepictureinpicture`, `position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; pointer-events: none`. Nudge `currentTime` off zero by `SEEK_EPSILON` once
metadata arrives, so the first frame paints without ever calling `play()`. The host box is
`background: var(--grey-100)`, `border-radius: var(--radius-card)`, `overflow: hidden`,
`cursor: grab` / `grabbing`, `user-select: none`.

## The pixel reveal (raw WebGL)

An image painted in by a **pixel wave** wherever the cursor moves, over the location photograph.

A coarse "heat" grid is kept on the CPU — a few thousand cells, cheap to update — and uploaded each
frame as a tiny texture. The fragment shader reads it with `NEAREST` filtering, so the grid's own
cells **are** the pixels of the effect; no pixelation pass is needed.

**Raw WebGL, not three.js:** this is one full-screen quad, and the location section should not pull
in a 3D engine to draw it.

```js
COLS = 84, ROWS = 56          // heat grid — each cell is one visible pixel block
DECAY = 0.955                 // heat retained per frame — how long the trail lingers
RADIUS = 11                   // splat radius, in grid cells
PRELOAD_MARGIN = "600px"      // spin the context up this far before the section arrives
```

**Vertex shader (verbatim):**

```glsl
attribute vec2 position;
varying vec2 vUv;
void main() {
  vUv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}
```

**Fragment shader (verbatim):**

```glsl
precision mediump float;
varying vec2 vUv;
uniform sampler2D uImage;
uniform sampler2D uHeat;
uniform vec2 uGrid;
uniform vec2 uCoverScale;

void main() {
  // Heat stays in canvas space, so the wave follows the cursor regardless of
  // how the artwork is fitted. NEAREST on a low-res grid quantises the reveal
  // into blocks — the grid's own cells are the pixels of the effect.
  float heat = texture2D(uHeat, vUv).r;

  // Cover-fit, matching the object-cover photograph underneath: the canvas is
  // 120% of the section tall (it rides the same parallax layer), so sampling
  // straight would stretch the artwork out of step with the photo.
  vec2 uv = (vUv - 0.5) * uCoverScale + 0.5;

  // No displacement: warping the lookup smeared the artwork into streaks
  // instead of revealing it.
  vec4 image = texture2D(uImage, uv);

  // Quantise the alpha to the same block grid so the edge steps in pixels
  // rather than fading smoothly, then keep a touch of softness at the tail.
  float alpha = smoothstep(0.04, 0.5, heat);
  gl_FragColor = vec4(image.rgb, image.a * alpha);
}
```

**Context:** `getContext("webgl", { alpha: true, premultipliedAlpha: false, antialias: false,
depth: false, stencil: false })` — one flat quad, nothing here depth-tests, stencils or antialiases.
Geometry is a single full-screen triangle: `new Float32Array([-1,-1, 3,-1, -1,3])`, drawn as
`gl.TRIANGLES, 0, 3`. Blending `SRC_ALPHA, ONE_MINUS_SRC_ALPHA`.

**Textures:** the heat texture is `NEAREST`/`NEAREST`, `CLAMP_TO_EDGE`, on unit 1; the image is
`LINEAR`/`LINEAR`, `CLAMP_TO_EDGE`, on unit 0, uploaded with `UNPACK_FLIP_Y_WEBGL = 1` and
`crossOrigin = "anonymous"` on the `Image` (the asset is cross-origin — a CORS header on the bucket
is necessary and **not** sufficient).

> **`UNPACK_FLIP_Y_WEBGL` is global GL state.** The image upload turns it on; set it back to `0`
> before every heat upload or the grid is flipped too and the whole reveal is mirrored.

**Cover scale:** with `canvasAspect > imageAspect`, `uCoverScale = (1, imageAspect/canvasAspect)`,
otherwise `(canvasAspect/imageAspect, 1)`.

**Per frame:**

1. Resolve the pointer against the canvas — **one `getBoundingClientRect()` per frame, inside the
   ticker**, never in the listener. A rect read in a `pointermove` handler that fires during scroll
   is a forced layout on every event.
2. `u,v` outside 0–1 → no splat. Otherwise splat at `{ col: u*COLS, row: (1-v)*ROWS }` — the heat
   texture is sampled with the image's flipped Y, so paint into the row the shader will read.
3. Decay the whole grid by `DECAY`, tracking the peak.
4. Add the splat over a `RADIUS`-cell disc: `falloff = 1 - dist/RADIUS`, skip `<= 0`,
   `heat = min(1, heat + falloff² * 0.34)`.
5. If `peak < 0.002` and there is no splat, skip the upload and the draw entirely.
6. Pack heat into the red channel of an `RGBA` byte array, upload, clear to transparent, draw.

**The stationary-cursor trap.** Scrolling the page under a stationary cursor makes the browser
synthesise `pointermove` events at the *same* coordinates. Treating those as hovering paints the
reveal in wherever the cursor happens to be parked, so the mask is already showing on arrival —
**ignore an event whose `clientX`/`clientY` match the previous one.** A cursor that stopped moving
stops feeding the wave and the trail decays out on its own.

**Gating.** Build the context only once the section is within `PRELOAD_MARGIN`, and then **never
tear it down** — proximity decides when to build, never when to destroy. Depending the effect on
visibility directly meant leaving the section disposed the program and returning re-created it,
measured as a `linkProgram` plus a 57–81 ms long task on *every* re-entry, landing mid-scroll. The
*draw* is gated separately on an `IntersectionObserver` at `10%` root margin and on
`document.hidden`. On leaving, `heat.fill(0)` — leave cold, arrive cold, or heat left in the grid
is still there on the way back and reads as a reveal nobody asked for.

**It does not run at all** unless `(hover: hover) and (pointer: fine) and (min-width: 768px)`.

Device budget: pixel ratio is clamped to **1.0** on every tier for this effect (it is quantised to
an 84 × 56 grid and cannot show more), and to `0.85` on mobile; frame budget is
`1000/30 - 1` ms on mobile, `1000/45 - 1` on tablet, `0` on desktop.

> The epsilon matters: the shared ticker skips while `time - last <= framerate`, so a bare `1000/30`
> lands on the first tick past 33.3 ms — about 26 fps, not 30.

## The 3D mark (three.js)

A Draco-compressed glTF of the AERRA logo, **matte black metal**, turning and travelling the length
of the audience section.

```js
CAMERA_FOV = 35, CAMERA_Z = 4.2
visibleHeight = 2 * CAMERA_Z * tan(CAMERA_FOV/2 * π/180)   // canvas height in world units
HEIGHT_RATIO = 0.127     // model height as a fraction of the canvas
turns        = 2         // full turns across the scroll range
CHASE_TAU    = 0.12      // seconds for the rendered angle to close most of the gap
EPSILON      = 0.0004    // below this the frame is identical — skip the draw
PRELOAD_MARGIN = "800px" // start loading three this far before the section arrives
```

**Renderer:** `WebGLRenderer({ alpha: true, antialias: <not mobile>, stencil: false,
powerPreference: <"high-performance" on desktop, else "default"> })`,
`outputColorSpace = SRGBColorSpace`, `toneMapping = ACESFilmicToneMapping`,
`toneMappingExposure = 1.15`. Filmic tone mapping keeps the specular roll-off on a dark metal from
clipping to a flat white edge. The canvas is `aria-hidden`; the host carries `role="img"` and the
label.

**Pixel ratio:** clamp to `1.0` on mobile, `1.25` on tablet, `1.5` on desktop, with a floor of
`0.75`. **`renderer.setSize(w, h)` with `updateStyle` on** — with a pixel ratio above 1 the backing
store is larger than the layout box, and without the CSS size three leaves the canvas to lay out at
its attribute size, overflowing the box by exactly the device pixel ratio.

**Lighting:** a PMREM'd `RoomEnvironment` at `0.04` as `scene.environment`, plus **one**
`DirectionalLight(0xffffff, 2.4)` at `(2.5, 3, 2.5)`. A metal is almost entirely reflection, so
without an environment it renders as a flat black silhouette — the synthetic room is what makes it
read as metal rather than plastic. One key light only: every real-time light multiplies the fragment
cost of every lit material, and the PMREM'd room already supplies the fill and the rim.

**Material** — applied to every mesh in the file, replacing whatever it shipped with:

```js
new MeshStandardMaterial({ color: 0x0b0b0b, metalness: 1, roughness: 0.62, envMapIntensity: 1.1 })
```

High roughness on a full metal is what "matte metal" means — it keeps the reflection broad and soft
instead of mirror-sharp.

**Normalisation:** box the loaded scene, centre it on a pivot, then
`scale = (visibleHeight * HEIGHT_RATIO) / max(size.x, size.y, size.z)`. Size is expressed as a
fraction of the canvas rather than in world units, so it stays put when the canvas is resized or the
adaptive grid rescales the page.

**Prewarm:** `renderer.compile(scene, camera)` then one `renderer.render()` immediately after the
model loads, while it is still off screen — so the first frame after it scrolls into view does not
stall on a shader link or a texture upload.

**Per frame**, driven by the section's own scroll progress (`"top bottom"` → `"bottom top"`, written
into a plain variable every frame, never React state):

```js
targetAngle  = progress * turns * 2π
targetOffset = (0.5 - progress) * (visibleHeight + modelHeight * 2)   // enter above, leave below

if (!primed) { primed = true; angle = targetAngle; offsetY = targetOffset }   // SNAP on the first
else {                                                                        // drawn frame
  const k = delta > 0 ? 1 - Math.exp(-delta / CHASE_TAU) : 1                   // frame-rate
  angle   += (targetAngle  - angle)   * k                                      // independent chase
  offsetY += (targetOffset - offsetY) * k
}
if (|angle - lastAngle| < EPSILON && |offsetY - lastOffset| < EPSILON) return  // identical frame
pivot.rotation.y = angle; pivot.position.y = offsetY; renderer.render(scene, camera)
```

The snap matters: `offsetY` starts at 0, which is the **middle** of the travel rather than the start
of it, so chasing from there makes the model fly up to its entry point and only then descend. Snap
on a jump, ease only while tracking. Re-prime whenever the loop was gated off, because scroll kept
moving while it was.

**Gating:** don't draw while the tab is hidden or the section is off screen (`IntersectionObserver`,
`10%` root margin), and under reduced motion / low-power draw **one** frame and stop — WebGL keeps
the last frame on the canvas, so a frozen scene costs nothing at all. As with the pixel reveal,
**mount once and never unmount**: rendering `near ? <scene> : null` tore the whole scene down on the
way out and rebuilt it — renderer, model, environment, every material — on the way back, mid-scroll,
measured as a 50–83 ms long task on each re-entry.

**Load gating:** don't fetch three at all until the section is within `PRELOAD_MARGIN`. Without that
gate ~456 KB of three plus the Draco decoder are fetched and parsed while the visitor is still
looking at the hero — measured as a 125 ms main-thread block at load. Code-splitting alone defers the
*bundle*, not the *work*.

## Responsive (below 1280 px)

Every desktop rule above is the original one moved behind a `min-width: 1280px` query. Below it the
page is a **stacked, self-sizing** document — nothing is absolutely positioned:

- Sections lose their fixed heights and take `padding: 5rem 1.25rem` (`6rem` block on location and
  contact); the hero becomes a centred column at `min-height: 100svh`, `padding: 7rem 1.25rem 10rem`.
- The hero wordmark is **hidden**. The `h1` and the contact `h2` drop to `--text-title` and render as
  plain wrapping text (the word-split engine is not used — see above).
- The about section stacks: eyebrow, heading, divider, the two highlights, the CTA, the photograph at
  `aspect-ratio: 561/683`, then the turntable at `aspect-ratio: 789/683`.
- The statistics stack in a centred column below 540 px and go to a row from `tablet:` up — 834 px
  fits them comfortably, and stacked they read as three unrelated facts.
- The audience grid is one column below 540 px, **two** from `tablet:` up (one 780 px-wide card per
  row is a phone layout stretched, not a layout), three at `lg:`. The empty diagonal cells are the
  desktop layout and are **not rendered** below it — in a single column they would just be dead
  space. Cards take `min-height: 15rem` and `padding: 1.5rem`.
- The contact form's three fields stack below 540 px and go to a row from `tablet:`.
- The nav collapses to logo + burger (above).

## Copy (verbatim)

**Nav** — `The house → #about` · `Location → #location` · `Price → #audience` ·
`Reviews → #contact`; CTA `Contact → #contact`. Logo links to `/`, `aria-label="AERRA — home"`.

**Hero** — wordmark `aerra`. Headline: `Where the road ends ` + `and the quiet begins` (the first
clause dimmed, the second white). Subtitle: *A 240 m² house on a south-facing slope, 40 minutes from
the city. One owner, one view, no neighbours in sight.* CTA `Book a viewing → #contact`. Caption,
two lines: *Finished and ready to live in —* / *heat, light and everything in between*.
Hero image alt: *The AERRA house lit from within at dusk, standing alone on a rocky mountain slope.*

**About** — eyebrow `About the house`. Body: *AERA is two floors of cedar and glass, set into the
slope. Floor-to-ceiling windows open the living room to the valley, and the terrace runs into the
treeline. Everything is done — down to the last socket.* CTA `Book a viewing → #contact`.
Highlights: **Move-in ready** — *Full finish, built-in kitchen and appliances included in the price.*
(circle glyph) · **Freehold land** — *0.45 acres, title clear, no charges or easements.* (triangle).
Quote: *"We stayed until it got dark."* — *The first family to view it, spring 2026*.
Photograph alt: *The house seen from the valley floor, its windows glowing against the dark
treeline.* Turntable label: *The house in 360 degrees — drag to rotate it*.

**Numbers** — eyebrow `By the numbers`. Heading: *A light on the mountain, kept on just for you.*
Subtitle: *Forty minutes to the city centre, sealed road to the gate, nearest neighbour behind the
hill*. Statistics: **240** *m² of living space* · **3.2** *metre ceilings* · **40** *minutes to the
city*.

**Location** — eyebrow `Location`. Body: *The house sits last in the line — beyond it, only forest
and ridge. Mains water and power, fibre broadband, and a road that gets cleared all winter. Isolation
without the trade-offs: schools, shops and the motorway are fifteen minutes out.* CTA `See it on the
map → #contact`. Photograph alt: *The house on its ridge at blue hour, the last building before open
forest.* Reveal label: *Move the cursor to uncover the site plan*.

**Audience** — eyebrow `Who this is for`. Intro: *This house isn't for everyone, and that's the
point. It's for people who've already worked out how they want to live — with a view instead of a
fence, quiet instead of a shared courtyard.* Cards, in cell order `0, 1, 3, 5, 7, 8`:

| # | title | description |
|---|---|---|
| 01 | A second home | You want somewhere you can reach in an hour, not somewhere you pack a week for. |
| 02 | Leaving the city | You're ready to live here full-time without giving up anything the flat gave you. |
| 03 | Working from home | You want a study facing the ridge, not a window facing the neighbour's wall. |
| 04 | Family | Four bedrooms, two baths and a plot with somewhere for the kids to run. |
| 05 | An investment | A liquid asset in a closed location: land freehold, house complete, offers considered. |
| 06 | Short-term letting | Ready to rent out tomorrow — furnished, equipped, and reachable year-round. |

Mark label: *The AERRA mark, rendered in three dimensions and turning with the page*.

**Contact** — eyebrow `Contact`. Heading: `Come and see it ` + `photographs undersell it` (first
clause dimmed). Body: *Leave your details and we'll walk you through it in person, or arrange a live
video tour whenever suits. We reply the same day.* Fields: `Name` (text, `autocomplete="name"`),
`Phone` (tel, `tel`), `Email` (email, `email`) — all required. Submit `Book a viewing`.
Photograph alt: *Close view of the cedar-clad facade and terrace at dusk.*

**Skip link** — `Skip to content → #main`, visually hidden until focused, then fixed at
`left: 0.75rem; top: 0.75rem` as a black pill.

## The inline SVGs

Four, all `fill="none"` with `fill="currentColor"` paths, `aria-hidden="true" focusable="false"`.

**Brand mark** — `viewBox="0 0 120 129"`, two paths (a chevron over an arc):

```
M28.596 81.9388C28.596 99.4431 42.8424 113.633 60.4162 113.633C77.99 113.633 92.2364 99.4431 92.2364 81.9388H107.664C107.664 107.93 86.5107 129 60.4162 129C34.3218 129 13.168 107.93 13.168 81.9388H28.596Z
M120 59.7644L109.092 70.6293L60 21.7335L10.9081 70.6293L0 59.7644L60 0L120 59.7644Z
```

**Arrow right** — `viewBox="0 0 18 8.71875"`:

```
M17.7938 3.86167L17.7931 3.861L14.1192 0.204748C13.8439 -0.0691546 13.3987 -0.0681353 13.1248 0.207138C12.8508 0.482376 12.8519 0.92756 13.1271 1.2015L15.5938 3.65625H0.703125C0.314789 3.65625 0 3.97104 0 4.35937C0 4.74771 0.314789 5.0625 0.703125 5.0625H15.5938L13.1272 7.51725C12.8519 7.79118 12.8509 8.23637 13.1248 8.51161C13.3988 8.78692 13.844 8.78786 14.1192 8.514L17.7932 4.85775L17.7938 4.85708C18.0692 4.58223 18.0683 4.1356 17.7938 3.86167Z
```

**Highlight — circle** — `viewBox="0 0 72 72"`: `<circle cx="36" cy="36" r="19.5" stroke="currentColor"/>`

**Highlight — triangle** — `viewBox="0 0 43 41"`, stroked not filled:

```
M18.4688 5.25586C19.8157 2.92123 23.1843 2.92123 24.5312 5.25586L40.1064 32.251C41.4524 34.5843 39.7679 37.5 37.0742 37.5H5.92578C3.23205 37.5 1.5476 34.5843 2.89355 32.251L18.4688 5.25586Z
```

**Quote glyph** — `viewBox="0 0 18.656 14.272"`:

```
M18.656 1.856L15.328 6.624C16.1387 6.79467 16.8213 7.22134 17.376 7.904C17.952 8.56533 18.24 9.36533 18.24 10.304C18.24 11.4133 17.856 12.352 17.088 13.12C16.32 13.888 15.3493 14.272 14.176 14.272C13.024 14.272 12.064 13.888 11.296 13.12C10.528 12.352 10.144 11.4133 10.144 10.304C10.144 9.70667 10.2613 9.09867 10.496 8.48C10.7307 7.84 11.1893 7.008 11.872 5.984L15.904 0C15.904 0 16.8213 0.618668 18.656 1.856ZM8.512 1.856L5.184 6.624C5.99467 6.79467 6.67733 7.22134 7.232 7.904C7.808 8.56533 8.096 9.36533 8.096 10.304C8.096 11.4133 7.712 12.352 6.944 13.12C6.176 13.888 5.216 14.272 4.064 14.272C2.912 14.272 1.94133 13.888 1.152 13.12C0.384 12.352 0 11.4133 0 10.304C0 9.70667 0.117333 9.09867 0.352 8.48C0.608 7.84 1.07733 7.008 1.76 5.984L5.76 0C5.76 0 6.67733 0.618668 8.512 1.856Z
```

## Fixed parameters (bake these in)

```
root ladder            1.111111vw · 16px ≤1279 · 3.72093vw ≤539
breakpoints            phone <540 · tablet 540–1279 · lg ≥1280
type                   16 / 20 / 36 / 80 / 801.395 px
weights                100 wordmark · 300 headings & body-light · 400 base · 500 buttons/nav CTA
line-heights           1 (display) · 1.2 (everything else)
text trim              flat -0.1125em/-0.1625em · body -0.2125em/-0.2625em
word gap               0.2245em
radii                  6 / 8 / 10 px
blur                   10px (nav, glass, scrubber) · 37.5px (contact panel)
durations              150 / 250 / 700 ms, cubic-bezier(0.2, 0, 0, 1)
springs                reveal 120/26/1 · snappy 170/24/1 · soft 72/30/1.1 · counter 60/34/1.1
                       curtain-count 90/26/1 · curtain-open 70/26/1.1 · drag 210/30/0.8
preloader              1500 min · 160 hold · 1200 exit · reveal at 1660 · window radius 6rem
                       · overshoot ×1.06 · counter exit ×1.6
hero delays            nav 200 · wordmark 1780 (+90/letter) · heading 1920 · subtitle 2080
                       · cta 2200 · caption 2320
wordmark travel        odd letters 14rem · even letters 5rem · entrance 0.22em / blur 0.03em
parallax               drift 10% · centred layer 120% · raised layer 120% bottom-anchored
card stagger           110 ms · numbers stagger 110 ms · about highlight 2 delay 90 ms
counter                blur 0.75rem max · settle epsilon 0.01 · opacity min(1, 0.15 + p*1.6)
pixel reveal           84×56 · decay 0.955 · radius 11 · add falloff²×0.34 · alpha smoothstep(0.04,0.5)
3D mark                fov 35 · z 4.2 · heightRatio 0.127 · 2 turns · chase tau 0.12 · epsilon 0.0004
                       · metal 0x0b0b0b, metalness 1, roughness 0.62, envMapIntensity 1.1
                       · exposure 1.15 ACES · key light 2.4 at (2.5, 3, 2.5)
device tiers           mobile <768 or coarse pointer · tablet <1024 · desktop
frame budget           mobile 1000/30-1 · tablet 1000/45-1 · desktop 0
```

## Assets

All fetched from `https://storage.getlayers.ai/assets/aerra-1ac2dea4cb/`. Set that as one
`ASSETS` constant and build every URL from it.

| file | used by |
|---|---|
| `hero.png` | the hero cut-out (2160 × 2271), `priority` / eager |
| `about.png` | the about photograph (842 × 1025) |
| `about-video.mp4` | the turntable — an **all-keyframe** encode |
| `location.png` | the location photograph (2160 × 1437) |
| `mask.png` | the site plan painted in by the pixel reveal |
| `contact.png` | the contact photograph (2160 × 1689) |
| `model.glb` | the 3D mark, Draco-compressed (3 KB) |
| `GoogleSansFlex_24pt-Thin.ttf` | weight 100 — the wordmark's fallback and thin UI |
| `GoogleSansFlex_24pt-Light.ttf` | weight 300 — headings and body-light |
| `GoogleSansFlex_24pt-Regular.ttf` | weight 400 |
| `GoogleSansFlex_24pt-Medium.ttf` | weight 500 — buttons and the nav CTA |
| `GoogleSansFlex-Variable-latin.woff2` | the display family — `opsz` for the 801 px wordmark |

Every element that reads pixels from a fetched asset — the `Image` feeding the pixel-reveal texture,
and three's loaders — must carry **`crossorigin="anonymous"`**. A CORS header on the bucket is
necessary and **not sufficient**; without the attribute the texture upload throws, the exception is
swallowed by the shared ticker, and the canvas 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 and what to do about it — a missing asset otherwise renders as a
correct DOM over a blank canvas with nothing but a console line to explain it.
[/hidecontent]
← 返回首页