Brewns
aadmin2026-11-05 发布Site模板0 阅读

Brewns

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

[hidecontent type="vip1"]
# Recreate this site as a single HTML file: BREWNS — Specialty Coffee House

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 Lenis are the only external code**, pulled through an
import map; the spring solver, the shared ticker, the reveal observers, the scroll scrubs, the text
reveals, the digit rolls, the pointer leans, the swirl fills, the printer feed and the preloader are
all written by hand. Hardcode every value given here as a fixed constant.

Where this document quotes source verbatim it is the project's own TypeScript — **strip the type
annotations and use it as it stands**. Every number in it is the shipped default. Nothing in it is
a suggestion.

## What it is

A one-page site for **brewns**, a specialty coffee house in San Francisco ("Concept 7", a 1440×800
board). The page alternates two surfaces: **near-black** (`#070707`) for the hero, the philosophy
block, the order block and the footer, and **bone** (`#f1f1ef`) for the menu and the locations
blocks — and one fixed header recolours itself to whichever it is passing over. Four faces: **Geist**
(bold/semibold, uppercase — every headline, the lede and the CTAs), **Space Mono** (every label,
eyebrow, nav link, price, address and the receipt), **Allura** (one word: the script "moment" laid
over the hero headline) and **Onest** (bound as the sans, never visibly used). One accent, amber
`#d58c3d`, spent only on a pulsing dot beside ORDER ONLINE and OUR LOCATIONS.

Two WebGL layers and one WebGL2 layer, all written by hand:

- **Opalesce** — a thin-film-interference gradient in coffee browns, one fullscreen triangle and one
  fragment shader, running behind the hero and behind the philosophy block; the cursor thickens the
  film so colour travels out behind the hand.
- **The hero product** — a Draco `.glb` of a bag of beans and a takeaway cup on a plain three.js
  scene, fitted to exactly the rectangle a product photograph used to occupy, lit by a baked studio
  of four softboxes plus three keys. It rises into place when the preloader pours away, leans toward
  the cursor (the cup further and sooner than the bag), lifts the piece the pointer is on, can be
  grabbed and spun, and sinks as the hero scrolls away.
- **The philosophy scene** — the same file's cup alone, tilted, standing where the board draws its
  cup and cut by the section's bottom edge, with 34 coffee beans (16 on a phone) floating
  weightless behind it on a hand-written rigid-body field: air wander, cursor push and wake, elastic
  bean-on-bean and bean-on-cup hits with friction, walls at the frustum, and a scroll vortex.

Everything else is DOM: a preloader that is a drawn cup filling with coffee and pouring away; a
menu of four product cards that lean toward the pointer with their photographs lagging behind on
the way in and a receipt that slides in behind VIEW FULL MENU on hover; a clock face with an iced
latte at its centre that leans toward the cursor and turns to 38° as you scroll past; the brand
swirl that grows out of four soft blobs behind the light blocks; a thermal printer that feeds a
receipt out of its slot in 46 jerks as the block rises, with a barcode that settles to its printed
height once it clears the housing; and a 385px footer band.

The page is written to a **1440-wide board** in `rem`, with the root font-size scaling with the
viewport (damped below 1440, damped again above it), so every rem in this document is one board
pixel at 1440. Below 1024 every block lays itself out in flow instead — there is no tablet or phone
board, so those layouts are the project's own and are given here too.

## Page shell & libraries

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>brewns — Specialty Coffee House in San Francisco</title>
<meta name="description" content="Specialty coffee house in San Francisco. Carefully sourced beans, thoughtfully brewed. Order ahead and skip the line — three locations, open daily 07:00–21:00.">
<meta name="theme-color" content="#070707">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@600;700&family=Onest:wght@400&display=swap" rel="stylesheet">
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
    "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs"
  }
}
</script>
```

**three is 0.180.0**, not newer — `NeutralToneMapping`, `PMREMGenerator.fromScene`, `RoomEnvironment`,
`InstancedMesh` and the loaders below all exist there and the tone curve is what the product was
lit against. Imports: `GLTFLoader` from `three/addons/loaders/GLTFLoader.js`, `DRACOLoader` from
`three/addons/loaders/DRACOLoader.js`, `RoomEnvironment` from
`three/addons/environments/RoomEnvironment.js`.

### Fonts

Two are self-hosted and come from the bucket; two come from Google Fonts.

```css
@font-face {
  font-family: "Space Mono";
  src: url("https://storage.getlayers.ai/assets/brewns-b9f02b6657/fonts/SpaceMono-Regular.ttf") format("truetype");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Allura";
  src: url("https://storage.getlayers.ai/assets/brewns-b9f02b6657/fonts/Allura-Regular.ttf") format("truetype");
  font-weight: 400; font-style: normal; font-display: swap;
}
:root {
  --font-geist: "Geist", system-ui, sans-serif;   /* weights 600 and 700 from Google Fonts */
  --font-onest: "Onest", system-ui, sans-serif;   /* bound as --font-sans; nothing on the page uses it */
  --font-space-mono: "Space Mono", ui-monospace, monospace;
  --font-allura: "Allura", cursive;
}
```

Geist is taken from Google rather than the bucket on purpose: the philosophy and order headlines are
**SemiBold (600)** and the hero and menu headlines **Bold (700)**, and a single static Bold file
cannot synthesise 600. `font-display: swap` everywhere; the preloader waits on `document.fonts.ready`
before it lets the page open, so nothing below reflows.

### Smooth scroll

Lenis, at the project's own settings, driven from your one `requestAnimationFrame` loop:

```js
import Lenis from "lenis";
const lenis = new Lenis({ smoothWheel: true });
window.lenis = lenis;
// inside the shared ticker, first thing every frame:
lenis.raf(time);
```

Include Lenis's required rules (`html.lenis, html.lenis body { height: auto }`,
`.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain }`,
`.lenis.lenis-stopped { overflow: hidden }`). The preloader calls `lenis.stop()` while it is up and
`lenis.start()` when it has gone; the phone menu panel does the same while open. Stopping also sets
`html { position: relative; overflow: hidden; height: 100% }` and starting removes those three
properties. `window.scrollTo(0, 0)` once at boot.

### The root font-size

The board is 1440 wide and every coordinate is a rem, so the root scales — but **damped**, both ways:

```css
html { font-size: 16px; }
/* Below the board: half the proportional shrink, so 1280 reads 15.11px, not 14.22. */
@media (max-width: 1439.98px) { html { font-size: calc(16px - (1440px - 100vw) / 128.57); } }
/* The tablet range is NOT a scaled board — below 1024 everything lays out in flow. Held at 16px. */
@media (max-width: 1023.98px) { html { font-size: 16px; } }
/* The phone base is 360. */
@media (max-width: 639.98px) { html { font-size: 4.444444vw; } }
```

Above 1440 a script scales it **up**, damped by `coef = 0.6666`, on every resize:

```js
const FONT_BASE = 16, BASE_WIDTH = 1440, COEF = 0.6666;
const interpolateFontSize = (w) => {
  const widthReduction = ((BASE_WIDTH - w) / BASE_WIDTH) * 100;
  return FONT_BASE - (FONT_BASE * widthReduction * COEF) / 100;
};
const applyGrid = () => {
  const size = interpolateFontSize(window.innerWidth);
  if (size > FONT_BASE) document.documentElement.style.setProperty("font-size", `${size}px`);
  else document.documentElement.style.removeProperty("font-size");
};
```

### Breakpoints and the class vocabulary used below

The project is Tailwind v4; this document quotes its class strings verbatim because they are the
exact layout. Translate them with these rules — they are all the reader needs:

- spacing unit `1` = `0.25rem` (`px-5` = 1.25rem, `left-10` = 2.5rem, `pt-18` = 4.5rem, `w-66` = 16.5rem,
  `bottom-9.75` = 2.4375rem, `bottom-10.25` = 2.5625rem, `bottom-7.5` = 1.875rem, `mt-11` = 2.75rem);
  `size-2` = 0.5rem square; `h-px` = 1px; `inset-0` = all four edges 0; `-inset-y-[0.35em]` = top and
  bottom −0.35em.
- breakpoints: `sm` = min-width 40rem (640px), `lg` = 64rem (1024px), `xl` = 80rem (1280px), and a
  custom **`board`** = 90rem (1440px). `max-sm:` = below 640; `sm:max-lg:` = 640–1023.98;
  `lg:max-xl:` = 1024–1279.98; `xl:max-board:` = 1280–1439.98; `md` = 48rem (768px).
- `text-<token>`, `leading-<token>`, `tracking-<token>`, `font-<face>`, `bg-/text-<colour>`,
  `w-(--size-x)` all resolve to the tokens in the next section. `foreground/35` etc. is the colour
  at that alpha. `ease-entrance` = `cubic-bezier(0.2, 0, 0, 1)`; `duration-[var(--duration-fast)]`
  = 150ms transition.
- `h-lvh` = `100lvh`; `sr-only` = visually hidden; `contents` = `display: contents`.

## Fixed parameters — the whole token block, verbatim

Reproduce this `:root` as it stands. It is the palette, the type scale, every board coordinate and
every geometry number the sections below refer to by name.

```css

:root {
  --raw-color-white: #ffffff;
  --raw-color-neutral-100: #ededed;
  --raw-color-neutral-900: #171717;
  --raw-color-neutral-950: #0a0a0a;

  --raw-duration-fast: 150ms;
  --raw-duration-normal: 250ms;
  --raw-duration-slow: 300ms;

  /* Brand — Figma "Concept 7" frame 1276:566 (brewns coffee house). */
  --raw-color-espresso-950: #070707; /* hero canvas */
  --raw-color-amber-500: #d58c3d; /* ORDER ONLINE status dot — node 1312:289 */
  --raw-color-espresso-900: #171715; /* swirl on the dark sections — node 2173:598 */
  --raw-color-espresso-900-half: rgba(
    23,
    23,
    21,
    0.5
  ); /* footer swirl — 2190:1194 */
  --raw-color-roast-900: #2a1a0f; /* preloader coffee, bottom of the pour */
  --raw-color-roast-500: #b07a45; /* preloader coffee, crema at the surface */
  --raw-color-bone-50: #f1f1ef; /* menu section surface — node 2149:5 */
  --raw-color-bone-100: #e7e5e3; /* menu section swirl — node 2161:275 */

  /* Opalesce background layers. The gradient runs behind two blocks now, so
     the fallback is shared and each block brings its own readability scrim —
     they put their copy in different places, so one scrim cannot serve both.
     Tune the stops and alphas here, not in the component. The fallback runs in
     the same palette as docs/opalesce.md's CONFIG and shows until the first
     frame lands — or permanently, if WebGL2 is unavailable. */
  --raw-gradient-hero-scrim: linear-gradient(
    to right,
    rgba(7, 7, 7, 0.75) 0%,
    rgba(7, 7, 7, 0.35) 35%,
    transparent 60%
  );
  /* Philosophy puts copy in the top band and the bottom band and leaves the
     cup's middle clear, so its scrim runs vertically rather than to the right. */
  --raw-gradient-philosophy-scrim: linear-gradient(
    to bottom,
    rgba(7, 7, 7, 0.86) 0%,
    rgba(7, 7, 7, 0.6) 26%,
    rgba(7, 7, 7, 0.38) 46%,
    rgba(7, 7, 7, 0.62) 72%,
    rgba(7, 7, 7, 0.88) 100%
  );
  --raw-gradient-opalesce-fallback: linear-gradient(
    120deg,
    #070707 0%,
    #110b07 35%,
    #2c1a11 65%,
    #6e3e22 100%
  );

  /* Type scale — Figma 1276:566. Design px / 16 (the 1440 grid base),
     so each rem matches the frame 1:1 at 1440px wide. */
  --raw-font-size-label: 0.875rem; /* 14px — eyebrow */
  --raw-font-size-base: 1rem; /* 16px — nav, lede, info bar */
  --raw-font-size-script: 5.625rem; /* 90px — "moment" */
  --raw-font-size-fine: 0.75rem; /* 12px — footer fine print */
  --raw-font-size-headline: 4rem; /* 64px — philosophy headline */
  --raw-font-size-display: 6.25rem; /* 100px — headline */

  /* The board is 1440 wide and there is no phone or tablet frame, so the big
     sizes are re-cut for the 360 and 1024 grid bases rather than scaled from
     the desktop ones: 100px of display type on a 360 board is four characters
     to a line. Body sizes are unchanged — 1rem is 16px on every base, which is
     already the right reading size. */
  --raw-font-size-display-phone: 2.375rem; /* 38px on the 360 base */
  --raw-font-size-display-hero-phone: 3.25rem; /* 52px — the hero only */
  --raw-font-size-display-tablet: 4rem; /* 64px on the 1024 base */
  --raw-font-size-headline-phone: 2rem; /* 32px */
  --raw-font-size-headline-tablet: 3rem; /* 48px */
  --raw-font-size-script-phone: 3.85rem; /* 62px */
  --raw-font-size-script-tablet: 7rem; /* 112px */
  --raw-font-size-display-hero-tablet: 6rem; /* 96px — the hero only */
  /* The preloader's count. It is the one figure on that screen, so it runs a
     step above the display scale rather than borrowing it. */
  --raw-font-size-count-phone: 5rem; /* 80px */
  --raw-font-size-count-tablet: 8rem; /* 128px */
  --raw-font-size-count: 11rem; /* 176px */
  --raw-font-size-lede-tablet: 1.125rem; /* 18px — 16 sits small under the 64px display */

  --raw-leading-flush: 0.8; /* CTA label, trimmed to cap height */
  --raw-leading-headline: 0.9; /* display + script */
  --raw-leading-copy: 1.2; /* nav, lede, info bar */

  /* Figma tracking is absolute px; stored here as em so it survives scaling.
     -0.48px@16 = -3px@100 = -2.7px@90 = -0.03em. */
  --raw-tracking-tight: -0.03em;
  --raw-tracking-label: 0.1em; /* 1.4px@14 */
  --raw-tracking-slash: -0.23em; /* -3.2px@14 — the "//" glyph pair */
  --raw-tracking-slash-space: -0.115em; /* -1.6px@14 — space after "//" */

  /* Hero frame geometry — Figma 1276:566 (1440x800). Values the 4px spacing
     scale cannot express; everything on-scale uses Tailwind's own utilities. */
  --raw-size-hero-copy: 30.4375rem; /* 487px — headline column */
  --raw-size-hero-cta: 9.3125rem; /* 149px — explore-menu block */
  --raw-size-hours: 7.625rem; /* 122px */
  --raw-size-hero-address: 9.9375rem; /* 159px */
  --raw-size-hero-social: 5.3125rem; /* 85px */
  --raw-size-hero-rule: 2.375rem; /* 38px — info-bar divider */
  --raw-size-dot: 0.3125rem; /* 5px — ORDER ONLINE bullet */
  --raw-size-logo-width: 6.4931rem; /* 103.889px */
  --raw-size-logo-height: 1.375rem; /* 22px */
  --raw-size-hero-media-width: 72.0625rem; /* 1153px */
  --raw-size-hero-media-height: 61rem; /* 976px */
  --raw-size-hero-media-x: 24.0625rem; /* 385px */
  /* How far the still hangs past the frame's right edge on the board:
     385 + 1153 - 1440. Anchoring the still by this instead of by its x keeps the
     crop proportional — placed by x, a narrower window cut the cup in half
     because the frame shrank while the overhang did not. */
  --raw-size-hero-media-overhang: 6.125rem; /* 98px */
  /* A small step right on narrow desktops, on top of the overhang. Keeping the
     crop proportional puts the still further from the right edge than the board
     reads at 1024, so it gains up to 40px there and loses it linearly by 1280 —
     zero from there up, which leaves the reference frame alone. */
  --raw-size-hero-media-nudge: max(
    0px,
    min(40px, calc((1280px - 100vw) * 0.15625))
  );
  --raw-size-hero-media-y: 1.8125rem; /* 29px */
  --raw-size-swirl-width: 139.6875rem; /* 2235px */
  --raw-size-swirl-height: 46.5625rem; /* 745px */
  --raw-size-swirl-x: 8.25rem; /* 132px */
  --raw-size-swirl-y: -1.5rem; /* -24px */
  /* "moment" overlaps the headline's last line on every base, but the headline
     wraps to a different number of lines on each, so the offsets are measured
     per base rather than scaled. */
  /* Width of the copy column on the tablet base. It is a measurement, not a
     preference: the clipped headline reveal needs the line to wrap, and a wider
     column lets "COFFEE FOR YOUR" sit on one line, where the engine leaves the
     single line in its out-state and the headline never appears. */
  --raw-size-hero-copy-tablet: 42rem; /* 480px on the 1024 base */
  --raw-size-hero-script-x-phone: 9.75rem; /* 156px on the 360 base */
  --raw-size-hero-script-y-phone: 5.25rem; /* 84px */
  --raw-size-hero-script-x-tablet: 16.25rem; /* 260px — clear of YOUR at 96px */
  --raw-size-hero-script-y-tablet: 7.8125rem; /* 136px — sits the script on YOUR's baseline */
  --raw-size-hero-script-x: 17.375rem; /* 278px — "moment" offset in the grid */
  --raw-size-hero-script-y: 13.1875rem; /* 211px */
  --raw-size-hero-copy-rise: 1.75rem; /* 28px — copy sits above the mid-line */
  --raw-size-underline: 0.1125rem; /* 1.8px — explore-menu rule */
  --raw-size-cap-16: 0.6875rem; /* 11px — label row, trimmed to cap height */
  --raw-size-cta-block: 1.1875rem; /* 19px — label row + rule */
  --raw-size-cap-12: 0.5rem; /* 8px — cap height of the 12px mono label */

  /* Menu block geometry — Figma "Block 2" 2149:5 (1440x800). */
  --raw-size-menu-intro: 32.4375rem; /* 519px — heading column */
  --raw-size-menu-intro-y: 6.625rem; /* 106px */
  --raw-size-menu-lede: 10.875rem; /* 174px */
  --raw-size-menu-lede-x: 21.625rem; /* 346px */
  --raw-size-menu-lede-y: 14.125rem; /* 226px */
  --raw-size-menu-cta: 10.0625rem; /* 161px */
  --raw-size-menu-cta-x: 77.4375rem; /* 1239px */
  --raw-size-menu-cta-y: 24.625rem; /* 394px */
  --raw-size-menu-cards-y: 28.3125rem; /* 453px */
  --raw-size-menu-card-height: 19.1875rem; /* 307px */

  /* Receipt (hover state, node 2229:207). Figma reports the rotated rect's
     top-left CORNER as x/y (1239, 279.54) but its axis-aligned BOUNDING BOX as
     width/height (566.779x685.661) — not the same rectangle. Solving those
     against the asset's 832x1890 aspect gives the unrotated box, its top-left
     and the angle below; verified by differencing the two Figma boards. */
  --raw-size-menu-receipt-width: 17.41125rem; /* 278.58px */
  --raw-size-menu-receipt-height: 39.553125rem; /* 632.85px */
  --raw-size-menu-receipt-x: 86.44375rem; /* 1383.10px */
  --raw-size-menu-receipt-y: 10.0725rem; /* 161.16px */
  /* Footer geometry — Figma "Concept 6" 2173:655 (1440x385). */
  /* Height of the design board, in rem.
     The board is 1440x800 and every coordinate inside it is a rem, so a section
     that is 50rem tall is a uniform scale of the board at any width — including
     above 1440, where the root font-size is damped rather than proportional.
     Tying the section to the viewport height instead made the composition
     depend on the window's aspect ratio: at 1440x800 they agreed, and anywhere
     else the board shrank horizontally while the section did not, which is where
     the 124px of slack under the card row came from. */
  --raw-size-board-height: 50rem;
  /* The order block's frame below the 1280 board. The machine hangs from the
     headline's own top line, so the frame is however tall the printer plus the
     fed receipt come to from there, plus the block's 40px foot — which is also
     where the copy lands, level with the paper's torn edge. */
  --raw-size-order-frame: 53.375rem; /* 854px at the 1440 base */
  /* How far the product still's crop box is lifted off the bottom of the card's
     media area. It moves the BOX, not the photograph inside it — shifting the
     photograph within a fixed box just trades a trim at the bottom for one at
     the top, which is exactly what it did before this was corrected. Zero on the
     board: there the card is wide enough that a base sitting on the media
     area's bottom edge reads as the design's own crop. Between 1280 and 1439
     the card row scales with the viewport while the still scales with the root
     size, so the product is ~4% larger against a narrower card and the same
     flush base reads as a cut. 1024 divides the same row four ways in a still
     narrower frame, so it reads as a cut there too. Overridden for both. */
  --raw-size-menu-still-lift: 0.75rem;
  /* Multiplier on the still's crop box. One on the board — the frames are the
     photographs' own. Below 1280 the card is narrower than the root-scaled
     still expects, so the product is trimmed back a little to sit inside it. */
  --raw-size-menu-still-scale: 1;
  /* Ceiling on the crop window's width. Off above the phone grid, where the
     board's own frames are narrower than the card that holds them. */
  --raw-size-menu-still-max: none;
  --raw-size-footer-height: 24.0625rem; /* 385px */
  --raw-size-footer-tagline: 6.5rem; /* 104px */
  --raw-size-footer-tagline-y: 6.375rem; /* 102px */
  --raw-size-footer-nav-x: 23.9375rem; /* 383px */
  /* Distance from the nav column to the contact column. Below the desktop board
     the two sit in one grid, so on it the contact block is placed relative to
     the nav block rather than to the band. 1183 - 383. */
  --raw-size-footer-contact-offset: 50rem; /* 800px */
  /* Distance from the frame's right edge to the right column, not the column's
     x on the board. Once the type scale is damped the board is rendered wider
     than the window, so anything placed by its board x walks off the edge; the
     right column has to hang off the right instead. 1440 - 1183. */
  --raw-size-footer-right-block: 16.0625rem; /* 257px */
  /* Block 5 copy. It sits on the page's rem grid, not inside the scaled
     printer scene — the scene scales to COVER the viewport and the grid scales
     with the root font size, so anything measured in scene px drifts off the
     page's left gutter as soon as the window stops being 1440x800. */
  --raw-size-order-headline: 25.0625rem; /* 401px */
  --raw-size-order-headline-y: 7.875rem; /* 126px */
  --raw-size-order-note: 10.1875rem; /* 163px */
  --raw-size-order-aside: 20.6875rem; /* 331px */
  --raw-size-order-aside-gap: 3.125rem; /* 50px */
  --raw-size-order-cta: 10.0625rem; /* 161px */
  --raw-size-footer-col: 7.625rem; /* 122px */
  --raw-size-footer-rule-top: 12.8125rem; /* 205px */
  --raw-size-footer-row-y: 15.3125rem; /* 245px */
  --raw-size-footer-rule-bottom: 20.1875rem; /* 323px */
  --raw-size-footer-legal-y: 21.6875rem; /* 347px */
  --raw-size-footer-address: 10rem; /* 160px */
  --raw-size-footer-address-b: 37.9375rem; /* 607px */
  --raw-size-footer-address-c: 53.0625rem; /* 849px */
  --raw-size-footer-divider-a: 35.9375rem; /* 575px */
  --raw-size-footer-divider-b: 51.0625rem; /* 817px */
  --raw-size-footer-dot: 0.25rem; /* 4px */

  --raw-angle-menu-receipt: -31.2678deg;

  /* Locations block geometry — Figma "Block 3_v2" 2164:283 (1440x800). */
  --raw-size-locations-dial: 40.875rem; /* 654px — clock face */
  --raw-size-locations-dial-y: 6.625rem; /* 106px */
  --raw-size-locations-cup-width: 17.5rem; /* 280px */
  --raw-size-locations-cup-height: 26.25rem; /* 420px */
  --raw-size-locations-cup-y: 11.875rem; /* 190px */
  --raw-size-locations-list: 11.1875rem; /* 179px — address column */
  --raw-size-locations-list-y: 37.375rem; /* 598px */
  --raw-size-locations-hours-y: 45.125rem; /* 722px */

  /* Philosophy block geometry — Figma "Block 4" 2173:591 (1440x800). */
  --raw-size-philosophy-headline: 49.25rem; /* 788px */
  --raw-size-philosophy-headline-y: 6.625rem; /* 106px */
  --raw-size-philosophy-cup-x: 14.625rem; /* 234px */
  --raw-size-philosophy-cup-y: 19.5625rem; /* 313px — the metadata's y, not the
     311 get_design_context reports; the render agrees with the metadata */
  --raw-size-philosophy-cup-width: 20.125rem; /* 322px */
  --raw-size-philosophy-cup-height: 30.125rem; /* 482px */
  --raw-size-philosophy-lede: 20.6875rem; /* 331px */
  --raw-size-philosophy-lede-x: 38.3125rem; /* 613px */
  --raw-size-philosophy-lede-y: 41.5625rem; /* 665px */
  --raw-size-philosophy-claims: 10.75rem; /* 172px */
  --raw-size-philosophy-claims-y: 44.0625rem; /* 705px */
  --raw-size-philosophy-eyebrow-y: 46.4375rem; /* 743px — top-anchored, so it
     stays level with the lede's last line at any viewport height */ /* counter-clockwise; the sign is not
     recoverable from the bounding box, it comes from matching the corners */
  --raw-size-arrow-width: 0.763356rem; /* 12.2137px — arrow artboard */
  --raw-size-arrow-height: 0.828444rem; /* 13.2551px */
}


:root {
  --background: var(--raw-color-espresso-950);
  --foreground: var(--raw-color-white);

  --accent: var(--raw-color-amber-500);
  --coffee-deep: var(--raw-color-roast-900);
  --coffee-crema: var(--raw-color-roast-500);
  --surface-backdrop: var(--raw-color-espresso-900);
  --surface-backdrop-soft: var(--raw-color-espresso-900-half);

  --hero-scrim: var(--raw-gradient-hero-scrim);
  --gradient-opalesce-fallback: var(--raw-gradient-opalesce-fallback);
  --philosophy-scrim: var(--raw-gradient-philosophy-scrim);

  /* The menu block inverts the page: light surface, dark type. */
  --surface-inverse: var(--raw-color-bone-50);
  --surface-inverse-backdrop: var(--raw-color-bone-100);
  --foreground-inverse: var(--raw-color-espresso-950);

  --duration-fast: var(--raw-duration-fast);
  --duration-normal: var(--raw-duration-normal);
  --duration-slow: var(--raw-duration-slow);

  --font-size-label: var(--raw-font-size-label);
  --font-size-base: var(--raw-font-size-base);
  --font-size-script: var(--raw-font-size-script);
  --font-size-fine: var(--raw-font-size-fine);
  --font-size-headline: var(--raw-font-size-headline);
  --font-size-display-phone: var(--raw-font-size-display-phone);
  --font-size-display-hero-phone: var(--raw-font-size-display-hero-phone);
  --font-size-display-tablet: var(--raw-font-size-display-tablet);
  --font-size-headline-phone: var(--raw-font-size-headline-phone);
  --font-size-headline-tablet: var(--raw-font-size-headline-tablet);
  --font-size-script-phone: var(--raw-font-size-script-phone);
  --font-size-script-tablet: var(--raw-font-size-script-tablet);
  --font-size-display-hero-tablet: var(--raw-font-size-display-hero-tablet);
  --font-size-count-phone: var(--raw-font-size-count-phone);
  --font-size-count-tablet: var(--raw-font-size-count-tablet);
  --font-size-count: var(--raw-font-size-count);
  --font-size-lede-tablet: var(--raw-font-size-lede-tablet);
  --font-size-display: var(--raw-font-size-display);

  --line-height-flush: var(--raw-leading-flush);
  --line-height-headline: var(--raw-leading-headline);
  --line-height-copy: var(--raw-leading-copy);

  --letter-spacing-tight: var(--raw-tracking-tight);
  --letter-spacing-label: var(--raw-tracking-label);
  --letter-spacing-slash: var(--raw-tracking-slash);
  --letter-spacing-slash-space: var(--raw-tracking-slash-space);

  --size-hero-copy: var(--raw-size-hero-copy);
  --size-hero-cta: var(--raw-size-hero-cta);
  --size-hours: var(--raw-size-hours);
  --size-hero-address: var(--raw-size-hero-address);
  --size-hero-social: var(--raw-size-hero-social);
  --size-hero-rule: var(--raw-size-hero-rule);
  --size-dot: var(--raw-size-dot);
  --size-logo-width: var(--raw-size-logo-width);
  --size-logo-height: var(--raw-size-logo-height);
  --size-hero-media-width: var(--raw-size-hero-media-width);
  --size-hero-media-height: var(--raw-size-hero-media-height);
  --size-hero-media-x: var(--raw-size-hero-media-x);
  --size-hero-media-overhang: var(--raw-size-hero-media-overhang);
  --size-hero-media-nudge: var(--raw-size-hero-media-nudge);
  --size-hero-media-y: var(--raw-size-hero-media-y);
  /* The still drops with the same surplus the boards do, so on a window taller
     than the board's proportion it sits where 1440 puts it rather than riding up
     under the header. Zero at 1440, so the reference frame is untouched. */
  --size-hero-media-drop: calc(
    var(--size-hero-media-y) + var(--size-board-drop)
  );
  --size-swirl-width: var(--raw-size-swirl-width);
  --size-swirl-height: var(--raw-size-swirl-height);
  --size-swirl-x: var(--raw-size-swirl-x);
  --size-swirl-y: var(--raw-size-swirl-y);
  --size-hero-copy-tablet: var(--raw-size-hero-copy-tablet);
  --size-hero-script-x-phone: var(--raw-size-hero-script-x-phone);
  --size-hero-script-y-phone: var(--raw-size-hero-script-y-phone);
  --size-hero-script-x-tablet: var(--raw-size-hero-script-x-tablet);
  --size-hero-script-y-tablet: var(--raw-size-hero-script-y-tablet);
  --size-hero-script-x: var(--raw-size-hero-script-x);
  --size-hero-script-y: var(--raw-size-hero-script-y);
  --size-hero-copy-rise: var(--raw-size-hero-copy-rise);
  --size-underline: var(--raw-size-underline);
  --size-cap-16: var(--raw-size-cap-16);
  --size-cta-block: var(--raw-size-cta-block);
  --size-cap-12: var(--raw-size-cap-12);
  --size-menu-intro: var(--raw-size-menu-intro);
  --size-menu-intro-y: var(--raw-size-menu-intro-y);
  --size-menu-lede: var(--raw-size-menu-lede);
  --size-menu-lede-x: var(--raw-size-menu-lede-x);
  --size-menu-lede-y: var(--raw-size-menu-lede-y);
  --size-menu-cta: var(--raw-size-menu-cta);
  --size-menu-cta-x: var(--raw-size-menu-cta-x);
  --size-menu-cta-y: var(--raw-size-menu-cta-y);
  --size-menu-cards-y: var(--raw-size-menu-cards-y);
  --size-menu-card-height: var(--raw-size-menu-card-height);
  --size-board-height: var(--raw-size-board-height);
  /* How far the board drops inside a full-height section.
     The section is the window's height; the board is 50rem. Whatever is left
     over is surplus, and dropping the composition by it puts the bottom gutter
     back on the board's own 40px while the top gains the slack instead — under a
     fixed header that reads as air, at the bottom it read as a hole. Capped so a
     very tall window does not push the whole block off the top of the eye line.
     At 1440x800 the surplus is zero, so the reference frame is untouched. */
  --size-board-drop: max(
    0px,
    min(calc(100lvh - var(--size-board-height)), 6.5rem)
  );
  --size-footer-height: var(--raw-size-footer-height);
  --size-footer-tagline: var(--raw-size-footer-tagline);
  --size-footer-tagline-y: var(--raw-size-footer-tagline-y);
  --size-footer-nav-x: var(--raw-size-footer-nav-x);
  --size-footer-contact-offset: var(--raw-size-footer-contact-offset);
  --size-footer-right-block: var(--raw-size-footer-right-block);
  --size-footer-contact-x: calc(100% - var(--size-footer-right-block));
  --size-menu-still-lift: var(--raw-size-menu-still-lift);
  --size-menu-still-scale: var(--raw-size-menu-still-scale);
  --size-menu-still-max: var(--raw-size-menu-still-max);
  --size-order-frame: var(--raw-size-order-frame);
  --size-order-headline: var(--raw-size-order-headline);
  --size-order-headline-y: var(--raw-size-order-headline-y);
  --size-order-note: var(--raw-size-order-note);
  --size-order-aside: var(--raw-size-order-aside);
  --size-order-aside-gap: var(--raw-size-order-aside-gap);
  --size-order-cta: var(--raw-size-order-cta);
  --size-footer-col: var(--raw-size-footer-col);
  --size-footer-rule-top: var(--raw-size-footer-rule-top);
  --size-footer-row-y: var(--raw-size-footer-row-y);
  --size-footer-rule-bottom: var(--raw-size-footer-rule-bottom);
  --size-footer-legal-y: var(--raw-size-footer-legal-y);
  --size-footer-address: var(--raw-size-footer-address);
  --size-footer-address-b: var(--raw-size-footer-address-b);
  --size-footer-address-c: var(--raw-size-footer-address-c);
  --size-footer-divider-a: var(--raw-size-footer-divider-a);
  --size-footer-divider-b: var(--raw-size-footer-divider-b);
  --size-footer-dot: var(--raw-size-footer-dot);
  --size-menu-receipt-width: var(--raw-size-menu-receipt-width);
  --size-menu-receipt-height: var(--raw-size-menu-receipt-height);
  --size-menu-receipt-x: var(--raw-size-menu-receipt-x);
  --size-menu-receipt-y: var(--raw-size-menu-receipt-y);
  --angle-menu-receipt: var(--raw-angle-menu-receipt);
  --size-locations-dial: var(--raw-size-locations-dial);
  --size-locations-dial-y: var(--raw-size-locations-dial-y);
  --size-locations-cup-width: var(--raw-size-locations-cup-width);
  --size-locations-cup-height: var(--raw-size-locations-cup-height);
  --size-locations-cup-y: var(--raw-size-locations-cup-y);
  --size-locations-list: var(--raw-size-locations-list);
  --size-locations-list-y: var(--raw-size-locations-list-y);
  --size-locations-hours-y: var(--raw-size-locations-hours-y);
  --size-philosophy-headline: var(--raw-size-philosophy-headline);
  --size-philosophy-headline-y: var(--raw-size-philosophy-headline-y);
  --size-philosophy-cup-x: var(--raw-size-philosophy-cup-x);
  --size-philosophy-cup-y: var(--raw-size-philosophy-cup-y);
  --size-philosophy-cup-width: var(--raw-size-philosophy-cup-width);
  --size-philosophy-cup-height: var(--raw-size-philosophy-cup-height);
  --size-philosophy-lede: var(--raw-size-philosophy-lede);
  --size-philosophy-lede-x: var(--raw-size-philosophy-lede-x);
  --size-philosophy-lede-y: var(--raw-size-philosophy-lede-y);
  --size-philosophy-claims: var(--raw-size-philosophy-claims);
  --size-philosophy-claims-y: var(--raw-size-philosophy-claims-y);
  --size-philosophy-eyebrow-y: var(--raw-size-philosophy-eyebrow-y);
  --size-arrow-width: var(--raw-size-arrow-width);
  --size-arrow-height: var(--raw-size-arrow-height);
}
```

Two bindings the sections use by name that are not in that block: `--ease-entrance:
cubic-bezier(0.2, 0, 0, 1)`, `--leading-display: 1.1` (the floor for every clipped text reveal), and
`--breakpoint-board: 90rem`. `body { background: var(--background); color: var(--foreground);
min-height: 100lvh; width: 100%; overflow-x: hidden }`.

## The motion engine you write

Every moving thing on this page is a **spring** — `@react-spring/web`'s integrator, which you
re-implement once. There are no CSS keyframes anywhere; CSS `transition` is used only for hover
colour/opacity changes at 150–250ms with `cubic-bezier(0.2, 0, 0, 1)`.

### The spring solver

Per animated value, per frame, react-spring's own stepping: a 1ms fixed sub-step over the elapsed
time (capped at 64ms per frame), `v += ((-tension * 1e-6) * (x - to) - (friction * 1e-3) * v) * 1`,
`x += v`, with `mass` 1; rest when `|v| < 0.01 * 1e-3`-scale and `|x - to| < 0.01` (react-spring's
`precision` is derived from the range; 0.01 of a unit is the working figure for everything here).
`clamp: true` stops at the target instead of overshooting. Duration mode (`{ duration, easing }`)
replaces the integrator with `x = from + (to - from) * easing(t / duration)`. Easings used:
`easeOutCubic` = `1 - (1 - t)³`, `easeOutQuart` = `1 - (1 - t)⁴`, `easeInOutSine` =
`-(cos(πt) - 1) / 2`. Strings with units (`"10px"`, `"inset(0 100% 0 0)"`, `"translateY(-30%)"`)
interpolate every number inside them and keep the surrounding text. A `delay` postpones the start.
`loop: { reverse: true }` plays there and back for ever.

Named configs used on this page, `{ tension, friction }`: 90/26 (the page's standard reveal),
80/26, 60/26, 58/26, 46/26, 45/24, 42/26, 40/26, 36/26, 120/26, 90/18, 120/18 (see the preloader),
170/26, 200/28, 210/26, 220/28, 260/30, 55/22, 90/22, 70/34, 38/26.

### The shared ticker

One `requestAnimationFrame` loop for the whole page. Subscribers register a callback and a minimum
gap in ms (`0` = every frame, `100` = the header's theme probe, `10` = the scroll scrubs); a
subscriber is skipped while `time - last <= gap`. Lenis's `raf` runs first every frame, then every
reader of `getBoundingClientRect`. The loop starts on the first subscriber and stops on the last.

### `Inview` — the reveal primitive

An element with a `from` and a `to` style and an `IntersectionObserver` on itself (or on a
`trigger` element). `mode="once"`: the first time it intersects it springs `from → to` after
`delayIn` ms and stays there. An `enabled` flag holds it at `from` until it flips true — everything
above the fold takes `enabled = pageReady` so it does not play behind the preloader. `rootMargin`
where given (the headlines use `"0px 0px -25% 0px"`: they fire when 25% of the viewport's height is
still below them). `innerTag`/`innerClassName`: the outer element holds a clip and the *inner* one
carries the travel (the footer's rising lines). Values animated: `opacity`, `y` (→ `translateY(px)`),
`scale`, `clipPath`, `transform`. With `immediateOut`, leaving `to` snaps back with no animation.

### `Hover` — the hover primitive

Springs `from → to` on `mouseenter` of a `trigger` element (or itself) and back on `mouseleave`,
with `delayIn` and `immediateOut`. **Off entirely below 768px wide** — there is no hover there.

### `SpringTrigger mode="scrub"` — the scroll scrub

Progress 0→1 of a `trigger` element's box between two positions, read from
`getBoundingClientRect` on the ticker (gap 10ms), written straight into `from → to` through
`interpolate` (below), by default with `config: { duration: 1 }` (i.e. bound to the scroll), or
through a physical spring where a `config` is given (the swirl fill's parallax: bound). The
position vocabulary, verbatim from the project's hook:

```js
const bb = el.getBoundingClientRect(), clientHeight = window.innerHeight;
const poses = {
  top_top: bb.top,
  center_top: bb.top + bb.height / 2,
  bottom_top: bb.bottom,
  top_bottom: bb.top - clientHeight,
  center_bottom: bb.top + bb.height / 2 - clientHeight,
  bottom_bottom: bb.bottom - clientHeight,
  top_center: bb.top - clientHeight / 2,
  center_center: bb.top + bb.height / 2 - clientHeight / 2,
  bottom_center: bb.bottom - clientHeight / 2,
};
const scrollStart = poses[start.replace(" ", "_")], scrollEnd = poses[end.replace(" ", "_")];
const length = Math.abs(scrollStart - scrollEnd);
const progress = Math.min(Math.max(0, 1 - (scrollStart + length) / length), 1);
```

`start="top bottom"` means "the trigger's top at the viewport's bottom". The scrub only runs while
the trigger is on screen (an `IntersectionObserver` gates the loop) and only writes when the
quantised progress actually changed.

`interpolate`, verbatim — it lerps every number inside a unit string or a single transform function:

```js

export const interpolate = (
  start: SpringValues,
  end: SpringValues,
  progress: number,
): SpringValues => {
  const result: SpringValues = {};

  // Helper to extract number from string with units
  const extractNumber = (
    value: unknown,
  ): { number: number; unit: string | null } => {
    if (typeof value === "number") return { number: value, unit: null };
    if (typeof value === "string") {
      // Handle CSS transform functions like translate(10px) or rotate(45deg)
      const functionMatch = value.match(/^([a-zA-Z]+)(([-0-9.]+)([^)]*))$/);
      if (functionMatch) {
        return {
          number: parseFloat(functionMatch[2]),
          unit: `${functionMatch[1]}(${functionMatch[3]})`,
        };
      }
      // Handle regular values with units like 45deg or 100px
      const match = value.match(/([-0-9.]+)([^0-9.]+)/);
      if (match) {
        return {
          number: parseFloat(match[1]),
          unit: match[2],
        };
      }
    }
    return { number: 0, unit: null };
  };

  // Interpolate each property in the objects
  for (const key in start) {
    const startVal = extractNumber(start[key]);
    const endVal = extractNumber(end[key]);

    if (startVal.unit !== null || endVal.unit !== null) {
      const unit = startVal.unit || endVal.unit;
      if (unit?.includes("(")) {
        // Check if it's any CSS transform function
        result[key] =
          `${unit.split("(")[0]}(${lerp(startVal.number, endVal.number, progress)}${unit.split(")")[0].slice(unit.split("(")[0].length)})`;
      } else {
        result[key] =
          `${lerp(startVal.number, endVal.number, progress)}${unit}`;
      }
    } else {
      result[key] = lerp(startVal.number, endVal.number, progress);
    }
  }

  return result;
};
```

### `TextEngine` — the text reveals

Headlines and ledes are split into **lines** or **words**, each wrapped in a `span` (the words with
`columnGap` 0.2em between them), and revealed with a stagger. Two recipes only:

- **Lines** (every `h1`/`h2`): each line springs from `{ y: "110%", opacity: 0 }` to
  `{ y: "0%", opacity: 1 }` on a **duration** of 900ms, `easeOutCubic`, `lineStagger` 90ms between
  lines; `overflow` clips each line to its line box, so the line-height floor is 1.1
  (`leading-display`) wherever a clip is used — the hero `h1` wraps each line in `-my-[0.1em]` to
  close the gap the floor opens. The menu, philosophy and order headings fire at `rootMargin
  "0px 0px -25% 0px"`; the hero's fires when the page is ready.
- **Words** (every lede and the order aside): each word from `{ y: 16, opacity: 0 }` (the hero) or
  `{ y: 14, opacity: 0 }` (everything else) to `{ y: 0, opacity: 1 }`, duration 650ms /
  `easeOutQuart` and `wordStagger` 18ms in the hero; 620ms / 14ms elsewhere; `delayIn` as given per
  block.

The container is `display: flex; flex-wrap: wrap` with `justify-content: flex-start`; the copy is
duplicated once visually-hidden for assistive technology, and every visible unit is `aria-hidden`.

### `DigitRoll` — numbers that roll to their value

For any string with digits (hours, prices, card indices): a transparent copy of the string holds
the box and baseline; over it, out of flow (`absolute inset-x-0 -inset-y-[0.35em] flex`), one cell
per character — non-digits printed plainly (`flex-none self-center whitespace-pre`), each digit a
column `[clip-path:inset(0)]` holding a stack ten cells tall (`h-[1000%]`, ten `flex h-[10%]
items-center justify-center` cells "0"…"9") that springs `translateY(0%) → translateY(-N0%)` for
digit N, config 90/26, `delayIn + order * 70` where `order` counts digits only. Both call sites are
`font-mono`, so every cell is the width of the glyph it covers.

### `RuleDraw` — a hairline that draws itself

One `span`, `Inview mode="once"`, `scaleX(0) → scaleX(1)` from `left center` (axis x) or
`scaleY(0) → scaleY(1)` from `center top` (axis y), config 55/22, `delayIn` as given.

### `UnderlineLink` — nav links that roll over

`<a>` → `span.relative.inline-block` → `span.relative.block.[clip-path:inset(0)]` holding the label
twice: the real one `Hover` `translateY(0%) → translateY(-100%)`, and an `aria-hidden` duplicate
`absolute inset-0` `translateY(100%) → translateY(0%)`, both config 260/30 on the link as trigger.
Where `rule` is on (everywhere but the header nav), a hairline `absolute inset-x-0 -bottom-px h-px
bg-current` springs `scaleX(0), opacity 0 → scaleX(1), opacity 1` from `left center`, config 220/28.

### `CtaLink` — the text CTA (explore menu · view full menu · order right now)

```html
<a class="flex h-(--size-cta-block) flex-col justify-between max-sm:w-max {width class}">
  <span class="flex h-(--size-cap-16) items-center gap-4">
    <span class="whitespace-nowrap font-display font-bold text-label leading-flush uppercase sm:text-base sm:max-lg:text-lede-tablet">{label}</span>
    <span class="relative flex size-2 flex-none items-center justify-center">
      <!-- arrow A: Hover translateX(0%),opacity 1 → translateX(190%),opacity 0 · 200/28 -->
      <!-- arrow B: Hover translateX(-190%),opacity 0 → translateX(0%),opacity 1 · 200/28 · delayIn 70 -->
    </span>
  </span>
  <span aria-hidden="true" class="relative block h-(--size-underline) w-full">
    <!-- rule base:    Hover immediateOut scaleX(1) → scaleX(0) · 260/30 · transform-origin right center -->
    <!-- rule overlay: Hover immediateOut delayIn 140 scaleX(0) → scaleX(1) · 170/26 · transform-origin left center -->
  </span>
</a>
```

Both rules are `absolute inset-0 block` painted `bg-foreground` (or `bg-foreground-inverse` on the
light board). The arrow glyph, verbatim, used by every CTA and inheriting `currentColor`:

```html
<span class="flex size-2 items-center justify-center"><span class="flex -rotate-45 items-center justify-center">
<svg viewBox="0 0 12.2137 13.2551" fill="none" aria-hidden="true" focusable="false" class="block w-(--size-arrow-width) h-(--size-arrow-height)">
<path d="M11.9501 7.26396C12.3016 6.91249 12.3016 6.34264 11.9501 5.99117L6.22254 0.263604C5.87107 -0.0878682 5.30122 -0.0878682 4.94975 0.263604C4.59828 0.615076 4.59828 1.18492 4.94975 1.5364L10.0409 6.62756L4.94975 11.7187C4.59828 12.0702 4.59828 12.6401 4.94975 12.9915C5.30122 13.343 5.87107 13.343 6.22254 12.9915L11.9501 7.26396ZM0 6.62756V7.52756H11.3137V6.62756V5.72756H0V6.62756Z" fill="currentColor"/>
</svg></span></span>
```

### `PointerLean` — leaning toward the pointer

A `div` with `perspective: 900px` around an inner layer with `transform-style: preserve-3d`. On
`pointermove` over the scope (the card itself, `scope="self"`, for the menu cards) the inner layer
springs to `rotateY(dx * 14deg) rotateX(-dy * 14deg)` where `dx`/`dy` are the pointer's position
in the scope's box mapped to −1…1 and clamped, config 90/22; on `pointerleave` back to 0. Off below
768px and under `prefers-reduced-motion`.

### `PulseDot` — the amber status dot

A `span` looping `opacity 1 → 0.15 → 1`, duration 700ms per half, `easeInOutSine`, `loop: {
reverse: true }`. Still at 1 under reduced motion. `size-(--size-dot) rounded-full bg-accent`
(5px) in the header and the locations eyebrow; `size-(--size-footer-dot)` (4px) in the footer.

### The page-ready store

Two booleans the whole page reads: `revealing` (true the moment the preloader starts to pour away)
and `ready` (true once it has gone). Everything above the fold — the header, the hero copy, the info
bar, the Opalesce fade-in, the swirl fills — holds its `from` state until `ready`; the hero product's
entrance starts on `revealing`. A page under `prefers-reduced-motion` sets both true at once and
never renders the preloader.

## The header — one fixed row for the whole page

```html
<header class="fixed inset-x-5 top-4 z-50 flex h-(--size-logo-height) items-center justify-between font-mono text-fine leading-copy tracking-tight transition-colors duration-[var(--duration-normal)] ease-entrance sm:inset-x-10 sm:top-6 sm:max-lg:text-base lg:text-base">
  <button aria-expanded="false" aria-controls="site-menu" aria-label="Open menu" class="relative -m-2 flex size-9 items-center justify-center p-2 max-lg:order-last sm:max-lg:size-11 lg:hidden">
    <!-- two lines that become a cross: span.absolute.right-2.h-px.bg-current  -->
    <!--   closed: line 1 "w-6 -translate-y-1", line 2 "w-4 translate-y-1" (sm:max-lg: h-0.5, w-8 / w-6, ±1.5) -->
    <!--   open:   line 1 "w-6 rotate-45",      line 2 "w-6 -rotate-45"  — transition-all 250ms ease-entrance -->
  </button>
  <nav aria-label="Primary" class="hidden lg:block">
    <!-- PrintIn: Inview enabled=ready clipPath inset(0 100% 0 0) → inset(0 0 0 0) · 46/26 · delayIn 220 -->
    <ul class="flex items-center gap-6 uppercase lg:gap-12">
      <li><a>Shop</a></li> <li><a>Menu</a></li> <li><a>Our Story</a></li> <li><a>Locations</a></li>   <!-- UnderlineLink, rule=false -->
    </ul>
  </nav>
  <!-- wordmark: Inview enabled=ready clipPath inset(0 100% 0 0) → inset(0 0 0 0) · 42/26 · delayIn 260 -->
  <span class="absolute left-1/2 h-full w-(--size-logo-width) -translate-x-1/2 max-sm:static max-sm:order-first max-sm:translate-x-0">
    <a href="/" aria-label="brewns" class="block size-full transition-colors duration-[var(--duration-normal)] ease-entrance [mask-position:center] [mask-repeat:no-repeat] [mask-size:100%_100%]"
       style="mask-image:url(https://storage.getlayers.ai/assets/brewns-b9f02b6657/shared/wordmark-mask.webp)"></a>
  </span>
  <a href="#order" class="flex items-center gap-1.5 whitespace-nowrap transition-opacity duration-[var(--duration-fast)] ease-entrance hover:opacity-60 max-sm:hidden max-lg:order-first">
    <!-- PrintIn delayIn 480 --> ORDER ONLINE
    <!-- PulseDot class="size-(--size-dot) shrink-0 rounded-full bg-accent max-lg:order-first sm:max-lg:size-2" -->
  </a>
</header>
```

The bar itself is `Inview enabled=ready` `{ opacity: 0, y: -12 } → { opacity: 1, y: 0 }`, 90/26,
`delayIn` 120. Nav links: `Shop` `#shop`, `Menu` `#menu`, `Our Story` `#story`, `Locations`
`#locations`; the order link `#order`.

**The wordmark is an alpha mask, not a picture.** `wordmark-mask.webp` (its own pixels are
near-black) is applied as `mask-image` on a box painted with the current foreground; the same file
is painted white in the preloader and the footer, and black over the light blocks. Never render it
as an `<img>`.

**Recolouring.** Every section carries `data-header-theme="dark"` or `"light"`. On the ticker,
every 100ms, find the section whose box spans `y = 35px` (24px top + half the 22px row) and set the
header's colour class: `light` → text `#070707` and the mark painted `#070707`; `dark` → white.
Separately, once the `<footer>`'s top is at or above 35px the whole bar is `invisible` and
`pointer-events: none` **below `sm` only** (the phone footer has its own controls).

**The phone panel** (`lg:hidden`), a sibling of the bar, `hidden` unless open:

```html
<div id="site-menu" class="fixed inset-0 z-40 flex flex-col bg-background px-5 pb-10 pt-24 text-foreground sm:px-10 sm:pb-14 sm:pt-32 lg:hidden">
  <p class="font-mono text-fine leading-copy tracking-label uppercase text-foreground/45 sm:text-label"><span class="tracking-slash">//</span><span class="tracking-slash-space"> </span>NAVIGATION</p>
  <nav aria-label="Primary" class="mt-7 flex flex-col border-t border-foreground/15 sm:mt-9">
    <!-- per link: a.group.flex.items-baseline.gap-4.border-b.border-foreground/15.py-3.5.font-display.font-bold.text-headline-phone.leading-headline.tracking-tight.uppercase.transition-opacity.duration-[150ms].ease-entrance.active:opacity-60.sm:gap-6.sm:py-5.sm:text-headline-tablet
         with a leading <span class="font-mono text-fine leading-flush tracking-label text-foreground/35 sm:text-label">01</span> … 04 -->
  </nav>
  <a href="#order" class="mt-auto flex items-center gap-1.5 pt-10 font-mono text-fine leading-copy tracking-tight uppercase transition-opacity duration-[150ms] ease-entrance active:opacity-60 sm:text-base">ORDER ONLINE <!-- PulseDot --></a>
</div>
```

While open the bar's text is forced white and the mark white, Lenis is stopped, and `Escape` closes
it and returns focus to the toggle.

## The preloader — a cup filling

Not rendered at all under `prefers-reduced-motion`. Otherwise a fixed panel over everything:

```html
<div aria-hidden="true" class="fixed inset-0 z-100 overflow-hidden bg-background px-5 py-5 text-foreground sm:px-10 sm:py-10" style="clip-path: inset(0% 0 0 0)">
  <!-- GpuWarmup — see below -->
  <p class="font-mono text-fine leading-copy tracking-label uppercase text-foreground/45 sm:text-label"><span class="tracking-slash">//</span><span class="tracking-slash-space"> </span>NOW BREWING</p>
  <p class="mt-2 font-display font-bold text-count-phone leading-display tracking-tight tabular-nums sm:mt-3 sm:text-count-tablet lg:text-count">000</p>
  <span role="img" aria-label="brewns" class="absolute bottom-5 left-5 block h-(--size-logo-height) w-(--size-logo-width) bg-foreground max-sm:h-[1.1rem] max-sm:w-[5.1945rem] [mask-position:center] [mask-repeat:no-repeat] [mask-size:100%_100%] sm:bottom-10 sm:left-10" style="mask-image:url(…/shared/wordmark-mask.webp)"></span>
  <svg viewBox="0 0 120 160" class="pointer-events-none absolute bottom-5 right-5 h-[34vh] max-h-120 w-auto sm:bottom-10 sm:right-10 sm:h-[52vh]" fill="none" aria-hidden="true">
    <defs>
      <clipPath id="preloader-cup"><path d="M20 26 L100 26 L88 150 Q88 158 80 158 L40 158 Q32 158 32 150 Z"/></clipPath>
      <linearGradient id="preloader-coffee" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0" stop-color="#b07a45"/><stop offset="1" stop-color="#2a1a0f"/>
      </linearGradient>
    </defs>
    <g clip-path="url(#preloader-cup)"><rect x="0" width="120" height="220" fill="url(#preloader-coffee)" y="{surfaceY}"/></g>
    <path d="M20 26 L100 26 L88 150 Q88 158 80 158 L40 158 Q32 158 32 150 Z" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/>
    <path d="M14 26 H106" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
  </svg>
</div>
```

**The level is the load.** Two springs: `level` (config 120/26) and `pour` (config 90/18).
`STEP = { mount: 0.1, fonts: 0.6, loaded: 1 }`, `MIN_VISIBLE = 620`, `MAX_WAIT = 4000`, `HOLD = 180`.

1. On mount: stop Lenis, `level → 0.1`.
2. `document.fonts.ready` → `level → 0.6` (unless already finishing).
3. `window` `load` (or `readyState === "complete"`, or `MAX_WAIT` elapsed, whichever first):
   `level → 1`; on its rest, wait `max(0, 620 - elapsed) + 180` ms, then set `revealing = true` and
   `pour → 1`; on **its** rest remove the panel, set `ready = true`, start Lenis. A belt-and-braces
   timer sets `ready` at `MAX_WAIT + 2000` regardless.
4. Every frame: the count is `String(Math.round(level * 100)).padStart(3, "0")`; the coffee's
   surface is `y = 156 - level * (1 - pour) * (156 - 30)` in the SVG's units; the panel's
   `clip-path` is `inset(${pour * 100}% 0 0 0)` — the screen is cut from the top down at the pour's
   own rate, so the whole panel leaves as one body of liquid.

**GpuWarmup** sits under the panel's type (`absolute inset-0 -z-10 overflow-hidden`,
`pointer-events: none`) and draws, once, invisible samples of every kind of drawing the page does
below the fold so the browser compiles those GPU programs while the panel hides the page rather
than on the first scroll. Everything in it is painted `#070707` on `#070707` or transparent-ish, so
nothing shows. Its contents, literally:

- three compositor layers, each twice (panel-sized and a top third): an opaque
  `bg-background` + the philosophy scrim gradient with `will-change: transform`; a translucent one
  `opacity-50 will-change-[opacity]` (the second at the bottom third); a translucent one scaled
  `scale-95 will-change-transform`; plus a translucent one `rotate-1` inside `overflow-hidden
  rounded-full`.
- display type: `flex font-display text-display leading-display text-background
  will-change-transform` at bottom-left holding four `g`s: plain, `overflow-hidden rounded-full`,
  `scale-95`, and both.
- a single-sampled raster row at top-left (`flex gap-1 p-1 will-change-transform`): four 8px specks
  wearing the swirl's blob mask (below) with `--fill` 50 and 95, each plain and `scale-95`; one
  speck with the philosophy scrim; one `overflow-hidden rounded-full` speck holding a `scale-95
  bg-background`.
- a multisampled row at top-right: an 8px even-odd SVG ring
  (`M0 4a4 4 0 1 0 8 0a4 4 0 1 0 -8 0M2 4a2 2 0 1 0 4 0a2 2 0 1 0 -4 0`, `fill-rule: evenodd`,
  fill `#070707`) and an 8px `overflow-hidden rounded-full font-mono text-fine leading-flush
  text-background` `g`.

## 1 — The hero

```html
<section aria-label="Introduction" data-header-theme="dark" class="relative h-lvh w-full overflow-hidden bg-background">
  <!-- Opalesce canvas, z-0 -->
  <div aria-hidden="true" class="pointer-events-none absolute inset-0 z-1" style="background-image: var(--hero-scrim)"></div>
  <div class="absolute inset-0 z-10 flex flex-col px-5 pb-5 pt-18 max-sm:pt-24 sm:px-10 sm:pb-10 sm:pt-32 sm:max-lg:pt-24 lg:block lg:p-0">
    <!-- HeroMedia (order-2) · HeroCopy (order-1) · HeroInfoBar (order-3) -->
  </div>
</section>
```

Three stacked layers: the Opalesce canvas at z-0 (its own block, below), the readability scrim at
z-1 (`--hero-scrim`: 75% black at the left fading out by 60%), the content above both. The
section's own background is the shader's `bgColor`, so the canvas edge leaves no seam. Below `lg`
the content is a **column** (copy, then the product, then the info bar) that distributes whatever
height the device has; at `lg` and above every piece is placed absolutely on the board.

### The copy column (left)

```html
<div class="order-1 flex w-full shrink-0 flex-col gap-4 text-foreground sm:max-lg:gap-[0.6875rem] sm:my-auto sm:max-lg:mb-auto sm:max-lg:mt-0 sm:w-(--size-hero-copy-tablet) sm:gap-5 lg:absolute lg:my-0 lg:left-10 lg:top-[calc(50%-var(--size-hero-copy-rise))] lg:w-(--size-hero-copy) lg:-translate-y-1/2 lg:gap-6">
  <div class="grid justify-items-start">
    <div class="col-start-1 row-start-1 flex flex-col gap-3 uppercase max-sm:gap-5 sm:gap-4 lg:gap-5">
      <p class="font-mono text-fine leading-copy tracking-label sm:text-label"><span class="tracking-slash">//</span><span class="tracking-slash-space"> </span>Specialty Coffee House</p>
      <h1 class="justify-start text-left font-display font-bold text-display-phone leading-display tracking-tight max-sm:text-display-hero-phone sm:text-display-tablet sm:max-lg:text-display-hero-tablet lg:text-display">COFFEE FOR YOUR</h1>
    </div>
    <p class="col-start-1 row-start-1 ml-(--size-hero-script-x-phone) mt-(--size-hero-script-y-phone) w-max whitespace-nowrap font-script text-script-phone leading-headline tracking-tight sm:ml-(--size-hero-script-x-tablet) sm:mt-(--size-hero-script-y-tablet) sm:text-script-tablet lg:ml-(--size-hero-script-x) lg:mt-(--size-hero-script-y) lg:text-script">moment</p>
  </div>
  <div class="flex w-full flex-col gap-6 max-sm:w-[17rem] max-sm:gap-9 sm:w-80 sm:gap-8 sm:max-lg:gap-[2.375rem] lg:w-66 lg:gap-10">
    <p class="w-full justify-start text-left font-display font-bold text-base leading-copy tracking-tight sm:max-lg:text-lede-tablet">CAREFULLY SOURCED BEANS AND THOUGHTFULLY BREWED TO BRING OUT THE BEST IN EVERY CUP.</p>
    <span class="block"><!-- CtaLink "explore menu" href="#menu" class="w-(--size-hero-cta)" --></span>
  </div>
</div>
```

The script word shares one grid cell with the headline and is pushed into place by its own offsets
(re-cut per grid base — see the tokens). All of it is gated on `ready`:

| piece | reveal |
|---|---|
| eyebrow | `Inview` `{opacity 0, y 10} → {1, 0}` · 90/26 |
| h1 | `TextEngine` lines · 900ms easeOutCubic · stagger 90 · `overflow` · each line wrapped in `-my-[0.1em]` |
| "moment" | `Inview` `clipPath: inset(-12% 100% -22% -4%) → inset(-12% -8% -22% -4%)` · 36/26 · delayIn 420 — one clip sweeps left to right and the joined-up script writes itself; the insets are negative on three sides so the swashes are never trimmed |
| lede | `TextEngine` words · `{y 16, opacity 0}` · 650ms easeOutQuart · stagger 18 · delayIn 260 |
| CTA | `Inview` `{opacity 0, y 12} → {1, 0}` · 90/26 · delayIn 520 |

### The info bar (bottom)

```html
<aside aria-label="Visit us" class="order-3 relative z-10 flex shrink-0 items-center gap-4 pt-4 max-sm:mt-auto font-mono text-label leading-copy tracking-tight uppercase text-foreground sm:gap-6 sm:pt-6 sm:text-base lg:absolute lg:pt-0 lg:bottom-10 lg:left-10 lg:mt-0 lg:gap-8">
  <span aria-hidden="true" class="pointer-events-none absolute -inset-x-10 -bottom-10 -top-6 -z-10 hidden bg-linear-to-t from-background/95 via-background/78 to-transparent sm:max-lg:block"></span>
  <p class="w-max lg:w-(--size-hours)"><span class="block">OPEN DAILY</span><span class="block">07:00 - 21:00</span></p>   <!-- Inview {opacity 0,y 16}→ · 90/26 · delayIn 620; each line a DigitRoll delayIn 760 -->
  <span class="block h-(--size-hero-rule) w-px shrink-0 bg-foreground/35"></span>                                    <!-- RuleDraw y · delayIn 700 -->
  <address class="w-max not-italic lg:w-(--size-hero-address)"><span class="block">139 COFFEE STREET</span><span class="block">SAN FRANCISCO, CA</span></address>  <!-- Inview · delayIn 760 -->
  <span class="hidden h-(--size-hero-rule) w-px shrink-0 bg-foreground/35 sm:block"></span>                          <!-- RuleDraw y · delayIn 840 -->
  <ul class="hidden w-max flex-col sm:flex lg:w-(--size-hero-social)"><li>INSTAGRAM</li><li>TIKTOK</li></ul>          <!-- Inview · delayIn 900; UnderlineLinks to https://instagram.com / https://tiktok.com -->
</aside>
```

The bar assembles left to right, each group in turn with the rules drawing between them. The
handles drop below `sm`.

### The product frame and its card

```html
<figure class="pointer-events-none relative order-2 -mx-5 min-h-0 w-auto flex-1 max-sm:absolute max-sm:inset-x-0 max-sm:-mx-20 max-sm:bottom-0 max-sm:top-[40%] max-sm:-mb-5 max-sm:flex-none sm:-mx-10 sm:max-lg:absolute sm:max-md:inset-x-0 sm:max-md:-mx-32 sm:max-md:top-[42%] sm:max-md:-mb-28 sm:max-md:w-auto md:max-lg:mx-0 md:max-lg:left-[-5rem] md:max-lg:right-auto md:max-lg:w-[max(64rem,calc(100%+16rem))] md:max-lg:top-[35.5%] md:max-lg:-mb-21 sm:max-lg:bottom-0 sm:max-lg:flex-none lg:absolute lg:left-[calc(100%+var(--size-hero-media-overhang)+var(--size-hero-media-nudge)-var(--size-hero-media-width))] lg:max-board:left-[calc(100%-2.5rem-0.847*var(--size-hero-media-width))] board:left-(--size-hero-media-x) lg:top-(--size-hero-media-drop) lg:mx-0 lg:h-(--size-hero-media-height) lg:min-h-full lg:w-(--size-hero-media-width) lg:flex-none">
  <div role="img" aria-label="A brewns coffee house bag of slow roast beans beside a branded takeaway cup" class="relative size-full"><!-- the three.js canvas is appended here --></div>
  <span aria-hidden="true" class="absolute hidden lg:block lg:pointer-events-auto left-[27%] right-[15%] top-[5%] bottom-[23%] cursor-grab touch-none active:cursor-grabbing"></span>
</figure>
```

The figure is `Inview mode="once"` `{ opacity: 0, scale: 1.06 } → { opacity: 1, scale: 1 }`, config
45/24 (not gated on `ready` — it plays under the panel, so the frame is simply there). The frame is
the photograph's box (1153×976 at 385,29 on the board, hung by its 98px overhang past the right
edge from `lg`, pinned to the board's own x at `board`); the small inner `span` is the **handle** —
the product's own rectangle inside the frame, and the only part of it that takes the pointer.

The card hangs off the page's right gutter, on the header's line, desktop only:

```html
<div class="pointer-events-none absolute right-10 top-[13%] hidden w-max lg:block">   <!-- Hover trigger=handle {opacity 0, y 18} → {1, 0} · 210/26 -->
  <div class="border border-foreground/20 bg-background/70 px-5 py-4 text-foreground backdrop-blur-md">
    <p class="font-mono text-fine leading-copy tracking-label uppercase text-foreground/45"><span class="tracking-slash">//</span><span class="tracking-slash-space"> </span>in the bag</p>
    <div class="mt-3 flex items-baseline justify-between gap-10">
      <p class="font-display font-bold text-headline-phone leading-headline tracking-tight uppercase">SLOW ROAST</p>
      <p class="font-mono text-base leading-flush tabular-nums">$18.00</p>
    </div>
    <ul class="mt-3 flex items-center gap-2.5 border-t border-foreground/15 pt-3 font-mono text-fine leading-flush tracking-label uppercase text-foreground/55">
      <li>250 G</li><li><span class="size-[3px] rounded-full bg-foreground/30"></span>WHOLE BEAN</li><li><span …></span>COPENHAGEN</li>
    </ul>
  </div>
</div>
```

Two cards, one per piece, and the one shown is the last piece the pointer was on (kept through the
gap between them): **bag** — `// in the bag` · `SLOW ROAST` · `$18.00` · `250 G · WHOLE BEAN ·
COPENHAGEN`; **cup** — `// in the cup` · `HOUSE LATTE` · `$4.20` · `250 ML · BREWED DAILY · TO GO`.

### The product — `hero-model.tsx`, verbatim

This is plain three.js with React only mounting it. Carry it over as a function that takes the
mount `div`, the handle `span`, the section, and an `onPiece(role | null)` callback, and subscribe
its `frame` to the ticker at gap 0. Three things to know before the code:

- `layout` is **this constant** — the saved position/rotation/scale laid over the fitted placement:
  ```js
  const savedHeroLayout = { position: [0.125, -0.049, 1], rotation: [0, 0, 0], scale: 1.155 };
  ```
  `HeroModelPanel`, `HERO_PANEL`, `snapshot`, `controls` and `DEFAULT_HERO_LAYOUT` are a development
  tuning tool and are not part of the page: drop those lines.
- `DRACO_PATH` is `"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/libs/draco/gltf/"` and
  `src` is `https://storage.getlayers.ai/assets/brewns-b9f02b6657/hero/models.glb`. **The model is
  Draco-compressed and will not parse without `DRACOLoader`.** The file holds three scenes; the
  first one that contains a mesh is the bag + cup composition, and the code below already picks it
  that way.
- `usePageReady.getState().revealing` / `subscribe` become your page-ready store: start the intro the
  moment `revealing` flips.

```ts

/**
 * Where the product sat inside the photograph this replaces, as fractions of
 * the frame. Measured off that asset's own alpha — the bag and cup ran 26.9% to
 * 84.7% across and 4.9% to 77% down, and the rest of the file was empty ground.
 * The model is fitted to the same rectangle, so nothing on the page moves.
 */
const FRAME = { left: 0.2697, right: 0.8439, top: 0.0502, bottom: 0.7695 };

/** The photograph's own box, which those fractions are measured against. */
const ASSET = { width: 1153, height: 976 };

/**
 * The photograph was laid into its frame two ways, and both are reproduced
 * here: on the desktop board it covered the box and was centred, and below the
 * board it was contained and sat on the bottom of it. Fitting the model to flat
 * fractions of the frame would have matched the board and quietly moved the
 * product on tablet and phone, where the frame is a different shape.
 *
 * This is the viewport width the switch happened at — Tailwind's `lg`, which is
 * what the photograph's own `lg:object-cover` was keyed to. Not the frame's
 * width: below the board the frame is wider than the screen on purpose.
 */
const COVER_FROM = 1024;

/**
 * Corrections to where the product stands, per grid.
 *
 * The photograph's own rectangle is the starting point everywhere, but it was
 * cut for the desktop board and the frames below it are shaped differently:
 * on the two middle grids the product ends up hard against the copy with its
 * cup already on the screen's edge, and on the phone the plinth lands whole
 * inside the frame instead of running off it.
 *
 * `scale` sizes the product about its own foot, so the plinth line stays put
 * while it shrinks or grows. `x` and `y` then move it, in fractions of the
 * frame. Both are measured, not guessed — see the numbers in each entry.
 */
const PLACEMENT = [
  /* 1024-1279: back 7% and right 9px. The cup was standing on the screen's
     right edge, so the air the headline needed had to come out of the size —
     a plain shift would have pushed the cup off. */
  { from: 1024, to: 1280, scale: 0.928, x: 0.01, y: 0 },
  /* 768-1023: right off the copy, a little larger, and set down a touch. The
     growth eats into the gap by the button, so the shift right pays it back —
     what it costs is the cup's outer edge, which runs off the screen by about
     twenty pixels and reads as a bleed rather than a cut. */
  { from: 768, to: 1024, scale: 1.02, x: 0.0454, y: 0.05 },
  /* Phone: sized to the gap between the button and the info bar rather than to
     taste. The packaging's foot is white and the bar's type is white, so
     anywhere the two overlap the address is unreadable — the product is held
     small enough that its foot lands above the bar and the plinth, which is
     nearly black, is what the type sits on. */
  { from: 0, to: 640, scale: 0.95, x: -0.045, y: 0.0163 },
] as const;

/**
 * Where the product sits inside the model's own bounding box, as fractions of
 * it. The file composes the bag and the cup and nothing else — no plinth — so
 * the product is the whole box.
 */
const PRODUCT = { left: 0, right: 1, top: 0, bottom: 1 };

/**
 * The depth the fit is solved on, 0 at the far face of the bounding box and 1
 * at the near one. The cup is round, so its silhouette is widest through the
 * middle — which is where the fit measures it.
 */
const PRODUCT_DEPTH = 0.5;

/**
 * A last vertical nudge, as a fraction of the product's height. The plane above
 * is a flat rectangle and the product is not: its silhouette sits slightly
 * higher in frame than the rectangle's centre does, and this is the measured
 * difference — with it the bag's top and the plinth line land where the
 * photograph's did.
 */
const FIT_NUDGE_Y = 0;

/** Breathing room kept between the product and the edge of the screen, in px. */
const FRAME_MARGIN = 10;

/**
 * The lift on hover. Only the piece under the pointer lifts: `by` is how high,
 * in fractions of the product's height, and `turn` / `roll` the small twist it
 * picks up on the way (radians, about its own vertical and its own depth axis)
 * — a thing picked up is never lifted dead straight.
 *
 * The lift runs on a damped spring (velocity carried frame to frame) rather
 * than a plain chase: it eases *in* as well as out, so the piece floats up
 * instead of jumping at the first frame. `stiffness` / `damping` are per frame
 * and sit just past critical — no overshoot, about a third of a second to
 * settle.
 */
const LIFT = {
  bag: { by: 0.05, turn: -0.14, roll: 0.05 },
  cup: { by: 0.1, turn: 0.24, roll: -0.08 },
  stiffness: 0.004,
  damping: 0.88,
  /** Below this the lift has arrived and the loop can settle (0-1 scale). */
  rest: 0.0005,
} as const;

/**
 * How far the product sinks as the hero scrolls away, in product heights over
 * the hero's own height, and how quickly it follows the scroll. It lags the
 * page a little — the product drifting down out of a frame that is leaving.
 */
const SCROLL_DROP = 0.22;
const SCROLL_EASE = 0.14;

/**
 * Extra canvas around the frame, as a fraction of it on every side. The frame
 * is the photograph's box, and the panel's offset and scale can carry the
 * product past it — without the margin the canvas edge cut straight through
 * the bag. The fit still solves against the frame, not the larger canvas.
 */
const BLEED = 0.22;

/**
 * The entrance once the preloader has gone: the product rises straight up
 * from below, turning a little into its pose about its own centre as it
 * comes. `rise` is in product heights, `turn` / `roll` in radians about the
 * vertical and the depth axis, `duration` and `delay` in ms. Eased out
 * (cubic), so it arrives rather than stops.
 */
const INTRO = { rise: 0.35, turn: 0.25, roll: -0.05, duration: 1400, delay: 0 };

/**
 * The lean toward the cursor, per piece: `by` scales the product's lean
 * (`POINTER.lean`), `ease` is how quickly the piece chases it. Different on
 * purpose — the cup turns further and sooner, the bag follows late — so the two
 * turn toward the cursor out of step instead of as one block.
 */
const LEAN = {
  bag: { by: 0.8, ease: 0.06 },
  cup: { by: 1.35, ease: 0.15 },
} as const;

/**
 * Degrees to radians. The panel's whole-product transform
 * (`src/data/hero-layout.json`) speaks degrees; three speaks radians.
 */
const DEG = Math.PI / 180;

/**
 * A long lens: enough convergence to read as a solid object rather than a flat
 * card, little enough that the silhouette still lands on the photograph's
 * rectangle. Product photography is shot this way for the same reason.
 */
const FIELD_OF_VIEW = 8;

/**
 * How the product answers the pointer.
 *
 * `lean` is the idle state — it turns a few degrees toward the cursor as it
 * crosses the section, which is the gesture the philosophy cup answers further
 * down the page. `drag` is the real one: grab the product and it turns under
 * your hand, a degree per two pixels, and keeps spinning briefly when you let
 * go.
 *
 * Both axes are clamped, and the yaw limit is a framing decision rather than a
 * taste one: the bag and cup stand side by side, so turning the pair swings the
 * near one outward, and past about forty degrees it reaches the edge of the
 * frame and is cut. Pitch is held tighter still — there is no underside to this
 * model worth showing and the plinth reads as a bare slab from below.
 */
const POINTER = {
  lean: { yaw: 0.2, pitch: 0.11 },
  dragPerPixel: 0.009,
  yawLimit: 0.6,
  pitchLimit: 0.3,
  /** Per-frame decay of the spin left over from a flick. */
  friction: 0.94,
  /** Below this the spin is spent and the loop can settle. */
  restSpin: 0.0004,
  /** How fast the shown angle chases the target. */
  ease: 0.16,
  /** Below this the chase is over. */
  restAngle: 0.0002,
} as const;

/**
 * The studio the product is lit in, matched to the reference render.
 *
 * The packaging is a 0.44-rough dielectric, so anything bright placed between
 * the camera and the bag's flat front lands its own reflection back down the
 * lens and lays a sheen over the panel — which is what lifted the printed black
 * to grey. Both keys are therefore held about sixty degrees off the view axis,
 * far enough out of that specular lobe to leave the type alone while their
 * diffuse still adds up to a fully lit face. It is the same reason a copy stand
 * puts its lamps out at the sides.
 *
 * Directions only: these are directional lights, so the distances mean nothing.
 */
const KEYS = [
  /* Left key. Still the main light, but brought down from 5.8: with the
     product nearer the lens it burnt the bag's face out to flat white and
     took the print's greys with it. */
  { position: [-4.2, 3, 1.4], intensity: 4 },
  /* Right key, lifted to a quarter of the left: the contrast comes from the
     pair now rather than from a blown face against a dead side. */
  { position: [3.9, 2.5, 1.3], intensity: 1 },
  /* Rim, behind and above: the bag's shoulder and the gloss along the lid. */
  { position: [0.6, 3, -3.2], intensity: 0.6 },
] as const;

/**
 * The room those keys stand in: softboxes on a black ground, baked to an
 * environment map. It carries the soft reflections in the lid and the printed
 * panel, and nothing else — a lit room would put ambient on every surface and
 * grey the type again, which is what the black ground is for. `emit` is a
 * radiance, so it runs past 1.
 */
const STUDIO = [
  { size: [7, 7], position: [-2.6, 2.4, 2.6], emit: 11 },
  { size: [5, 5], position: [3.4, 0.5, 2.2], emit: 2.4 },
  { size: [6, 3], position: [0.6, 3, -3], emit: 3.2 },
  { size: [6, 6], position: [0, -3, 1.6], emit: 0.5 },
] as const;

/**
 * The keys' colour. Neutral white came out a touch warm in red and a touch cold
 * in blue against the reference — this is the correction, measured off the same
 * patch of the bag's front. It is a two per cent move and reads as white.
 */
const LIGHT_COLOUR = 0xfcfff9;

/** How much of that room reaches the product — a soft fill under the keys,
    so the shadow side holds detail without the keys having to shout. */
const ENVIRONMENT = 0.045;

/** The mesh ships Draco-compressed, so the decoder has to be on hand to read
    it. Served from /public rather than a CDN — the page must not depend on a
    third party to draw its own product. */
const DRACO_PATH = "/draco/";

/**
 * Floor under the pixel ratio, so the render is supersampled even where the
 * screen is not. The product is a single object on a dark ground and its edge
 * is the whole silhouette; multisampling alone left it stepped on a 1x display.
 */
const MIN_PIXEL_RATIO = 1.5;

/**
 * Strength every normal map is drawn at. The exporter writes the bag's at 3.3,
 * which turns each fold of the paper into a stripe running to black; at 1 the
 * map reads as the paper it was baked from.
 */
const NORMAL_STRENGTH = 1;

const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

const clamp = (value: number, limit: number) =>
  Math.max(-limit, Math.min(limit, value));

export interface HeroModelProps {
  /** Path to the .glb, served from /public. */
  src: string;
  /** Describes the product for anyone who cannot see the canvas. */
  alt: string;
  /**
   * The product's own rectangle in the layout — what the pointer grabs. The
   * frame around it overhangs the copy column, so the whole canvas cannot be
   * the handle; this element is the part of it the product actually occupies.
   */
  surface: RefObject<HTMLElement | null>;
  /** Told which piece the pointer is on — or none — whenever that changes. */
  onPiece?: (piece: HeroPiece | null) => void;
}

/** The pieces the product is composed of. */
export type HeroPiece = "bag" | "cup";

/**
 * The hero's product, as geometry you can turn.
 *
 * Plain three rather than a React renderer: the scene is one mesh and one light
 * rig, so a reconciler would buy nothing, and the React renderer's global JSX
 * augmentation collides with the vendored spring components, which are not ours
 * to change.
 *
 * The camera is fitted once, at the rest pose, and then left alone. Turning the
 * product moves the model inside a fixed frame rather than re-solving the frame
 * for each new silhouette — which is what keeps the bag exactly the size and in
 * exactly the place the photograph's was, however far it has been spun.
 *
 * Drawing is on demand. The component subscribes to the app's one shared ticker
 * and returns immediately on any frame where no angle has changed, so a product
 * standing still costs a function call rather than a redraw, and a product
 * scrolled off the screen costs nothing at all.
 */
export const HeroModel = ({ src, alt, surface, onPiece }: HeroModelProps) => {
  const host = useRef<HTMLDivElement>(null);
  /* Read live from the scene's listeners, which are set up once. */
  const onPieceRef = useRef(onPiece);
  useEffect(() => {
    onPieceRef.current = onPiece;
  });
  const frame = useRef<(() => void) | null>(null);
  /** The scene's side of the dev panel, and what the panel starts from. */
  const controls = useRef<HeroModelControls | null>(null);
  const [snapshot, setSnapshot] = useState<HeroSnapshot | null>(null);

  useLoop(() => frame.current?.(), { framerate: 0 });

  useEffect(() => {
    const mount = host.current;
    if (!mount) return;
    const hero = mount.closest("section");

    // The canvas overhangs the frame by BLEED on every side; `mount` stays the
    // frame, and everything the fit measures is measured on it.
    const canvas = document.createElement("canvas");
    canvas.style.position = "absolute";
    canvas.style.left = `${-BLEED * 100}%`;
    canvas.style.top = `${-BLEED * 100}%`;
    canvas.style.width = `${(1 + 2 * BLEED) * 100}%`;
    canvas.style.height = `${(1 + 2 * BLEED) * 100}%`;
    canvas.style.display = "block";
    mount.appendChild(canvas);

    let renderer: WebGLRenderer;
    try {
      renderer = new WebGLRenderer({
        canvas,
        alpha: true,
        antialias: true,
        powerPreference: "high-performance",
        // The scene is drawn on demand, so frames are minutes apart. Without
        // this the drawing buffer is free to be thrown away after it has been
        // composited, and the next repaint that is not ours — a tab coming
        // back to the front, a style change above it — shows an empty canvas
        // with no redraw to fix it.
        preserveDrawingBuffer: true,
      });
    } catch {
      // No WebGL: leave the frame empty rather than throwing on the hero.
      canvas.remove();
      return;
    }

    renderer.setClearAlpha(0);
    renderer.outputColorSpace = SRGBColorSpace;
    // Khronos' PBR-neutral curve rather than ACES: it is built for product
    // shots and leaves albedo where the texture put it, where ACES pulls the
    // whole image toward grey — the paper went dull and the printed black with
    // it, which is the one thing the reference render does not do.
    renderer.toneMapping = NeutralToneMapping;
    // Down from 1.49: that figure was measured on the old bag's paper, and on
    // this model it clipped the lit face to white. Here the face stays just
    // under white and the printed type keeps its weight.
    renderer.toneMappingExposure = 1.15;

    const scene = new Scene();
    const camera = new PerspectiveCamera(FIELD_OF_VIEW, 1, 0.1, 100);

    const studio = new Scene();
    studio.background = new Color(0x000000);
    for (const { size, position, emit } of STUDIO) {
      const panel = new Mesh(
        new PlaneGeometry(size[0], size[1]),
        new MeshBasicMaterial({ color: new Color(emit, emit, emit) }),
      );
      panel.position.set(position[0], position[1], position[2]);
      panel.lookAt(0, 0, 0);
      studio.add(panel);
    }

    const pmrem = new PMREMGenerator(renderer);
    const environment = pmrem.fromScene(studio, 0.04);
    scene.environment = environment.texture;
    scene.environmentIntensity = ENVIRONMENT;

    // The panels only existed to be baked into that map.
    studio.traverse((node) => {
      const panel = node as Partial<Mesh>;
      panel.geometry?.dispose();
      (panel.material as MeshBasicMaterial | undefined)?.dispose();
    });

    for (const { position, intensity } of KEYS) {
      const light = new DirectionalLight(LIGHT_COLOUR, intensity);
      light.position.set(position[0], position[1], position[2]);
      scene.add(light);
    }

    /**
     * The panel's whole-product transform. It sits above everything the fit
     * measures — the fit projects through the pivot's own matrix, not the
     * stage's — so moving, turning or scaling the product here lays it over
     * the fitted placement instead of being re-fitted away.
     */
    const stage = new Group();
    /** The scroll drift, above the panel's transform and just as unseen by
        the fit. */
    const drift = new Group();
    scene.add(drift);
    drift.add(stage);
    /**
     * The entrance after the preloader. It sits *under* the stage, directly
     * over the pivot, so its turn is about the product's own centre. Above the
     * stage it turned about the scene's origin instead, and the panel's offset
     * (a product height toward the lens) swung the product sideways across
     * the frame as it rose.
     */
    const intro = new Group();
    stage.add(intro);

    // The product turns about its own centre, so the mesh is hung off a pivot
    // that sits there rather than wherever the exporter left the origin.
    const pivot = new Group();
    intro.add(pivot);

    let disposed = false;
    let model: Group | null = null;
    /**
     * The pieces the file composes the product from — each top-level node of
     * its scene — with where each rests, how far it has lifted and how far it
     * has leaned toward the cursor.
     */
    type Piece = {
      node: Object3D;
      role: "bag" | "cup";
      base: number;
      by: number;
      turn: number;
      /** The lift spring's velocity, per frame. */
      speed: number;
      roll: number;
      /** 0 at rest, 1 fully lifted. */
      lift: number;
      leanBy: number;
      leanEase: number;
      lean: { yaw: number; pitch: number };
    };
    let pieces: Piece[] = [];
    /** The piece under the pointer, which is the only one that lifts. */
    let hovered: Piece | null = null;
    let layout: HeroLayout = savedHeroLayout;
    /**
     * Two boxes, both the packaging's and both in world units at the rest pose.
     * `plane` is flat, on the depth the bag stands at, and sizes the product —
     * a box as deep as the plinth projects near corners the eye never sees.
     * `solid` has the packaging's own depth and is what keeps it on screen once
     * it turns: a flat plane goes edge-on as it rotates and stops describing
     * where the product actually is.
     */
    let plane: Box3 | null = null;
    let solid: Box3 | null = null;

    /* Angles, all in radians and all measured from the rest pose. `drag` is
       what the hand has put in, `lean` what the pointer is asking for, `shown`
       what is actually on screen and chasing the sum of the two. */
    const drag = { yaw: 0, pitch: 0 };
    const lean = { yaw: 0, pitch: 0 };
    const shown = { yaw: 0, pitch: 0 };
    let spin = 0;
    let productHeight = 0;
    let dirty = true;
    let onScreen = true;
    let reduced = window.matchMedia(REDUCED_MOTION_QUERY).matches;

    /* The entrance: starts the moment the preloader begins to pour away —
       the hero is already showing through as it drains, so waiting for the
       pour to finish read as a pause. It may already be under way if this
       mounts late; it is skipped under reduced motion. */
    let introStart: number | null = null;
    let introDone = reduced;
    const startIntro = () => {
      if (introStart !== null) return;
      introStart = performance.now() + INTRO.delay;
      dirty = true;
    };
    if (usePageReady.getState().revealing) startIntro();
    const unsubscribeReady = usePageReady.subscribe((state) => {
      if (state.revealing) startIntro();
    });

    /**
     * Where the packaging lands on screen, in pixels, for the camera as it
     * currently stands. Eight corners rather than one number, because under a
     * perspective lens the near corner of a box projects larger than the far
     * one and the world height alone under-sizes the fit.
     */
    const project = (box: Box3, width: number, height: number) => {
      let x0 = Infinity;
      let y0 = Infinity;
      let x1 = -Infinity;
      let y1 = -Infinity;
      const corner = new Vector3();
      pivot.updateMatrix();
      for (let i = 0; i < 8; i++) {
        corner
          .set(
            i & 1 ? box.max.x : box.min.x,
            i & 2 ? box.max.y : box.min.y,
            i & 4 ? box.max.z : box.min.z,
          )
          // The box is the pivot's, so it turns with it — through the pivot's
          // own matrix, not its world one: the stage above it is the panel's
          // offset, and the fit must not see it.
          .applyMatrix4(pivot.matrix)
          .project(camera);
        x0 = Math.min(x0, ((corner.x + 1) / 2) * width);
        x1 = Math.max(x1, ((corner.x + 1) / 2) * width);
        y0 = Math.min(y0, ((1 - corner.y) / 2) * height);
        y1 = Math.max(y1, ((1 - corner.y) / 2) * height);
      }
      return { x0, y0, x1, y1 };
    };

    /** Where the product has to sit, in pixels, from the last fit. */
    let target: {
      x: number;
      y: number;
      width: number;
      height: number;
      /** The screen's own edges, in the canvas's pixel coordinates. */
      left: number;
      right: number;
    } | null = null;

    /**
     * Re-centres the frame on the product for the pose it is in now.
     *
     * The pair stands side by side, so turning it swings the near one outward
     * and, held to a fixed frame, straight off the edge of the screen. Running
     * the shift every frame keeps the product framed at any angle while the
     * camera's distance — and so the product's size — stays exactly where the
     * fit put it. At rest the shift resolves to the photograph's own placement,
     * which is the number that had to be preserved.
     */
    const reframe = () => {
      if (!target || !plane || !solid) return;
      camera.clearViewOffset();
      camera.updateProjectionMatrix();
      const shot = project(plane, target.width, target.height);
      const reach = project(solid, target.width, target.height);

      /* The offset that puts the product's centre on the mark, then held to
         whatever range still keeps the whole of it on screen. Raising the
         offset moves the product left, so the far edge sets the floor and the
         near edge the ceiling; if it is too wide for both, it is centred. */
      const edges = target;
      const hold = (want: number, low: number, high: number) => {
        const floor = high - edges.right;
        const ceiling = low - edges.left;
        return floor > ceiling
          ? (floor + ceiling) / 2
          : Math.min(Math.max(want, floor), ceiling);
      };

      camera.setViewOffset(
        target.width,
        target.height,
        hold((shot.x0 + shot.x1) / 2 - target.x, reach.x0, reach.x1),
        // Vertically the mark stands: the plinth is meant to run off the
        // bottom edge, exactly as it did in the photograph, and pitch is
        // clamped tightly enough that nothing else reaches the top.
        (shot.y0 + shot.y1) / 2 - target.y,
        target.width,
        target.height,
      );
      camera.updateProjectionMatrix();
    };

    /** Puts the product where the photograph's was, at this frame's size. */
    const fit = () => {
      // The frame's size; the canvas is this plus BLEED on every side.
      const width = mount.clientWidth;
      const height = mount.clientHeight;
      if (!width || !height || !model || !plane) return;
      const padX = width * BLEED;
      const padY = height * BLEED;
      const canvasWidth = width + 2 * padX;
      const canvasHeight = height + 2 * padY;

      // Read once here rather than every frame: the frame only moves when the
      // window does, and this is the one place that runs on a resize.
      const onPage = mount.getBoundingClientRect();
      const canvasLeft = onPage.left - padX;

      // Where the photograph put its product inside this frame, as fractions of
      // it — the asset's own `object-fit` maths, run again for these pixels.
      const cover = window.innerWidth >= COVER_FROM;
      const lay = cover
        ? Math.max(width / ASSET.width, height / ASSET.height)
        : Math.min(width / ASSET.width, height / ASSET.height);
      const laidW = ASSET.width * lay;
      const laidH = ASSET.height * lay;
      const offsetX = (width - laidW) / 2;
      const offsetY = cover ? (height - laidH) / 2 : height - laidH;
      const rect: { left: number; right: number; top: number; bottom: number } =
        {
          left: (offsetX + FRAME.left * laidW) / width,
          right: (offsetX + FRAME.right * laidW) / width,
          top: (offsetY + FRAME.top * laidH) / height,
          bottom: (offsetY + FRAME.bottom * laidH) / height,
        };

      // On the squarer frames between tablet and board, the foot of the
      // product is let out to the bottom of the screen instead of stopping
      // where the photograph's did.
      // The grid's own correction, applied to that rectangle.
      const place = PLACEMENT.find(
        (band) => window.innerWidth >= band.from && window.innerWidth < band.to,
      );
      if (place) {
        const tall = rect.bottom - rect.top;
        const half = (rect.right - rect.left) / 2;
        const middle = (rect.left + rect.right) / 2 + place.x;
        rect.top = rect.bottom - tall * place.scale + place.y;
        rect.bottom += place.y;
        rect.left = middle - half;
        rect.right = middle + half;
      }

      camera.aspect = canvasWidth / canvasHeight;

      // The frame's rectangle, carried into the canvas's pixels.
      const targetTop = padY + height * rect.top;
      const targetHeight = height * (rect.bottom - rect.top);

      // The fit is solved at the rest pose whatever the product has been turned
      // to, so a window resize mid-spin cannot resize the product with it.
      const turned = pivot.rotation.clone();
      pivot.rotation.set(0, 0, 0);

      // Stand back until the packaging measures the rectangle's height. Four
      // passes rather than one formula: distance and projected size feed each
      // other, and the ratio converges on the first two.
      let distance =
        plane.getSize(new Vector3()).y /
        (rect.bottom - rect.top) /
        2 /
        Math.tan((FIELD_OF_VIEW * Math.PI) / 360);
      for (let pass = 0; pass < 4; pass++) {
        // The lens is long, so the camera stands a long way back — further
        // than any fixed far plane reaches once the product is large in world
        // units. The clip range rides the distance instead.
        camera.near = distance / 20;
        camera.far = distance * 4;
        camera.position.set(0, 0, distance);
        camera.lookAt(0, 0, 0);
        camera.clearViewOffset();
        camera.updateProjectionMatrix();
        camera.updateMatrixWorld();
        const shot = project(plane, canvasWidth, canvasHeight);
        distance *= (shot.y1 - shot.y0) / targetHeight;
      }

      // Where that product has to land: a shift of the frustum, not a turn of
      // the camera. Turning it would keystone the bag; a shifted frustum is a
      // shift lens and leaves the product square to the viewer.
      target = {
        x: padX + width * ((rect.left + rect.right) / 2),
        y: targetTop + targetHeight * (0.5 + FIT_NUDGE_Y),
        width: canvasWidth,
        height: canvasHeight,
        left: FRAME_MARGIN - canvasLeft,
        right: window.innerWidth - canvasLeft - FRAME_MARGIN,
      };

      pivot.rotation.copy(turned);
      reframe();

      renderer.setPixelRatio(
        Math.min(Math.max(window.devicePixelRatio, MIN_PIXEL_RATIO), 2),
      );
      renderer.setSize(canvasWidth, canvasHeight, false);
      dirty = true;
    };

    /* One frame. Cheap when nothing is moving, which is most of the time. */
    frame.current = () => {
      if (!model || !onScreen || document.hidden) return;

      // Sink with the scroll: 0 at the top of the page, SCROLL_DROP product
      // heights once the hero has scrolled its own height away.
      if (hero && !reduced && productHeight) {
        const box = hero.getBoundingClientRect();
        const away = Math.min(1, Math.max(0, -box.top / box.height));
        const gap = -away * SCROLL_DROP * productHeight - drift.position.y;
        if (Math.abs(gap) > productHeight * 1e-4) {
          drift.position.y += gap * SCROLL_EASE;
          dirty = true;
        }
      }

      // The entrance: below and turned until the page is ready, then up into
      // place on an ease-out cubic.
      if (!introDone && productHeight) {
        const t =
          introStart === null
            ? 0
            : Math.min(
                1,
                Math.max(0, (performance.now() - introStart) / INTRO.duration),
              );
        const left = Math.pow(1 - t, 3);
        intro.position.y = -left * INTRO.rise * productHeight;
        intro.rotation.set(0, left * INTRO.turn, left * INTRO.roll);
        introDone = t >= 1;
        dirty = true;
      }

      if (spin) {
        drag.yaw = clamp(drag.yaw + spin, POINTER.yawLimit);
        spin = Math.abs(spin) > POINTER.restSpin ? spin * POINTER.friction : 0;
        dirty = true;
      }

      // Each piece on its own, about its own centre: the lift (only the one
      // under the pointer) with its twist, and the lean toward the cursor at
      // the piece's own rate, so the two never move as one block.
      for (const piece of pieces) {
        const liftGap = (hovered === piece ? 1 : 0) - piece.lift;
        const yawGap = lean.yaw * piece.leanBy - piece.lean.yaw;
        const pitchGap = lean.pitch * piece.leanBy - piece.lean.pitch;
        if (
          Math.abs(liftGap) < LIFT.rest &&
          Math.abs(piece.speed) < LIFT.rest &&
          Math.abs(yawGap) < POINTER.restAngle &&
          Math.abs(pitchGap) < POINTER.restAngle
        )
          continue;
        piece.speed = (piece.speed + liftGap * LIFT.stiffness) * LIFT.damping;
        piece.lift += piece.speed;
        piece.lean.yaw += yawGap * piece.leanEase;
        piece.lean.pitch += pitchGap * piece.leanEase;
        piece.node.position.y =
          piece.base + piece.lift * piece.by * productHeight;
        piece.node.rotation.set(
          piece.lean.pitch,
          piece.lean.yaw + piece.lift * piece.turn,
          piece.lift * piece.roll,
        );
        dirty = true;
      }

      // The whole product answers only the hand; the cursor's lean is per
      // piece, above.
      const yaw = clamp(drag.yaw, POINTER.yawLimit);
      const pitch = clamp(drag.pitch, POINTER.pitchLimit);
      const dYaw = yaw - shown.yaw;
      const dPitch = pitch - shown.pitch;

      if (
        Math.abs(dYaw) > POINTER.restAngle ||
        Math.abs(dPitch) > POINTER.restAngle
      ) {
        shown.yaw += dYaw * POINTER.ease;
        shown.pitch += dPitch * POINTER.ease;
        dirty = true;
      }

      if (!dirty) return;
      pivot.rotation.set(shown.pitch, shown.yaw, 0);
      reframe();
      renderer.render(scene, camera);
      dirty = false;
    };

    /**
     * Measures the product as the file composes it and fits the camera to it.
     * Runs once, at load, while the stage is still at rest — the panel's
     * transform is laid on afterwards by `applyStage` and never re-measured.
     */
    const measure = () => {
      if (!model) return;
      // Measured on its own, at rest — off the pivot, which the pointer turns.
      model.removeFromParent();
      model.position.set(0, 0, 0);
      model.rotation.set(0, 0, 0);
      model.updateWorldMatrix(true, true);
      const box = new Box3().setFromObject(model);
      const span = box.getSize(new Vector3());
      const centre = box.getCenter(new Vector3());

      // Tell the bag from the cup by height — the bag is the taller — so each
      // lifts on its own rate. The turn above is about the vertical, so a
      // piece's own `y` is still straight up.
      const sized = model.children.map((node) => ({
        node,
        height: new Box3().setFromObject(node).getSize(new Vector3()).y,
      }));
      const tallest = Math.max(...sized.map((piece) => piece.height));
      pieces = sized.map(({ node, height }): Piece => {
        const role = height === tallest ? "bag" : "cup";
        return {
          node,
          role,
          base: node.position.y,
          ...LIFT[role],
          lift: 0,
          speed: 0,
          leanBy: LEAN[role].by,
          leanEase: LEAN[role].ease,
          lean: { yaw: 0, pitch: 0 },
        };
      });
      hovered = null;

      // Local position is applied after local rotation, so this lands the
      // product's own centre — not the exporter's origin — on the pivot,
      // which is the point everything turns about.
      model.position.set(
        -(box.min.x + span.x * ((PRODUCT.left + PRODUCT.right) / 2)),
        -(box.max.y - span.y * ((PRODUCT.top + PRODUCT.bottom) / 2)),
        -centre.z,
      );
      pivot.add(model);
      pivot.updateWorldMatrix(true, true);

      // The product's own box, in world units, now that the mesh is placed.
      const world = new Box3().setFromObject(pivot);
      const reach = world.getSize(new Vector3());
      const back = world.min.z;
      const front = world.min.z + reach.z * PRODUCT_DEPTH;
      const left = world.min.x + reach.x * PRODUCT.left;
      const right = world.min.x + reach.x * PRODUCT.right;
      const bottom = world.max.y - reach.y * PRODUCT.bottom;
      const top = world.max.y - reach.y * PRODUCT.top;

      plane = new Box3(
        new Vector3(left, bottom, front),
        new Vector3(right, top, front),
      );
      productHeight = top - bottom;
      // Full depth, not the fit plane's: the cup stands forward of the bag,
      // so a box that stopped at the bag's own depth did not contain it and
      // let it swing off the screen when the product turned.
      solid = new Box3(
        new Vector3(left, bottom, back),
        new Vector3(right, top, world.max.z),
      );

      fit();
    };

    /**
     * Lays the panel's transform over the fitted product. Position is in
     * product heights, so it means the same at every window size; rotation
     * and scale are about the product's own centre, where the pivot sits.
     */
    const applyStage = () => {
      const [x, y, z] = layout.position;
      const [rx, ry, rz] = layout.rotation;
      stage.position.set(x * productHeight, y * productHeight, z * productHeight);
      stage.rotation.set(rx * DEG, ry * DEG, rz * DEG);
      stage.scale.setScalar(layout.scale);
      dirty = true;
    };

    controls.current = {
      apply: (next) => {
        layout = next;
        applyStage();
      },
    };

    /* Which piece the pointer is over, by a ray through the canvas — the
       handle is one rectangle for the whole product, so it cannot say. */
    const raycaster = new Raycaster();
    const ndc = new Vector2();
    const pick = (event: MouseEvent) => {
      if (!model) return;
      const box = canvas.getBoundingClientRect();
      ndc.set(
        ((event.clientX - box.left) / box.width) * 2 - 1,
        -((event.clientY - box.top) / box.height) * 2 + 1,
      );
      raycaster.setFromCamera(ndc, camera);
      let node: Object3D | null =
        raycaster.intersectObject(model, true)[0]?.object ?? null;
      while (node && node.parent !== model) node = node.parent;
      const next = pieces.find((piece) => piece.node === node) ?? null;
      if (next !== hovered) {
        hovered = next;
        dirty = true;
        onPieceRef.current?.(next?.role ?? null);
      }
    };

    const draco = new DRACOLoader().setDecoderPath(DRACO_PATH);
    const loader = new GLTFLoader().setDRACOLoader(draco);

    loader.load(
      src,
      (gltf) => {
        if (disposed) return;
        // The file's default scene is an empty one exported ahead of the scene
        // the cup is actually in, so the scene is picked by what it holds
        // rather than by the file's own default.
        const holdsMesh = (root: Group) => {
          let found = false;
          root.traverse((node) => {
            if ((node as Mesh).isMesh) found = true;
          });
          return found;
        };
        model = gltf.scenes.find(holdsMesh) ?? gltf.scene;

        // Normal maps at their true strength. The loader stores glTF's green
        // channel flipped (a negative y), so each sign is kept.
        model.traverse((node) => {
          const mesh = node as Mesh;
          if (!mesh.isMesh) return;
          const materials = Array.isArray(mesh.material)
            ? mesh.material
            : [mesh.material];
          for (const material of materials) {
            const { normalScale } = material as MeshStandardMaterial;
            normalScale?.set(
              Math.sign(normalScale.x) * NORMAL_STRENGTH,
              Math.sign(normalScale.y) * NORMAL_STRENGTH,
            );
          }
        });

        const loaded = model;
        // Every piece is re-hung from a plain holder at its own centre, so the
        // hover lift and the cursor lean turn each piece about its own middle
        // — whatever nesting, axis conversions and unit scales the exporter
        // left inside (the cup carries a Sketchfab Z-up turn and a ×100 × 0.01
        // pair).
        loaded.updateWorldMatrix(true, true);
        for (const node of [...loaded.children]) {
          const centre = new Box3()
            .setFromObject(node)
            .getCenter(new Vector3());
          const holder = new Group();
          holder.name = node.name;
          holder.position.copy(centre);
          loaded.add(holder);
          node.position.sub(centre);
          holder.add(node);
        }
        measure();
        applyStage();

        // Opt-in: the panel only appears with `?panel` in the address.
        if (HERO_PANEL && new URLSearchParams(window.location.search).has("panel"))
          setSnapshot({ layout, file: DEFAULT_HERO_LAYOUT });
      },
      undefined,
      (error) => {
        /* A missing model leaves the frame empty; the page still stands. The
           loader also routes anything the success path throws through here, so
           it is reported rather than swallowed. */
        console.error("hero model", error);
      },
    );

    const observer = new ResizeObserver(fit);
    observer.observe(mount);

    const watcher = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
    });
    watcher.observe(mount);

    // Coming back to the tab: the context may have been dropped while away.
    const onVisible = () => {
      dirty = true;
    };
    document.addEventListener("visibilitychange", onVisible);
    canvas.addEventListener("webglcontextrestored", onVisible);

    const motion = window.matchMedia(REDUCED_MOTION_QUERY);
    const onMotionChange = (event: MediaQueryListEvent) => {
      reduced = event.matches;
      if (reduced) {
        lean.yaw = 0;
        lean.pitch = 0;
        spin = 0;
      }
    };
    motion.addEventListener("change", onMotionChange);

    /* Grabbing the product. Pointer capture rather than window listeners, so a
       drag that runs off the frame still belongs to the product until it ends. */
    const handle = surface.current;
    let dragging = false;
    let lastX = 0;
    let lastY = 0;

    const onDown = (event: PointerEvent) => {
      dragging = true;
      spin = 0;
      lastX = event.clientX;
      lastY = event.clientY;
      // Capture can be refused for a pointer the browser no longer tracks; the
      // drag still works from the handle's own moves, so this must not throw.
      try {
        handle?.setPointerCapture(event.pointerId);
      } catch {}
    };

    const onDrag = (event: PointerEvent) => {
      if (!dragging) return;
      const dx = event.clientX - lastX;
      const dy = event.clientY - lastY;
      lastX = event.clientX;
      lastY = event.clientY;
      drag.yaw = clamp(drag.yaw + dx * POINTER.dragPerPixel, POINTER.yawLimit);
      drag.pitch = clamp(
        drag.pitch + dy * POINTER.dragPerPixel,
        POINTER.pitchLimit,
      );
      // The last sample is the throw; under reduced motion there is none.
      spin = reduced ? 0 : dx * POINTER.dragPerPixel;
      dirty = true;
    };

    const onUp = (event: PointerEvent) => {
      dragging = false;
      try {
        handle?.releasePointerCapture(event.pointerId);
      } catch {}
    };

    /* Only the piece under the pointer lifts. It is the same rectangle the
       drag and the card answer, so all three arrive as one gesture; the ray
       then says which piece inside it the pointer is actually on. */
    const onEnter = (event: MouseEvent) => pick(event);
    const onLeave = () => {
      hovered = null;
      dirty = true;
      onPieceRef.current?.(null);
    };
    const onMove = (event: PointerEvent) => {
      onDrag(event);
      if (!dragging) pick(event);
    };

    handle?.addEventListener("mouseenter", onEnter);
    handle?.addEventListener("mouseleave", onLeave);
    handle?.addEventListener("pointerdown", onDown);
    handle?.addEventListener("pointermove", onMove);
    handle?.addEventListener("pointerup", onUp);
    handle?.addEventListener("pointercancel", onUp);

    /* The idle lean, measured against the whole section the way the page's
       other pointer-aware pieces are. It rides on top of whatever the hand has
       already turned, so the two never fight over the same angle. */
    const section = mount.closest("section");

    const onSectionMove = (event: PointerEvent) => {
      if (dragging || reduced) return;
      const box = section?.getBoundingClientRect();
      if (!box) return;
      const nx = (event.clientX - (box.left + box.width / 2)) / (box.width / 2);
      const ny =
        (event.clientY - (box.top + box.height / 2)) / (box.height / 2);
      lean.yaw = clamp(nx, 1) * POINTER.lean.yaw;
      lean.pitch = -clamp(ny, 1) * POINTER.lean.pitch;
      dirty = true;
    };

    const onSectionLeave = () => {
      lean.yaw = 0;
      lean.pitch = 0;
      dirty = true;
    };

    section?.addEventListener("pointermove", onSectionMove);
    section?.addEventListener("pointerleave", onSectionLeave);

    return () => {
      disposed = true;
      frame.current = null;
      controls.current = null;
      unsubscribeReady();
      observer.disconnect();
      watcher.disconnect();
      document.removeEventListener("visibilitychange", onVisible);
      canvas.removeEventListener("webglcontextrestored", onVisible);
      motion.removeEventListener("change", onMotionChange);
      handle?.removeEventListener("mouseenter", onEnter);
      handle?.removeEventListener("mouseleave", onLeave);
      handle?.removeEventListener("pointerdown", onDown);
      handle?.removeEventListener("pointermove", onMove);
      handle?.removeEventListener("pointerup", onUp);
      handle?.removeEventListener("pointercancel", onUp);
      section?.removeEventListener("pointermove", onSectionMove);
      section?.removeEventListener("pointerleave", onSectionLeave);
      scene.traverse((node) => {
        const mesh = node as {
          geometry?: { dispose(): void };
          material?: unknown;
        };
        mesh.geometry?.dispose();
        const material = mesh.material;
        for (const one of Array.isArray(material) ? material : [material]) {
          (one as { dispose?: () => void } | undefined)?.dispose?.();
        }
      });
      draco.dispose();
      environment.texture.dispose();
      pmrem.dispose();
      renderer.dispose();
      canvas.remove();
    };
  }, [src, surface]);
```

The load's error path (`console.error("hero model", error)`) is where a missing model shows: add a
visible banner there naming the URL (see Assets).

## 2 — Opalesce, the gradient behind the hero and the philosophy block

A WebGL2 canvas, mounted twice (once per block), each owning its own context, with an
`IntersectionObserver` parking the loop while its section is off screen. The wrapper:

```html
<div aria-hidden="true" class="absolute inset-0 z-0 bg-background">
  <div class="absolute inset-0" style="background-image: var(--gradient-opalesce-fallback)"></div>   <!-- shown until the first frame; permanently if WebGL2 is absent -->
  <span class="absolute inset-0 block" style="opacity:0"><canvas class="pointer-events-none block h-full w-full"></canvas></span>   <!-- fades opacity 0→1 over 300ms once the first draw has landed -->
</div>
```

The palette lives in `CONFIG` and nowhere else — never edit the GLSL to change a colour; the ramp is
walked perceptually and the stops are placed against each other. The vertex shader, the fragment
shader and the engine, verbatim from the project:

```ts

/* ====== CONFIG — docs/opalesce.md § "CONFIG проекта brewns" ======
   Three values differ from the spec, all to make the pointer visible, all agreed:
     speed           0.07 → 0.12  (the flow, asked for a touch faster)
     flow            0.11 → 0.16  (asked for more churn — see below)
     warp            1.25 → 1.45
     cursor          0    → 1
     pointerRadius   0.75 → 0.3
     pointerStrength 0.15 → 1.2
     iridescence     0.1  → 0.22
     parallax        0.0015 → 0.01

   `speed` only reparametrises time — it scales `t = iTime * uSpeed` and nothing
   else — so the set of fields the shader can reach is unchanged and the
   worst-case contrast below is independent of it. It also leaves the frame
   pacing alone, so faster does not mean choppier.

   `flow` and `warp` are the two halves of "перетекание": `flow` is how fast the
   two warp channels drift past each other, `warp` is how far they drag the
   ground when they do. Both only move the coordinate the noise is sampled at,
   so the set of values `base` can take is the fbm range either way and the
   worst case is unchanged in principle — but the field visits it differently,
   so the sweep below was re-run rather than assumed.

   `pointerRadius` is what makes the touch read as a touch. uv is scaled by
   height, so 0.75 is a gaussian wider than the canvas: it washed the whole
   field evenly and nothing tracked the cursor. 0.3 is roughly a third of the
   frame height, so there is a soft patch centred on the pointer.
   `iridescence` is the gate, not `pointerStrength`: the film only reaches the
   composite through `mix(vec3(1.0), film * 1.25, uIridescence)`, so at 0.1 the
   touch tops out around 16/255 however hard it is pushed. Raising it costs
   headline contrast — measured at 7.29:1, against 7.46 before. Re-measure if
   either moves again; the floor is 7:1. Everything else is untouched. */
const CONFIG = {
  bgColor: "#070707",
  colorA: "#110b07",
  colorB: "#2c1a11",
  colorC: "#6e3e22",
  colorD: "#c69b6e",
  scale: 0.5,
  speed: 0.12,
  flow: 0.16,
  warp: 1.45,
  warpScale: 0.75,
  roughness: 0.45,
  lacunarity: 2,
  thickness: 1.2,
  iridescence: 0.22,
  spread: 0.28,
  sheen: 0.05,
  contrast: 1.35,
  midpoint: 0.54,
  glow: 0.08,
  sink: 0.35,
  grain: 0.045,
  grainAnim: 1,
  dither: 1.55,
  vignette: 0.26,
  cursor: 1,
  pointerRadius: 0.3,
  pointerStrength: 1.2,
  parallax: 0.01,
  maxDpr: 1.5,
};

const VERT = `#version 300 es
void main() {
  vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;

const FRAG = `#version 300 es
precision highp float;
out vec4 fragColor;

uniform vec2  iResolution;
uniform float iTime;
uniform vec2  iMouse;          // the body node — aspect-corrected, same space as uv
uniform vec2  iMouseVel;       // lead node minus body node — a velocity proxy
uniform vec2  iMouseWake;      // a third, much slower node — the tail of the trail

uniform vec3  uBgColor, uColorA, uColorB, uColorC, uColorD;
uniform float uScale, uSpeed, uFlow, uWarp, uWarpScale;
uniform float uRoughness, uLacunarity, uThickness, uIridescence, uSpread;
uniform float uSheen, uContrast, uMidpoint, uGlow, uSink;
uniform float uGrain, uDither, uVignette, uPointerRadius, uPointerStrength;
uniform float uParallax;

#define TRAIL_TAPS 6

vec2 hash2(vec2 p) {
  p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
  return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
}

float snoise(vec2 p) {
  const float K1 = 0.366025404, K2 = 0.211324865;
  vec2 i = floor(p + (p.x + p.y) * K1);
  vec2 a = p - i + (i.x + i.y) * K2;
  float m = step(a.y, a.x);
  vec2 o = vec2(m, 1.0 - m);
  vec2 b = a - o + K2;
  vec2 c = a - 1.0 + 2.0 * K2;
  vec3 h = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);
  vec3 n = h * h * h * h * vec3(dot(a, hash2(i)), dot(b, hash2(i + o)), dot(c, hash2(i + 1.0)));
  return dot(n, vec3(70.0));
}

vec2 rot(vec2 p, float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c) * p; }

/* THE TOUCH. The shader has no memory, so the tail is reconstructed from three
   pointer nodes: TRAIL_TAPS gaussians strung along the segment between the slow
   wake node and the body node, weighted toward the head. A resting cursor puts
   every tap in the same place and the result is one clean gaussian of exactly
   1.0 at the centre; a moving cursor stretches them into a tapered ribbon that
   melts away from the back as the wake catches up. No branch, no jump, and the
   falloff is the same gaussian at every point along it. */
float trail(vec2 uv, float radius) {
  /* The sample point is wobbled by a slow field first. A gaussian is a perfect
     circle, and a perfect circle parked under a resting cursor is the one thing
     that reads as drawn rather than felt; warping the coordinate by a fraction
     of the radius makes the touch an organic patch that breathes. */
  vec2 wob = vec2(snoise(uv * 1.7 + vec2(0.0, iTime * 0.05)),
                  snoise(uv * 1.7 + vec2(4.3, -iTime * 0.04)));
  uv += wob * radius * 0.22;
  float a = 0.0, wsum = 0.0;
  float r2 = max(1e-4, radius * radius);
  for (int i = 0; i < TRAIL_TAPS; i++) {
    float k = float(i) / float(TRAIL_TAPS - 1);
    vec2 c = mix(iMouseWake, iMouse, k);
    vec2 dd = uv - c;
    float w = mix(0.28, 1.0, k);
    a += w * exp(-dot(dd, dd) / r2);
    wsum += w;
  }
  return a / wsum;
}

vec3 ramp4(float t) {
  vec3 c = mix(uColorA, uColorB, smoothstep(0.00, 0.36, t));
  c = mix(c, uColorC, smoothstep(0.32, 0.70, t));
  c = mix(c, uColorD, smoothstep(0.66, 1.00, t));
  return c;
}

/* A SOFT SHOULDER instead of a clamp. clamp((f - mid) * contrast + 0.5) plateaus wherever
   the field runs past either end, and a plateau is a flat patch of pure colour with a
   visible edge around it — the loudest single way a gradient stops looking expensive.
   tanh has the same slope through the middle and saturates smoothly, so the highlights
   roll off and nothing ever reaches the rail. Same two knobs, no plateau. */
float tone(float x) {
  return 0.5 + 0.5 * tanh((x - uMidpoint) * uContrast * 2.2);
}

// triangular-PDF dither — the only reliable cure for 8-bit gradient banding
float triDither(vec2 fc) {
  float a = fract(sin(dot(fc, vec2(12.9898, 78.233))) * 43758.5453);
  float b = fract(sin(dot(fc + 17.0, vec2(12.9898, 78.233))) * 43758.5453);
  return (a + b - 1.0) / 255.0;
}

#define OCT 3

float fbm(vec2 p) {
  float v = 0.0, amp = 0.5;
  for (int i = 0; i < OCT; i++) { v += amp * snoise(p); p = p * uLacunarity + vec2(6.1, 2.7); amp *= uRoughness; }
  return v;
}


// ---- house grain. ONE look across the collection: an integer hash (no sin() streaks),
// triangular so it reads as film rather than static, weighted into the midtones so it
// never crusts a black or a white. Static by default; uGrainAnim re-seeds it 24×/s.
uniform float uGrainAnim;
float houseGrain(vec2 fc) {
  uvec2 q = uvec2(fc) * uvec2(1597334677u, 3812015801u)
          + uint(floor(iTime * 24.0 * uGrainAnim)) * 2654435769u;
  uint n = q.x ^ q.y; n = n * 1664525u + 1013904223u; n ^= n >> 16u; n *= 2246822519u; n ^= n >> 13u;
  float a = float(n & 0xffffu) / 65535.0;
  n *= 3266489917u; n ^= n >> 16u;
  float b = float(n & 0xffffu) / 65535.0;
  return a + b - 1.0;
}
void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution) / iResolution.y;
  float t = iTime * uSpeed;

  float tr = trail(uv, uPointerRadius);
  vec2 p = (uv - iMouse * uParallax) * uScale;

  // the two warp channels drift with opposite signs, so the ground churns instead of panning
  vec2 q = vec2(fbm(p * uWarpScale + vec2(0.0, t * uFlow)),
                fbm(p * uWarpScale + vec2(5.2, 1.3) - t * uFlow * 0.7));
  float base = fbm(p + uWarp * q + vec2(t * 0.12, -t * 0.09)) * 0.5 + 0.5;

  /* Film thickness: the ground, plus the touch. A change of a fraction of a wavelength is
     a whole hue step, which is why the trail reads as colour travelling out behind the
     hand rather than as a bright smear following it. */
  float thick = base * uThickness + tr * uPointerStrength;

  /* The three channels must run at genuinely different rates or the cosines stay in step
     and the film comes out grey — a tint you can only see in the numbers. A fifth of a
     cycle between red and blue is about where it starts reading as colour and still well
     short of a rainbow. */
  vec3 film = 0.5 + 0.5 * cos(6.28318 * (thick * vec3(1.0, 1.0 - uSpread * 0.18, 1.0 - uSpread * 0.36)
                                         + vec3(0.0, 0.08, 0.16)));

  float f = tone(base);

  vec3 col = ramp4(f);
  /* MULTIPLY, never add. A thin film only tints what is already behind it; an additive
     rainbow over a palette is the tell that gives most iridescence away. */
  col *= mix(vec3(1.0), film * 1.25, uIridescence);
  col += uColorD * (uGlow * pow(f, 4.0) + uSheen * pow(film.g, 6.0) * 0.25);
  col = mix(uBgColor, col, smoothstep(0.0, max(0.01, uSink), f) * 0.90 + 0.10);

  col *= 1.0 - uVignette * dot(uv, uv);
  { float hgL = clamp(dot(col, vec3(0.299, 0.587, 0.114)), 0.0, 1.0);
    col += houseGrain(gl_FragCoord.xy) * uGrain * mix(1.0, 4.0 * hgL * (1.0 - hgL), 0.6); }
  col += triDither(gl_FragCoord.xy) * uDither;

  fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}`;

/** Not in the spec's engine, which calls it without declaring it. */
const hexToVec3 = (h: string): [number, number, number] => {
  const s = h.replace("#", "");
  return [
    parseInt(s.slice(0, 2), 16) / 255,
    parseInt(s.slice(2, 4), 16) / 255,
    parseInt(s.slice(4, 6), 16) / 255,
  ];
};

export const OpalesceGradient = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const gl = canvas.getContext("webgl2", {
      alpha: false,
      antialias: false,
      depth: false,
      stencil: false,
      powerPreference: "high-performance",
    });
    // No WebGL2: the CSS fallback underneath stays visible and the page is fine.
    if (!gl) return;

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let program: WebGLProgram | null = null;
    let vertexShader: WebGLShader | null = null;
    let fragmentShader: WebGLShader | null = null;
    let vao: WebGLVertexArrayObject | null = null;
    let rafId = 0;
    let locations: Record<string, WebGLUniformLocation | null> = {};

    const loc = (n: string) => {
      if (!program) return null;
      if (!(n in locations)) locations[n] = gl.getUniformLocation(program, n);
      return locations[n];
    };
    // uniform2f(null, …) is safe — iMouseVel is declared but unused, so the
    // linker optimises it away and its location is legitimately null.
    const u1f = (n: string, v: number) => gl.uniform1f(loc(n), v);
    const u2f = (n: string, x: number, y: number) => gl.uniform2f(loc(n), x, y);
    const u3c = (n: string, hex: string) => {
      const c = hexToVec3(hex);
      gl.uniform3f(loc(n), c[0], c[1], c[2]);
    };

    const compile = (type: number, src: string) => {
      const sh = gl.createShader(type);
      if (!sh) throw new Error("createShader failed");
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        throw new Error(gl.getShaderInfoLog(sh) ?? "shader compile failed");
      }
      return sh;
    };

    /* ====== RESIZE — section-sized, per the spec's integration notes ====== */
    let dpr = 1;
    const resize = () => {
      dpr = Math.min(window.devicePixelRatio || 1, CONFIG.maxDpr);
      const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
      const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
      }
      gl.viewport(0, 0, w, h);
      gl.useProgram(program);
      u2f("iResolution", w, h);
    };

    /* ====== CONFIG → UNIFORMS ====== */
    const applyConfig = () => {
      gl.useProgram(program);
      u3c("uBgColor", CONFIG.bgColor);
      u3c("uColorA", CONFIG.colorA);
      u3c("uColorB", CONFIG.colorB);
      u3c("uColorC", CONFIG.colorC);
      u3c("uColorD", CONFIG.colorD);
      u1f("uScale", CONFIG.scale);
      u1f("uSpeed", CONFIG.speed);
      u1f("uFlow", CONFIG.flow);
      u1f("uWarp", CONFIG.warp);
      u1f("uWarpScale", CONFIG.warpScale);
      u1f("uRoughness", CONFIG.roughness);
      u1f("uLacunarity", CONFIG.lacunarity);
      u1f("uThickness", CONFIG.thickness);
      u1f("uIridescence", CONFIG.iridescence);
      u1f("uSpread", CONFIG.spread);
      u1f("uSheen", CONFIG.sheen);
      u1f("uContrast", CONFIG.contrast);
      u1f("uMidpoint", CONFIG.midpoint);
      u1f("uGlow", CONFIG.glow);
      u1f("uSink", CONFIG.sink);
      u1f("uGrain", CONFIG.grain);
      u1f("uGrainAnim", CONFIG.grainAnim);
      u1f("uDither", CONFIG.dither);
      u1f("uVignette", CONFIG.vignette);
      u1f("uPointerRadius", CONFIG.pointerRadius);
      u1f("uPointerStrength", CONFIG.pointerStrength);
      u1f("uParallax", CONFIG.parallax);
      resize();
    };

    const build = () => {
      if (program) gl.deleteProgram(program);
      if (vertexShader) gl.deleteShader(vertexShader);
      if (fragmentShader) gl.deleteShader(fragmentShader);
      if (vao) gl.deleteVertexArray(vao);
      locations = {};

      vertexShader = compile(gl.VERTEX_SHADER, VERT);
      fragmentShader = compile(gl.FRAGMENT_SHADER, FRAG);
      program = gl.createProgram();
      if (!program) throw new Error("createProgram failed");
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        throw new Error(gl.getProgramInfoLog(program) ?? "link failed");
      }
      gl.useProgram(program);
      vao = gl.createVertexArray();
      gl.bindVertexArray(vao);
    };

    const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3);

    /* ====== POINTER — a lead, a body and a wake, per the spec. The easing is
       the whole point: the pointer is felt, not tracked. ====== */
    const mouse: {
      x: number;
      y: number;
      ax: number;
      ay: number;
      wx: number;
      wy: number;
      tx: number;
      ty: number;
      rest?: { x: number; y: number };
    } = { x: 0, y: 0, ax: 0, ay: 0, wx: 0, wy: 0, tx: 0, ty: 0 };

    const aim = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      const a = rect.width / rect.height;
      mouse.tx = ((e.clientX - rect.left) / rect.width - 0.5) * a;
      mouse.ty = 0.5 - (e.clientY - rect.top) / rect.height;
    };

    /* ====== RENDER LOOP ====== */
    let visible = true;
    const io = new IntersectionObserver(
      (es) => {
        visible = es[0].isIntersecting;
      },
      { threshold: 0 },
    );
    io.observe(canvas);

    const t0 = performance.now();
    let prevT = t0;
    let clock = 0;
    let fpsT = t0;
    let fpsN = 0;

    const frame = (now: number) => {
      rafId = requestAnimationFrame(frame);

      const raw = now - prevT;
      prevT = now;
      if (!visible || document.hidden) return;
      const ms = raw > 50 ? 50 : raw < 4.167 ? 4.167 : raw;
      const s = ms > 36.7 ? 2.2 : ms * 0.06;
      clock += ms * 0.001;

      const kLead = 0.105 * s,
        kBody = 0.043 * s,
        kWake = 0.017 * s;
      mouse.ax += (mouse.tx - mouse.ax) * kLead;
      mouse.ay += (mouse.ty - mouse.ay) * kLead;
      mouse.x += (mouse.ax - mouse.x) * kBody;
      mouse.y += (mouse.ay - mouse.y) * kBody;
      mouse.wx += (mouse.x - mouse.wx) * kWake;
      mouse.wy += (mouse.y - mouse.wy) * kWake;

      u1f("iTime", clock);
      if (mouse.rest === undefined) mouse.rest = { x: mouse.tx, y: mouse.ty };
      if (!CONFIG.cursor) {
        mouse.tx = mouse.rest.x;
        mouse.ty = mouse.rest.y;
      }
      u2f("iMouse", mouse.x, mouse.y);
      u2f("iMouseVel", mouse.ax - mouse.x, mouse.ay - mouse.y);
      u2f("iMouseWake", mouse.wx, mouse.wy);
      draw();

      fpsN++;
      if (now - fpsT > 500) {
        fpsT = now;
        fpsN = 0;
      }
    };

    /* ====== RESIZE OBSERVER — coalesced to one call per frame ====== */
    let resizeQueued = false;
    const ro = new ResizeObserver(() => {
      if (resizeQueued) return;
      resizeQueued = true;
      requestAnimationFrame(() => {
        resizeQueued = false;
        resize();
        // Under reduced motion nothing else will ever redraw it.
        if (reduceMotion) draw();
      });
    });
    ro.observe(canvas);

    const onContextLost = (e: Event) => {
      e.preventDefault();
      cancelAnimationFrame(rafId);
    };
    const onContextRestored = () => {
      build();
      applyConfig();
      draw();
      if (!reduceMotion) rafId = requestAnimationFrame(frame);
    };
    canvas.addEventListener("webglcontextlost", onContextLost);
    canvas.addEventListener("webglcontextrestored", onContextRestored);

    if (CONFIG.cursor) {
      window.addEventListener("pointermove", aim, { passive: true });
      window.addEventListener("pointerdown", aim, { passive: true });
    }

    build();
    applyConfig();
    draw(); // one draw before the reveal, so we never fade onto a blank canvas
    setReady(true);
    if (!reduceMotion) rafId = requestAnimationFrame(frame);

    return () => {
      cancelAnimationFrame(rafId);
      ro.disconnect();
      io.disconnect();
      canvas.removeEventListener("webglcontextlost", onContextLost);
      canvas.removeEventListener("webglcontextrestored", onContextRestored);
      if (CONFIG.cursor) {
        window.removeEventListener("pointermove", aim);
        window.removeEventListener("pointerdown", aim);
      }
      if (program) gl.deleteProgram(program);
      if (vertexShader) gl.deleteShader(vertexShader);
      if (fragmentShader) gl.deleteShader(fragmentShader);
      if (vao) gl.deleteVertexArray(vao);
    };
  }, []);
```

Notes on that engine: `resize()` reads `canvas.clientWidth/clientHeight` (section-sized, not the
window) through a `ResizeObserver` coalesced to one call per frame; `aim()` listens on `window` so
the pointer is felt near the section's edges too; the frame interval is clamped to [4.167, 50] ms
and the shader clock accumulates from that clamped value, so a backgrounded tab costs a pause and
never a lurch; `CONFIG.cursor` is JS-only, not a uniform; `iMouseVel` is declared but unused, so its
location is legitimately `null`. DPR is capped at 1.5. Under `prefers-reduced-motion` it draws once
and only redraws on resize.

## 3 — The menu (the light half of the page)

```html
<section aria-label="Menu" id="menu" data-header-theme="light" class="relative flex flex-col w-full overflow-hidden lg:min-h-lvh bg-surface-inverse lg:h-lvh lg:max-xl:h-[calc(var(--size-board-height)+2.5rem)] lg:max-xl:min-h-0">
  <!-- SwirlFill direction="ltr" class="left-(--size-swirl-x) top-(--size-swirl-y) h-(--size-swirl-height) w-(--size-swirl-width)" -->
  <div class="relative z-10 flex flex-1 flex-col justify-center gap-8 px-5 pb-14 pt-20 max-sm:gap-9 sm:gap-10 sm:px-10 sm:pb-16 sm:pt-24 sm:max-lg:grid sm:max-lg:grid-cols-2 sm:max-lg:items-end sm:max-lg:gap-x-10 sm:max-lg:gap-y-5 lg:block lg:p-0 board:top-(--size-board-drop)">
    <!-- MenuIntro · MenuCta (receipt first, then the link) · the card row -->
  </div>
</section>
```

Paint order is the design's: swirl, heading, the receipt and its link, the card row on top.

### The heading column

```html
<div class="flex w-full flex-col gap-4 uppercase text-foreground-inverse max-sm:gap-5 sm:max-lg:contents sm:gap-5 lg:absolute lg:left-10 lg:top-(--size-menu-intro-y) lg:w-(--size-menu-intro)">
  <p class="font-mono text-fine leading-copy tracking-label sm:text-label sm:max-lg:col-span-2"><span class="tracking-slash">//</span><span class="tracking-slash-space"> </span>our menu</p>   <!-- Inview clipPath inset(0 100% 0 0)→inset(0 0 0 0) · 46/26 -->
  <h2 class="justify-start text-left font-display font-bold text-display-phone leading-headline tracking-tight max-sm:max-w-64 sm:text-display-tablet sm:max-lg:col-span-2 sm:max-lg:max-w-104 lg:text-display">FAVORITES MADE DAILY</h2>   <!-- TextEngine lines · rootMargin -25% -->
  <div class="w-full max-sm:max-w-[18.5rem] sm:max-w-80 lg:absolute lg:left-(--size-menu-lede-x) lg:top-(--size-menu-lede-y) lg:w-(--size-menu-lede)">
    <p class="w-full justify-start text-left font-display font-bold text-base leading-copy tracking-tight sm:max-lg:text-lede-tablet">Quality ingredients, carefully prepared. Simple, balanced and made to enjoy.</p>   <!-- TextEngine words · 620ms · stagger 14 · delayIn 320 · rootMargin -25% -->
  </div>
</div>
```

The lede is tucked into the gap the third heading line leaves free. This heading reveals by rising
and fading (the line recipe), not out of a clip box, because the block animates as it scrolls into
view and a half-cut glyph is what a reader would arrive on.

### VIEW FULL MENU and the receipt behind it

```html
<span aria-hidden="true" class="pointer-events-none absolute left-(--size-menu-receipt-x) top-(--size-menu-receipt-y) hidden h-(--size-menu-receipt-height) w-(--size-menu-receipt-width) lg:block" style="opacity:0; transform: translate(150px,150px)">
  <span class="block size-full" style="rotate: var(--angle-menu-receipt)"><img src="…/menu/menu-receipt.webp" alt="" width="832" height="1890" class="size-full object-cover"></span>
</span>
<span class="block sm:max-lg:justify-self-end lg:absolute lg:right-10 lg:top-(--size-menu-cta-y)">   <!-- Inview {opacity 0,y 12}→ · 90/26 · delayIn 420 -->
  <!-- CtaLink "view full menu" href="#menu" class="w-(--size-menu-cta) text-foreground-inverse" ruleClassName="bg-foreground-inverse" -->
</span>
```

Hovering (or focusing) the link springs the receipt `{ x: 150, y: 150, opacity: 0 } → { 0, 0, 1 }`,
config 120/26, and back on leave. Only the translation animates; the sheet's −31.2678° tilt is a
static `rotate` on the inner element, so it slides in from the bottom-right in screen space. The
receipt's box (278.58×632.85 at 1383.10,161.16) was solved from Figma's rotated-rect corner and
axis-aligned bbox against the asset's 832×1890 aspect.

### The card row

```html
<ul class="grid grid-cols-2 gap-3 max-sm:grid-cols-1 sm:gap-4 sm:max-lg:col-span-2 sm:max-lg:mt-11 lg:absolute lg:inset-x-10 lg:bottom-10 lg:top-auto lg:flex lg:gap-3 board:bottom-auto board:top-(--size-menu-cards-y)">
  <li class="flex flex-1"> … ×4 </li>
</ul>
```

One card, with `order` its index 0–3:

```html
<div class="flex flex-1" style="perspective:900px"><div class="flex flex-1" style="transform-style:preserve-3d">   <!-- PointerLean scope=self · 14° · 90/22 -->
<article class="group relative flex h-64 max-sm:h-[22.5rem] sm:max-lg:h-96 flex-1 flex-col items-center gap-3 border border-foreground-inverse p-3 text-foreground-inverse sm:h-72 sm:gap-4 sm:p-4 lg:h-(--size-menu-card-height)">   <!-- Inview {opacity 0, y 28}→ · 80/26 · delayIn order*90 -->
  <div aria-hidden="true" class="pointer-events-none absolute inset-0"></div>   <!-- the scrub's range: the card itself -->
  <p class="h-(--size-cap-12) w-full font-mono text-xs leading-flush uppercase text-foreground-inverse/35 transition-colors duration-[250ms] ease-entrance group-hover:text-foreground-inverse"><!-- DigitRoll "01" delayIn order*90+160 --></p>
  <div class="relative min-h-px w-full flex-1 {overflow-clip if clip}">
    <span class="block size-full"><span class="block size-full">   <!-- SpringTrigger scrub trigger=range start="top bottom" end="center center" from {y: 11} to {y: 0} -->
      <div class="absolute left-1/2 -translate-x-1/2 overflow-hidden" style="width: calc({frame.width/16}rem * var(--size-menu-still-scale)); max-width: var(--size-menu-still-max); aspect-ratio: {frame.width} / {frame.height}; bottom: calc({frame.bottom/16}rem + var(--size-menu-still-lift)); margin-left: {frame.offsetX/16}rem">
        <img src="…" alt="…" width height class="absolute max-w-none {object-cover if fit=cover}" style="top: {crop.top}; left: {crop.left}; width: {crop.width}; height: {crop.height}">
      </div>
    </span></span>
  </div>
  <div class="flex min-h-(--size-cap-16) w-full items-start justify-between gap-2 max-sm:items-baseline font-mono text-label leading-flush uppercase lg:text-base">
    <p>ESPRESSO</p>   <!-- Inview clipPath inset(0 100% -30% 0) → inset(0 0 -30% 0) · 58/26 · delayIn order*90+300 -->
    <p><!-- DigitRoll "$2.50" delayIn order*90+240 --></p>
  </div>
</article>
</div></div>
```

The still lags 11px behind its card as the row rises into view — the card arrives on one spring and
the photograph on a scroll scrub, so they separate and then close up. The four cards' data — the
`frame` is the box the still occupies inside the media area in board px, `crop` how the still is
cropped inside it:

| # | name | price | file (natural size) | frame `{w, h, bottom, offsetX}` | crop `{top, left, width, height}` | fit | clip |
|---|---|---|---|---|---|---|---|
| 01 | ESPRESSO | $2.50 | `menu-espresso.webp` 1216×1293 | 129, 156, 0, 0 | −12.54%, −27.55%, 145.17%, 128.38% | fill | yes |
| 02 | LATTE | $4.20 | `menu-latte.webp` 1024×1536 | 180, 205, 0, 0.5 | −16.55%, −0.03%, 100.07%, 132.36% | fill | yes |
| 03 | ICED COFFEE | $4.50 | `menu-iced-coffee.webp` 1024×1536 | 197, 261, 0, 0 | 0.1%, 2.54%, 94.92%, 107.62% | fill | **no** — it spills out of the card |
| 04 | CINNAMON ROLL | $3.80 | `menu-cinnamon.webp` 1536×1024 | 327, 218, −8, 0 | 0%, 0%, 100%, 100% | cover | yes |

Alt texts: "A brewns single-shot espresso cup", "A brewns latte cup with a heart poured into the
foam", "A brewns iced matcha in a clear cup with a straw", "A glazed cinnamon roll on a ceramic
plate".

### The swirl fill — behind the menu, the locations and the footer

The brand swirl is `swirl-mask.webp`, an **alpha mask** (its own pixels are near-white) painted with
a token, never shown as an image. Behind the two light blocks it **fills** with colour as the block
arrives, growing out of four round soft-rimmed blobs, and drifts on a scroll parallax; behind the
footer it simply fades in. Verbatim, the fill:

```ts

/** Where the fill sweeps from. */
export type SwirlFillDirection = "ltr" | "rtl";

/**
 * The blobs the colour grows out of, left-to-right order. Each is a circle
 * centred at `x`/`y` (percent of the swirl's box) that opens once `--fill`
 * passes `delay`.
 *
 * Every blob's rate is solved so that all of them reach the same radius
 * (`REACH`, past the box's diagonal) at the same moment, `--fill` 100. The
 * last patches of the shape therefore close from several sides at once while
 * the clock is easing out, instead of one late, fast blob slamming the rest of
 * the shape shut — which is what made the old ending look abrupt.
 */
const BLOBS = [
  { x: 0, y: 78, delay: 0 },
  { x: 30, y: 22, delay: 10 },
  { x: 58, y: 88, delay: 22 },
  { x: 92, y: 40, delay: 34 },
] as const;

/** Radius every blob ends at, percent of its gradient ray — well past full. */
const REACH = 115;

/** Width of each blob's soft rim, percent of the ray. Wide, so the edge
    dissolves rather than draws a line. */
const FEATHER = 14;

/**
 * The fill's timing. Not tied to the scroll: it plays once, on its own clock,
 * long and eased gently at both ends so the colour spreads and settles.
 */
const FILL = { duration: 3400, easing: easings.easeInOutSine };
const DELAY = 100;

/**
 * Share of the section that has to be on screen before the fill starts. The
 * section, not the swirl: the swirl is larger than the block and reaches up
 * into the one above, so watching the swirl itself fired while the page was
 * still under the preloader and the fill had finished before anyone saw it.
 */
const THRESHOLD = 0.12;

/**
 * Scroll parallax on the swirl, px: it drifts from `-PARALLAX` to `PARALLAX`
 * while its box crosses the screen, lagging the page like a background.
 */
const PARALLAX = 100;

/** Fades out the top of the section, so a swirl that runs past the section's
    top edge dissolves into it instead of being cut straight across. */
const FADE_TOP =
  "[mask-image:linear-gradient(to_bottom,transparent,black_22%)]";

const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

/**
 * The fill's own mask: one radial gradient per blob, all driven by `--fill`.
 * Mask layers add, so the blobs union; nested inside the swirl's mask, the
 * two multiply and the colour only ever lands inside the swirl.
 *
 * Exported for the preloader's GPU warm-up, which paints it at two fill values
 * so the GPU programs for both phases of these gradients exist before the
 * swirl first plays (`common/preloader/gpu-warmup.tsx`).
 */
export const blobMask = (direction: SwirlFillDirection) =>
  BLOBS.map(({ x, y, delay }) => {
    const cx = direction === "ltr" ? x : 100 - x;
    const rate = REACH / (100 - delay);
    const radius = `calc((var(--fill) - ${delay}) * ${rate}%)`;
    return `radial-gradient(circle at ${cx}% ${y}%, #000 calc(${radius} - ${FEATHER}%), transparent ${radius})`;
  }).join(", ");
```

In plain terms: a section-sized frame (with the optional top fade `mask-image: linear-gradient(to
bottom, transparent, black 22%)`) holds the parallax box (placed by the swirl tokens: 2235×745 at
132,−24), which drifts `translateY(-100px → 100px)` as its box crosses the screen (`top bottom` →
`bottom top`, bound to scroll); inside it a span wearing `mask-image: url(swirl-mask.webp)`
(`center / 100% 100% no-repeat`), and inside that a span painted `--surface-inverse-backdrop`
(`#e7e5e3`) wearing the **blob mask** — four `radial-gradient(circle at cx% y%, #000 calc(r - 14%),
transparent r)` layers with `r = calc((var(--fill) - delay) * 115 / (100 - delay))%`, blobs at
(0,78,0) (30,22,10) (58,88,22) (92,40,34), `cx` mirrored (`100 - x`) for `rtl`. `--fill` springs
0 → 100 on a duration of 3400ms `easeInOutSine` after a 100ms delay, once, when 12% of the
**section** is on screen and the page is ready; under reduced motion it is set to 100 outright.

## 4 — The locations (the clock face)

```html
<section aria-label="Our locations" id="locations" data-header-theme="light" class="relative flex flex-col w-full overflow-hidden lg:min-h-lvh bg-surface-inverse lg:h-lvh lg:max-xl:h-(--size-board-height) lg:max-xl:min-h-0">
  <!-- SwirlFill direction="rtl" fadeTop — the menu block fills left→right, this one right→left, so the two read as one stroke carried on -->
  <div class="relative z-10 flex flex-1 flex-col justify-center gap-8 px-5 pb-14 pt-20 sm:gap-10 sm:px-10 sm:pb-16 sm:pt-24 sm:max-lg:pb-10 max-lg:grid max-lg:grid-cols-2 max-lg:items-start max-sm:gap-y-10 lg:block lg:p-0 board:top-(--size-board-drop)">
    <!-- LocationsDial · LocationsInfo -->
  </div>
</section>
```

### The dial and the cup

```html
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div>                        <!-- range: the whole section -->
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-1/5 h-full"></div>     <!-- turnRange: the same box lifted by a fifth -->
<div class="relative mx-auto aspect-square w-full max-w-80 sm:max-w-[26rem] max-lg:order-3 max-lg:col-span-2 sm:max-lg:max-w-[34rem] lg:contents">
  <figure class="pointer-events-none absolute inset-0 lg:inset-auto lg:left-1/2 lg:top-(--size-locations-dial-y) lg:size-(--size-locations-dial) lg:-translate-x-1/2">   <!-- Inview {opacity 0}→{1} · 60/26 -->
    <span class="block size-full"><span class="block size-full">   <!-- SpringTrigger scrub trigger=range "top bottom"→"center center" {y: 26}→{y: 0} -->
      <img src="…/locations/clock.webp" alt="A clock face marking the hours" width="1308" height="1308" class="size-full object-cover">
    </span></span>
  </figure>
  <div aria-hidden="true" class="pointer-events-none absolute left-1/2 top-[12.8%] h-[64.2%] w-[42.8%] -translate-x-1/2 [perspective:900px] lg:top-(--size-locations-cup-y) lg:h-(--size-locations-cup-height) lg:w-(--size-locations-cup-width)">
    <div class="size-full [transform-style:preserve-3d]">   <!-- the lean: rotateX/rotateY spring 90/22 -->
      <span class="block size-full"><span class="block size-full">   <!-- parallax: scrub trigger=range "top bottom"→"center center" {y: 80}→{y: 0} -->
        <span class="block size-full"><span class="block size-full">   <!-- turn: scrub trigger=turnRange "center center"→"bottom top" {rotate: "0deg"}→{rotate: "38deg"} -->
          <img src="…/locations/cup.webp" alt="A brewns iced latte in a clear cup" width="1024" height="1536" class="size-full object-cover">
        </span></span>
      </span></span>
    </div>
  </div>
</div>
```

Three motions on three nested elements so none fights the others for `transform`: the **lean**
follows the pointer across the whole section (`rotateY = clamp(dx) * 14`, `rotateX = -clamp(dy) *
14`, dx/dy the pointer in the section's box mapped to −1…1; back to 0 on leave; off below 768px);
the **parallax** runs while the block rises into view and resolves to zero at `center center`; the
**turn** runs while the block leaves upward, from 0° to **38°** (clockwise from 12; it must stay at
or below 90°), measured on the lifted box so it starts a fifth of a screen sooner. The dial and the
cup drift at different rates (26px vs 80px) — that difference is the parallax.

### The three text groups

```html
<p class="order-first flex items-center gap-1.5 font-mono text-label leading-copy tracking-tight text-foreground-inverse sm:text-base max-lg:order-1 max-lg:col-start-1 lg:absolute lg:left-10 lg:top-(--size-locations-dial-y)"><!-- PulseDot size-(--size-dot) bg-accent -->OUR LOCATIONS</p>   <!-- Inview clipPath inset(0 100% 0 0)→ · 46/26 -->
<ul class="grid grid-cols-1 gap-5 font-mono text-base leading-copy tracking-tight uppercase text-foreground-inverse sm:grid-cols-3 max-lg:order-4 max-lg:col-span-2 lg:absolute lg:bottom-9.75 lg:left-10 lg:top-auto lg:flex board:bottom-auto board:top-(--size-locations-list-y) lg:w-(--size-locations-list) lg:flex-col lg:gap-6">
  <li><address class="not-italic"><span class="block">139 Coffee Street</span><span class="block">San Francisco, CA</span></address></li>   <!-- Inview clipPath · 80/26 · delayIn 160 + i*110 -->
  <li>…310 Valencia Street / San Francisco, CA</li>
  <li>…56 Columbus Avenue / San Francisco, CA</li>
</ul>
<p class="font-mono text-base leading-copy tracking-tight uppercase text-foreground-inverse max-lg:order-2 max-lg:col-start-2 max-lg:justify-self-end max-lg:text-right lg:absolute lg:bottom-6 lg:right-10 lg:top-auto lg:w-(--size-hours) lg:text-right board:bottom-auto board:top-(--size-locations-hours-y)"><span class="block">OPEN DAILY</span><span class="block">07:00 - 21:00</span></p>   <!-- Inview clipPath · 46/26 · delayIn 280; DigitRoll delayIn 360 -->
```

## 5 — The philosophy block

```html
<section aria-label="Our philosophy" id="story" data-header-theme="dark" class="relative flex flex-col w-full overflow-hidden lg:min-h-lvh bg-background lg:h-lvh lg:max-xl:h-(--size-board-height) lg:max-xl:min-h-0">
  <!-- Opalesce, z-0 -->
  <div aria-hidden="true" class="pointer-events-none absolute inset-0 z-1" style="background-image: var(--philosophy-scrim)"></div>
  <div aria-hidden="true" class="pointer-events-none absolute inset-0 z-5 transform-gpu backface-hidden"><!-- the three.js canvas --></div>
  <div class="relative z-10 flex flex-1 flex-col justify-center gap-8 px-5 pb-14 pt-20 max-sm:gap-5 sm:gap-10 sm:px-10 sm:pb-16 sm:pt-24 sm:max-lg:grid sm:max-lg:grid-cols-[21rem_1fr] sm:max-lg:content-center sm:max-lg:items-end sm:max-lg:gap-x-5 lg:absolute lg:inset-0 lg:block lg:p-0 board:bottom-0 board:top-(--size-board-drop)">
    <figure data-philosophy-cup class="pointer-events-none order-2 mx-auto aspect-2/3 h-auto w-52 max-sm:mt-5 max-sm:-mb-3 max-sm:w-64 sm:w-64 sm:max-lg:col-span-2 sm:max-lg:w-88 lg:absolute lg:left-(--size-philosophy-cup-x) lg:top-(--size-philosophy-cup-y) lg:order-none xl:max-board:bottom-[0.3125rem] xl:max-board:top-auto xl:max-board:h-[calc(var(--size-philosophy-cup-height)*1.09)] xl:max-board:w-[calc(var(--size-philosophy-cup-width)*1.09)] lg:h-(--size-philosophy-cup-height) lg:w-(--size-philosophy-cup-width)">
      <span role="img" aria-label="A branded brewns takeaway cup, tipped to one side among drifting coffee beans" class="block size-full"></span>
    </figure>
    <!-- PhilosophyCopy -->
  </div>
</section>
```

The `figure` is the board's cup box, **empty** — the layout places it and the scene draws the cup
over it, projecting its rectangle into the world on every resize. The copy, all top-anchored on the
board:

```html
<div class="order-1 w-full sm:max-lg:col-span-2 sm:max-lg:max-w-[38rem] lg:absolute lg:left-10 lg:top-(--size-philosophy-headline-y) lg:w-(--size-philosophy-headline)">
  <h2 class="justify-start text-left font-display font-semibold text-headline-phone leading-headline tracking-tight uppercase text-foreground sm:text-headline-tablet lg:text-headline">THE CITY NEVER STOPS. YOUR COFFEE SHOULDN'T SLOW YOU DOWN.</h2>   <!-- TextEngine lines · rootMargin -25% -->
</div>
<div class="order-3 w-full lg:absolute lg:bottom-9.75 lg:max-board:bottom-10.25 lg:left-(--size-philosophy-lede-x) lg:top-auto lg:w-(--size-philosophy-lede) board:bottom-auto board:top-(--size-philosophy-lede-y)">
  <p class="justify-start text-left font-display font-bold text-base leading-copy tracking-tight uppercase text-foreground sm:max-lg:text-lede-tablet">Stay for a conversation or leave with the cup in your hand — both work, neither takes any effort. Nothing here is rushed, and nothing keeps you longer than you meant to stay.</p>   <!-- TextEngine words · 620ms · 14 · delayIn 220 -->
</div>
<ul class="order-4 flex flex-col gap-0.5 max-sm:hidden font-mono text-label leading-copy tracking-tight uppercase text-foreground sm:max-lg:justify-self-end sm:max-lg:text-right lg:absolute lg:bottom-10.25 lg:right-10 lg:top-auto lg:w-(--size-philosophy-claims) lg:text-right board:bottom-auto board:top-(--size-philosophy-claims-y)">
  <li>Good coffee.</li><li>NO CEREMONY.</li><li>NO WAITING.</li>   <!-- Inview clipPath inset(0 100% 0 0)→ · 80/26 · delayIn 340 + i*90 -->
</ul>
<p class="order-0 font-mono text-label leading-copy tracking-label uppercase text-foreground sm:max-lg:col-span-2 lg:absolute lg:bottom-7.5 lg:left-10 lg:max-board:bottom-10.25 lg:top-auto board:bottom-auto board:top-(--size-philosophy-eyebrow-y)"><span class="tracking-slash">//</span><span class="tracking-slash-space"> </span>OUR Philosophy</p>   <!-- Inview {opacity 0,y 10}→ · 90/26 · delayIn 420 -->
```

### The scene — `philosophy-scene.tsx` and `bean-field.ts`, verbatim

Mount as a function taking the canvas host, the section and the `[data-philosophy-cup]` figure;
`useLoop` is a ticker subscription at gap 0; `DRACO_PATH` and `src` are the CDN decoder and the
bucket model as in the hero. The cup scene is the one whose every material name starts with
`"CupCoffee"`; the bean is the mesh with material `"COFFEE_MAT"`.

```ts

/* ════════════ TUNING — everything adjustable lives here ════════════ */

/** The camera. A moderate lens: enough convergence for the beans' depth to
    read, not so much that the cup keystones at the frame's edge. */
const VIEW = { fov: 30, distance: 20 };

/**
 * The depth band the beans float in, world z. The cup stands at 0 and is about
 * 1.7 units deep at its widest, so the band starts behind its back face: the
 * beans drift behind the cup and never through it.
 */
const DEPTH = { near: -3, far: -12 };

const BEANS = {
  /** How many, per tier — a phone pays for every one in fill and physics. */
  desktop: 34,
  mobile: 16,
  /** A bean's length at scale 1, as a fraction of the view's height at the
      cup. The beans stand further back than the cup, so this runs large to
      keep them the size they read at on screen. */
  length: 0.07,
  /** Scale range. Log-uniform, so small and large are equally common. */
  scale: { min: 0.55, max: 1.7 },
};

const CUP = {
  /** Turns the printed wordmark to the lens. */
  yaw: Math.PI + 1.2,
  /** Lean to the right, radians. */
  tilt: 0.21,
  /** Height against the board's cup box. Under 1: the takeaway cup is much
      wider than the photographed one, so at the box's full height it crowded
      the headline. */
  scale: 0.92,
  /** How far below the box's bottom the cup's foot sits, as a share of its
      height — the part the section's bottom edge cuts off. */
  drop: 0.2,
  /** Radius of the capsule the beans bounce off, as a share of its height. */
  capsule: 0.36,
  /** How far it turns toward the pointer, radians. */
  lean: { yaw: 0.26, pitch: 0.1 },
  /** How quickly it follows, per second. */
  follow: 4,
  /** Turn about its own (tilted) vertical axis across the section's scroll,
      radians, centred so the wordmark faces the lens mid-section. */
  spin: Math.PI * 1.2,
  /** How quickly the turn catches up with the scroll, per second. */
  spinFollow: 6,
  /** Entrance: it rises this share of its height into place, at this rate. */
  rise: 1.1,
  riseRate: 2.4,
};

/**
 * The light: a baked room for reflections plus a warm key and a rim, so the
 * beans' roasted surface and the cup's wordmark both read on the dark field.
 */
const LIGHT = {
  exposure: 1.15,
  environment: 0.55,
  key: { colour: 0xfff1e0, intensity: 2.4, position: [-5, 6, 7] },
  rim: { colour: 0xffd9b0, intensity: 1.6, position: [5, 3, -6] },
} as const;

/** Far beans sink into the section's own ground (`--color-background`). */
const FOG = { colour: 0x070707, start: 4, end: 24 };

/**
 * The scroll vortex. Scrolling sets the air wheeling about the view's centre,
 * the beans' depth band midway: `strength` is the swirl at `fullSpeed` px/s
 * of scroll (world units per second squared), and `follow` how quickly it
 * builds and dies away, per second. Scrolling down turns it one way, up the
 * other.
 */
const VORTEX = { strength: 2.4, fullSpeed: 1600, follow: 3 };

/** The cursor's movement fades from the air this fast once it stops, per second. */
const POINTER_SETTLE = 8;
/** Caps the cursor speed handed to the physics, world units per second. */
const POINTER_MAX_SPEED = 24;

/** Frame budget on a phone: the physics is fixed-step, so this only thins
    the drawing, not the motion. */
const MOBILE_FRAME = 1000 / 30;

/** What the file's meshes are told apart by. */
const CUP_MATERIAL = "CupCoffee";
const BEAN_MATERIAL = "COFFEE_MAT";

const DRACO_PATH = "/draco/";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

/* ═══════════════════════════════════════════════════════════════════ */

const materialsOf = (mesh: Mesh): Material[] =>
  Array.isArray(mesh.material) ? mesh.material : [mesh.material];

const meshesOf = (root: Object3D) => {
  const found: Mesh[] = [];
  root.traverse((node) => {
    if ((node as Mesh).isMesh) found.push(node as Mesh);
  });
  return found;
};

/** A scene holding the cup and nothing else. */
const isCupOnly = (root: Object3D) => {
  const meshes = meshesOf(root);
  return (
    meshes.length > 0 &&
    meshes.every((mesh) =>
      materialsOf(mesh).every((material) =>
        material.name.startsWith(CUP_MATERIAL),
      ),
    )
  );
};

const disposeTree = (root: Object3D) => {
  for (const mesh of meshesOf(root)) {
    mesh.geometry.dispose();
    for (const material of materialsOf(mesh)) {
      for (const value of Object.values(material)) {
        if ((value as Texture | null)?.isTexture) (value as Texture).dispose();
      }
      material.dispose();
    }
  }
};

export interface PhilosophySceneProps {
  /** The .glb holding the cup alone in one scene and a bean in another. */
  src: string;
}

/**
 * The philosophy block's 3D layer: coffee beans floating weightless across
 * the whole section, and the hero's takeaway cup standing where the board puts
 * its cup, leaning right and cut by the section's bottom edge.
 *
 * One canvas for both, so the beans bounce off the cup rather than passing
 * through it. It sits over the scrim and under the copy. The cup's place comes
 * from the layout, not from here: the section marks the board's cup box with
 * `CUP_ANCHOR`, and the scene projects that box into the world on every resize.
 *
 * The beans are one instanced mesh — one draw call however many there are —
 * and the physics lives in `bean-field.ts`. Drawing runs off the app's shared
 * ticker and stops when the section is off screen or the tab is hidden. Under
 * reduced motion nothing moves: the beans hang where they were scattered and
 * the cup stands in place.
 */
export const PhilosophyScene = ({ src }: PhilosophySceneProps) => {
  const host = useRef<HTMLDivElement>(null);
  const frame = useRef<((time: number) => void) | null>(null);

  useLoop((time) => frame.current?.(time), { framerate: 0 });

  useEffect(() => {
    const mount = host.current;
    const section = mount?.closest("section");
    if (!mount || !section) return;
    const mark = section.querySelector<HTMLElement>(`[${CUP_ANCHOR}]`);

    // Tiered once: a device does not change what it is mid-visit.
    const mobile =
      window.innerWidth < 768 ||
      window.matchMedia("(hover: none) and (pointer: coarse)").matches;
    const reduced = window.matchMedia(REDUCED_MOTION_QUERY).matches;

    const canvas = document.createElement("canvas");
    canvas.style.width = "100%";
    canvas.style.height = "100%";
    canvas.style.display = "block";
    mount.appendChild(canvas);

    let renderer: WebGLRenderer;
    try {
      renderer = new WebGLRenderer({
        canvas,
        alpha: true,
        antialias: !mobile,
        stencil: false,
        powerPreference: mobile ? "default" : "high-performance",
      });
    } catch {
      // No WebGL: the section stands without its 3D layer.
      canvas.remove();
      return;
    }

    renderer.setClearAlpha(0);
    renderer.outputColorSpace = SRGBColorSpace;
    renderer.toneMapping = NeutralToneMapping;
    renderer.toneMappingExposure = LIGHT.exposure;

    const scene = new Scene();
    scene.fog = new Fog(
      FOG.colour,
      VIEW.distance + FOG.start,
      VIEW.distance + FOG.end,
    );
    const camera = new PerspectiveCamera(VIEW.fov, 1, 1, 60);
    camera.position.set(0, 0, VIEW.distance);
    camera.lookAt(0, 0, 0);

    const pmrem = new PMREMGenerator(renderer);
    const room = new RoomEnvironment();
    const environment = pmrem.fromScene(room, 0.04);
    room.dispose();
    scene.environment = environment.texture;
    scene.environmentIntensity = LIGHT.environment;

    for (const { colour, intensity, position } of [LIGHT.key, LIGHT.rim]) {
      const light = new DirectionalLight(colour, intensity);
      light.position.set(position[0], position[1], position[2]);
      scene.add(light);
    }

    /** Carries the cup's place on the page, its size, tilt and lean. Its
        content is a unit-tall cup centred on the origin. */
    const cup = new Group();
    cup.visible = false;
    scene.add(cup);
    /** The scroll turn, under the tilt so it spins the cup about its own
        axis rather than the world's vertical. */
    const spin = new Group();
    cup.add(spin);
    let turned = 0;

    const tanHalf = Math.tan((VIEW.fov * Math.PI) / 360);
    const view: FieldView = {
      distance: VIEW.distance,
      tanHalf,
      aspect: 1,
      near: DEPTH.near,
      far: DEPTH.far,
    };
    const world: FieldWorld = {
      view,
      pointer: {
        active: false,
        origin: new Vector3(),
        direction: new Vector3(),
        velocity: new Vector3(),
      },
      obstacle: null,
      vortex: {
        centre: new Vector3(0, 0, (DEPTH.near + DEPTH.far) / 2),
        strength: 0,
      },
    };
    const capsule = { a: new Vector3(), b: new Vector3(), radius: 0 };
    /** Last frame's scroll position, for the vortex; null after a gap. */
    let lastScroll: number | null = null;

    /** Where the board's cup box lands in the world, from the last fit. */
    const place = { x: 0, bottom: 0, height: 0, shown: false };
    /** The pointer across the section, -1..1 each way, for the cup's lean. */
    const aim = { x: 0, y: 0 };
    const lean = { yaw: 0, pitch: 0 };
    let rise = reduced ? 0 : 1;

    let field: Field | null = null;
    let beans: InstancedMesh | null = null;
    let cupReady = false;
    let ready = false;
    let disposed = false;
    let dirty = true;
    let onScreen = false;
    let last = 0;
    let lastDraw = 0;
    const loaded: Object3D[] = [];

    const poseCup = () => {
      cup.visible = cupReady && place.shown;
      cup.scale.setScalar(place.height || 1);
      cup.position.set(
        place.x,
        place.bottom + place.height / 2 - rise * place.height * CUP.rise,
        0,
      );
      cup.rotation.set(lean.pitch, lean.yaw, -CUP.tilt);
      cup.updateMatrixWorld();

      if (!cup.visible) {
        world.obstacle = null;
        return;
      }
      // The capsule runs up the cup's own axis, so it tilts and leans with it.
      capsule.radius = place.height * CUP.capsule;
      capsule.a.set(0, -0.5 + CUP.capsule, 0).applyMatrix4(cup.matrixWorld);
      capsule.b.set(0, 0.5 - CUP.capsule, 0).applyMatrix4(cup.matrixWorld);
      world.obstacle = capsule;
    };

    let fittedWidth = 0;
    const fit = () => {
      const width = mount.clientWidth;
      const height = mount.clientHeight;
      if (!width || !height) return;
      // On touch, a height-only change is the URL bar folding away; rebuilding
      // the framebuffer for it flashes the canvas mid-scroll.
      if (mobile && fittedWidth === width && ready) return;
      fittedWidth = width;

      camera.aspect = width / height;
      camera.updateProjectionMatrix();
      view.aspect = camera.aspect;
      renderer.setPixelRatio(
        Math.min(window.devicePixelRatio, mobile ? 1 : 1.5),
      );
      renderer.setSize(width, height, false);

      if (mark) {
        const box = mount.getBoundingClientRect();
        const cell = mark.getBoundingClientRect();
        place.shown = cell.height > 0;
        if (place.shown) {
          // World units per pixel at the cup's depth, where z is 0.
          const unit = (2 * VIEW.distance * tanHalf) / height;
          place.height = cell.height * unit * CUP.scale;
          place.x = (cell.left + cell.width / 2 - box.left - width / 2) * unit;
          place.bottom =
            (box.top + height / 2 - cell.bottom) * unit -
            place.height * CUP.drop;
        }
      }
      poseCup();
      dirty = true;
    };

    const matrix = new Matrix4();
    const scale = new Vector3();
    const writeBeans = () => {
      if (!field || !beans) return;
      field.beans.forEach((bean, i) => {
        matrix.compose(bean.position, bean.rotation, scale.setScalar(bean.size));
        beans?.setMatrixAt(i, matrix);
      });
      beans.instanceMatrix.needsUpdate = true;
    };

    const ray = new Vector3();
    frame.current = (time) => {
      if (!ready || !onScreen || document.hidden) {
        last = 0;
        lastScroll = null;
        return;
      }
      if (mobile && time - lastDraw < MOBILE_FRAME) return;
      const dt = last ? Math.min(0.05, (time - last) / 1000) : 0;
      last = time;
      lastDraw = time;

      if (!reduced && dt > 0) {
        const { pointer } = world;
        if (pointer.active) {
          ray
            .set(aim.x, aim.y, 0.5)
            .unproject(camera)
            .sub(camera.position)
            .normalize();
          pointer.origin.copy(camera.position);
          pointer.direction.copy(ray);
        }
        pointer.velocity.multiplyScalar(Math.exp(-POINTER_SETTLE * dt));

        const follow = 1 - Math.exp(-CUP.follow * dt);
        const aimed = pointer.active ? 1 : 0;
        lean.yaw += (aim.x * aimed * CUP.lean.yaw - lean.yaw) * follow;
        lean.pitch += (-aim.y * aimed * CUP.lean.pitch - lean.pitch) * follow;
        rise *= Math.exp(-CUP.riseRate * dt);

        // Scroll turn: 0 as the section's top enters, 1 as its bottom leaves.
        const box = section.getBoundingClientRect();
        const travel = Math.min(
          1,
          Math.max(
            0,
            (window.innerHeight - box.top) / (window.innerHeight + box.height),
          ),
        );
        turned +=
          ((travel - 0.5) * CUP.spin - turned) *
          (1 - Math.exp(-CUP.spinFollow * dt));
        spin.rotation.y = turned;
        poseCup();

        // Scroll speed → vortex, eased so it builds and dies away smoothly.
        const scrollNow = window.scrollY;
        const scrollSpeed =
          lastScroll === null ? 0 : (scrollNow - lastScroll) / dt;
        lastScroll = scrollNow;
        const swirl =
          Math.max(-1, Math.min(1, scrollSpeed / VORTEX.fullSpeed)) *
          VORTEX.strength;
        world.vortex.strength +=
          (swirl - world.vortex.strength) * (1 - Math.exp(-VORTEX.follow * dt));

        if (field) stepField(field, dt, world);
        dirty = true;
      }

      if (!dirty) return;
      writeBeans();
      renderer.render(scene, camera);
      dirty = false;
    };

    const draco = new DRACOLoader().setDecoderPath(DRACO_PATH);
    const loader = new GLTFLoader().setDRACOLoader(draco);

    loader.load(
      src,
      (gltf) => {
        if (disposed) {
          gltf.scenes.forEach(disposeTree);
          return;
        }
        loaded.push(...gltf.scenes);

        const cupScene = gltf.scenes.find(isCupOnly);
        if (cupScene) {
          cupScene.rotation.set(0, CUP.yaw, 0);
          cupScene.updateWorldMatrix(true, true);
          const box = new Box3().setFromObject(cupScene);
          const size = box.getSize(new Vector3());
          cupScene.position.sub(box.getCenter(new Vector3()));
          const unit = new Group();
          unit.scale.setScalar(1 / (size.y || 1));
          unit.add(cupScene);
          spin.add(unit);
          cupReady = true;
        }

        const bean = gltf.scenes
          .flatMap(meshesOf)
          .find((mesh) =>
            materialsOf(mesh).some((material) => material.name === BEAN_MATERIAL),
          );
        if (bean) {
          // Baked to a unit-long bean centred on its own middle, so every
          // instance scales and turns about the right point.
          bean.updateWorldMatrix(true, false);
          const geometry = bean.geometry.clone().applyMatrix4(bean.matrixWorld);
          geometry.computeBoundingBox();
          const bounds = geometry.boundingBox ?? new Box3();
          const centre = bounds.getCenter(new Vector3());
          const longest = Math.max(...bounds.getSize(new Vector3()).toArray()) || 1;
          geometry.translate(-centre.x, -centre.y, -centre.z);
          geometry.scale(1 / longest, 1 / longest, 1 / longest);

          beans = new InstancedMesh(
            geometry,
            bean.material,
            mobile ? BEANS.mobile : BEANS.desktop,
          );
          // The physics moves every instance; the shared bounds would not.
          beans.frustumCulled = false;
          scene.add(beans);
        }

        fit();
        if (beans) {
          field = createField(
            beans.count,
            {
              base: BEANS.length * 2 * VIEW.distance * tanHalf,
              ...BEANS.scale,
            },
            world,
          );
          writeBeans();
        }

        // Prewarm, while the section is still far off screen: upload every
        // texture, link every program, then draw one real frame.
        //
        // Linking alone was not enough — measured on a cold GPU cache, the
        // first draw on arrival still stalled 225-425 ms: three checks the link
        // status synchronously on first use, and the GPU builds its pipeline
        // for the draw, not the link. The same first draw also allocated the
        // canvas's drawing buffer (~70 ms). `compileAsync` waits on the parallel
        // compile without blocking the thread, and the throwaway frame then
        // takes both costs here instead of mid-scroll. The cup is posed at rest
        // for it, so it is inside the frustum and actually drawn.
        for (const mesh of meshesOf(scene)) {
          for (const material of materialsOf(mesh)) {
            for (const value of Object.values(material)) {
              if ((value as Texture | null)?.isTexture)
                renderer.initTexture(value as Texture);
            }
          }
        }
        renderer.compileAsync(scene, camera).then(() => {
          if (disposed) return;
          const entering = rise;
          rise = 0;
          poseCup();
          renderer.render(scene, camera);
          rise = entering;
          poseCup();
          ready = true;
          dirty = true;
        });
      },
      undefined,
      (error) => {
        console.error("philosophy scene", error);
      },
    );

    let queued = 0;
    const observer = new ResizeObserver(() => {
      cancelAnimationFrame(queued);
      queued = requestAnimationFrame(fit);
    });
    observer.observe(mount);

    const watcher = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        dirty = true;
      },
      { rootMargin: "10% 0px" },
    );
    watcher.observe(mount);

    const onVisible = () => {
      dirty = true;
    };
    document.addEventListener("visibilitychange", onVisible);
    canvas.addEventListener("webglcontextrestored", onVisible);

    /* The cursor. Nothing reacts until it has actually moved — an unmoved
       pointer would otherwise sit dead centre and punch a hole in the field. */
    let lastMove = { x: 0, y: 0, t: 0 };
    const scratch = new Vector3();
    const onMove = (event: PointerEvent) => {
      const box = mount.getBoundingClientRect();
      aim.x = ((event.clientX - box.left) / box.width) * 2 - 1;
      aim.y = -(((event.clientY - box.top) / box.height) * 2 - 1);

      const elapsed = (event.timeStamp - lastMove.t) / 1000;
      if (world.pointer.active && elapsed > 0 && elapsed < 0.1) {
        const unit = (2 * VIEW.distance * tanHalf) / box.height;
        scratch.set(
          ((event.clientX - lastMove.x) / elapsed) * unit,
          -((event.clientY - lastMove.y) / elapsed) * unit,
          0,
        );
        world.pointer.velocity.lerp(scratch, 0.5).clampLength(0, POINTER_MAX_SPEED);
      }
      lastMove = { x: event.clientX, y: event.clientY, t: event.timeStamp };
      world.pointer.active = true;
    };
    const onLeave = () => {
      world.pointer.active = false;
      aim.x = 0;
      aim.y = 0;
    };

    section.addEventListener("pointermove", onMove, { passive: true });
    section.addEventListener("pointerdown", onMove, { passive: true });
    section.addEventListener("pointerleave", onLeave, { passive: true });
    section.addEventListener("pointercancel", onLeave, { passive: true });

    return () => {
      disposed = true;
      frame.current = null;
      cancelAnimationFrame(queued);
      observer.disconnect();
      watcher.disconnect();
      document.removeEventListener("visibilitychange", onVisible);
      canvas.removeEventListener("webglcontextrestored", onVisible);
      section.removeEventListener("pointermove", onMove);
      section.removeEventListener("pointerdown", onMove);
      section.removeEventListener("pointerleave", onLeave);
      section.removeEventListener("pointercancel", onLeave);
      beans?.geometry.dispose();
      beans?.dispose();
      loaded.forEach(disposeTree);
      draco.dispose();
      environment.texture.dispose();
      pmrem.dispose();
      renderer.dispose();
      canvas.remove();
    };
  }, [src]);
```

The physics, `bean-field.ts`, verbatim (`Vector3` and `Quaternion` from three):

```ts

import { Quaternion, Vector3 } from "three";

/**
 * The physics of the beans floating behind the philosophy block.
 *
 * Plain rigid-body mechanics, no library: each bean is a sphere for collision
 * purposes with a mass that grows with the cube of its size, so a large bean is
 * sluggish and a small one is flicked away. There is no gravity — the beans are
 * weightless — so what keeps them moving is a slow wander of the air around
 * them, and what keeps them from drifting forever is a light drag. The cursor
 * pushes the air out of its way; bean-on-bean and bean-on-cup hits are elastic
 * with surface friction, which is what turns a glancing hit into spin.
 *
 * Units are the scene's world units and seconds. The view at the cup's depth
 * is about eleven units tall.
 */
export const FIELD = {
  /** Fixed physics step. Rendering runs at whatever the display gives; the
      simulation always advances in these slices, so it behaves the same at
      30, 60 or 120 fps. */
  step: 1 / 120,
  /** Most slices one frame may owe — a stalled frame is dropped, not replayed. */
  maxSteps: 8,
  /** The slow drift each bean wanders on, as an acceleration. It is the air
      moving, so it ignores mass. */
  wander: 0.16,
  /** How fast that drift turns, in radians per second, lowest to highest. */
  wanderRate: { min: 0.12, max: 0.34 },
  /** Linear drag, per second. Low: in weightlessness nothing stops quickly. */
  drag: 0.42,
  /** Angular drag, per second. */
  spinDrag: 0.3,
  /** The idle tumble every bean keeps, as an angular acceleration. */
  spinWander: 0.22,
  /** Stiffness of the invisible walls at the frame's edge, per second squared. */
  wall: 4.5,
  /** Share of a bean allowed past the frame's edge before the wall pushes. */
  bleed: 0.6,
  /** Bounce: 0 is clay, 1 a superball. A roasted bean is hard and light. */
  restitution: 0.62,
  /** Surface friction in collisions. */
  friction: 0.35,
  /** The cursor's reach, as a fraction of the view's height at the cup. */
  pointerReach: 0.16,
  /** The cursor's push at point blank, as an acceleration on a bean of mass 1. */
  pointerPush: 26,
  /** How much of the cursor's own movement is handed to the air around it. */
  pointerWake: 1.8,
  /** How much a passing cursor sets a bean spinning. */
  pointerSpin: 3.2,
  /** Caps, so a violent flick cannot fling a bean through a wall. */
  maxSpeed: 7,
  maxSpin: 6,
  /** Collision radius as a fraction of a bean's length: a bean is long and
      flat, and this is the sphere that best stands in for it. */
  radius: 0.36,
  /** Scroll vortex: radius (world units) inside which its swirl builds up to
      full speed, and the share of it that pulls inward, so the beans wheel
      round the centre rather than flying off at a tangent. */
  vortexCore: 3,
  vortexInward: 0.22,
  /** How much of the swirl each bean picks up as spin about the view axis. */
  vortexSpin: 0.35,
} as const;

export interface Bean {
  position: Vector3;
  velocity: Vector3;
  rotation: Quaternion;
  /** Angular velocity, world space, radians per second. */
  spin: Vector3;
  /** Rendered length, world units. */
  size: number;
  /** Collision radius, world units. */
  radius: number;
  /** Relative mass: the cube of the bean's scale. */
  mass: number;
  /** Moment of inertia of the collision sphere. */
  inertia: number;
  /** Phase and rate of the bean's own wander, per axis. */
  phase: [number, number, number];
  rate: [number, number, number];
}

/** The cursor, as a ray from the camera through the pointer. */
export interface FieldPointer {
  active: boolean;
  origin: Vector3;
  direction: Vector3;
  /** How fast the cursor is moving, world units per second. */
  velocity: Vector3;
}

/** A capsule the beans bounce off: a segment and a radius. */
export interface FieldObstacle {
  a: Vector3;
  b: Vector3;
  radius: number;
}

/** The camera's frustum, which is the box the beans live in. */
export interface FieldView {
  distance: number;
  /** tan of half the vertical field of view. */
  tanHalf: number;
  aspect: number;
  /** Depth band, world z; the camera looks down -z. */
  near: number;
  far: number;
}

/** A swirl about an axis parallel to the view, driven from outside. */
export interface FieldVortex {
  centre: Vector3;
  /** Tangential acceleration at full swirl, world units per second squared;
      the sign sets the direction. 0 is still air. */
  strength: number;
}

export interface FieldWorld {
  view: FieldView;
  pointer: FieldPointer;
  obstacle: FieldObstacle | null;
  vortex: FieldVortex;
}

export interface Field {
  beans: Bean[];
  /** Simulated seconds owed to the next step. */
  debt: number;
  time: number;
}

/* Scratch, so the loop allocates nothing. */
const _d = new Vector3();
const _n = new Vector3();
const _t = new Vector3();
const _p = new Vector3();
const _w = new Vector3();
const _a = new Vector3();
const _ri = new Vector3();
const _rj = new Vector3();
const _rel = new Vector3();
const _seg = new Vector3();
const _q = new Quaternion();

const lerp = (from: number, to: number, t: number) => from + (to - from) * t;

/** Half the frame's height at depth `z`. */
const halfHeight = (view: FieldView, z: number) =>
  (view.distance - z) * view.tanHalf;

const randomUnit = (target: Vector3, random: () => number) => {
  const z = random() * 2 - 1;
  const angle = random() * Math.PI * 2;
  const r = Math.sqrt(1 - z * z);
  return target.set(r * Math.cos(angle), r * Math.sin(angle), z);
};

/** A uniformly random orientation (Shoemake). */
const randomRotation = (target: Quaternion, random: () => number) => {
  const u1 = random();
  const u2 = random() * Math.PI * 2;
  const u3 = random() * Math.PI * 2;
  const a = Math.sqrt(1 - u1);
  const b = Math.sqrt(u1);
  return target.set(a * Math.sin(u2), a * Math.cos(u2), b * Math.sin(u3), b * Math.cos(u3));
};

/** Closest point to `p` on the obstacle's segment, written into `target`. */
const closestOnSegment = (obstacle: FieldObstacle, p: Vector3, target: Vector3) => {
  _seg.subVectors(obstacle.b, obstacle.a);
  const length = _seg.lengthSq();
  const s = length > 0 ? Math.min(1, Math.max(0, _t.subVectors(p, obstacle.a).dot(_seg) / length)) : 0;
  return target.copy(obstacle.a).addScaledVector(_seg, s);
};

/**
 * Scatters `count` beans through the frustum, clear of each other and of the
 * obstacle. Sizes are log-uniform between `min` and `max` times `base`, so
 * small beans are as common as large ones read to the eye.
 */
export const createField = (
  count: number,
  sizes: { base: number; min: number; max: number },
  world: FieldWorld,
  random: () => number = Math.random,
): Field => {
  const beans: Bean[] = [];
  const { view, obstacle } = world;

  for (let i = 0; i < count; i++) {
    const scale = sizes.min * Math.pow(sizes.max / sizes.min, random());
    const size = sizes.base * scale;
    const radius = size * FIELD.radius;
    const mass = scale * scale * scale;
    const position = new Vector3();

    // Rejection sampling: a free spot, or the last try if none turns up.
    for (let attempt = 0; attempt < 40; attempt++) {
      const z = lerp(view.far, view.near, random());
      const half = halfHeight(view, z) * 0.9;
      position.set((random() * 2 - 1) * half * view.aspect, (random() * 2 - 1) * half, z);
      const clearOfBeans = beans.every(
        (other) => other.position.distanceTo(position) > other.radius + radius,
      );
      const clearOfCup =
        !obstacle ||
        closestOnSegment(obstacle, position, _p).distanceTo(position) > obstacle.radius + radius;
      if (clearOfBeans && clearOfCup) break;
    }

    beans.push({
      position,
      velocity: randomUnit(new Vector3(), random).multiplyScalar(lerp(0.08, 0.25, random())),
      rotation: randomRotation(new Quaternion(), random),
      spin: randomUnit(new Vector3(), random).multiplyScalar(lerp(0.15, 0.5, random())),
      size,
      radius,
      mass,
      inertia: 0.4 * mass * radius * radius,
      phase: [random() * 7, random() * 7, random() * 7],
      rate: [
        lerp(FIELD.wanderRate.min, FIELD.wanderRate.max, random()),
        lerp(FIELD.wanderRate.min, FIELD.wanderRate.max, random()),
        lerp(FIELD.wanderRate.min, FIELD.wanderRate.max, random()),
      ],
    });
  }

  return { beans, debt: 0, time: 0 };
};

/** Forces, integration and walls for one bean over one slice. */
const moveBean = (bean: Bean, h: number, time: number, world: FieldWorld) => {
  const { position: p, velocity: v, spin: w, phase, rate } = bean;
  const { view, pointer } = world;
  const inverseMass = 1 / bean.mass;

  // The air's own slow wander.
  _a.set(
    Math.sin(time * rate[0] + phase[0]),
    Math.sin(time * rate[1] + phase[1]),
    0.6 * Math.sin(time * rate[2] + phase[2]),
  ).multiplyScalar(FIELD.wander);

  // Walls: a spring past the frame's edge, which is a frustum, so the box is
  // wider at the back than at the front.
  const halfH = halfHeight(view, p.z);
  const inset = bean.radius * (1 - FIELD.bleed);
  const limitX = halfH * view.aspect - inset;
  const limitY = halfH - inset;
  if (Math.abs(p.x) > limitX) _a.x -= Math.sign(p.x) * (Math.abs(p.x) - limitX) * FIELD.wall;
  if (Math.abs(p.y) > limitY) _a.y -= Math.sign(p.y) * (Math.abs(p.y) - limitY) * FIELD.wall;
  if (p.z > view.near) _a.z -= (p.z - view.near) * FIELD.wall;
  if (p.z < view.far) _a.z += (view.far - p.z) * FIELD.wall;

  // The cursor: a push away from its ray, strongest on the ray and gone at the
  // edge of its reach, plus the wake of its own movement. Both are forces, so a
  // heavy bean answers less.
  if (pointer.active) {
    const reach = FIELD.pointerReach * 2 * view.distance * view.tanHalf;
    _d.subVectors(p, pointer.origin);
    const along = _d.dot(pointer.direction);
    _d.addScaledVector(pointer.direction, -along);
    const distance = _d.length();
    if (along > 0 && distance < reach) {
      const falloff = 1 - distance / reach;
      if (distance > 1e-5) {
        _n.copy(_d).divideScalar(distance);
        _a.addScaledVector(_n, FIELD.pointerPush * falloff * falloff * inverseMass);
        // The push lands on the bean's near face while the air slides past it,
        // which is a torque about the axis across the two.
        _w.crossVectors(_n, pointer.velocity).multiplyScalar(-FIELD.pointerSpin * falloff * inverseMass * h);
        w.add(_w);
      }
      _a.addScaledVector(pointer.velocity, FIELD.pointerWake * falloff * inverseMass);
    }
  }

  // The scroll vortex: air wheeling about the view axis through its centre.
  // It is the air moving, so like the wander it ignores mass. Tangential
  // speed builds linearly out to the core and holds beyond it; a share of it
  // pulls inward so the ring holds together.
  const { vortex } = world;
  if (vortex.strength) {
    const dx = p.x - vortex.centre.x;
    const dy = p.y - vortex.centre.y;
    const r = Math.hypot(dx, dy);
    if (r > 1e-4) {
      const swirl = vortex.strength * Math.min(1, r / FIELD.vortexCore);
      _a.x += (-dy / r) * swirl - (dx / r) * Math.abs(swirl) * FIELD.vortexInward;
      _a.y += (dx / r) * swirl - (dy / r) * Math.abs(swirl) * FIELD.vortexInward;
      w.z += swirl * FIELD.vortexSpin * h;
    }
  }

  v.addScaledVector(_a, h).multiplyScalar(Math.exp(-FIELD.drag * h));
  if (v.lengthSq() > FIELD.maxSpeed * FIELD.maxSpeed) v.setLength(FIELD.maxSpeed);
  p.addScaledVector(v, h);

  // The idle tumble, then drag.
  w.x += Math.sin(time * rate[1] * 0.7 + phase[2]) * FIELD.spinWander * h;
  w.y += Math.sin(time * rate[2] * 0.7 + phase[0]) * FIELD.spinWander * h;
  w.z += Math.sin(time * rate[0] * 0.7 + phase[1]) * FIELD.spinWander * h;
  w.multiplyScalar(Math.exp(-FIELD.spinDrag * h));
  if (w.lengthSq() > FIELD.maxSpin * FIELD.maxSpin) w.setLength(FIELD.maxSpin);

  // Orientation follows the spin, which is world-space, hence premultiply.
  const angle = w.length() * h;
  if (angle > 1e-9) {
    _q.setFromAxisAngle(_w.copy(w).normalize(), angle);
    bean.rotation.premultiply(_q).normalize();
  }
};

/** Elastic hit with friction between two beans that overlap. */
const collide = (i: Bean, j: Bean) => {
  _d.subVectors(j.position, i.position);
  const reach = i.radius + j.radius;
  const distanceSq = _d.lengthSq();
  if (distanceSq >= reach * reach || distanceSq < 1e-12) return;

  const distance = Math.sqrt(distanceSq);
  _n.copy(_d).divideScalar(distance);
  const im = 1 / i.mass;
  const jm = 1 / j.mass;

  // Push apart, lighter bean moving further.
  const correction = (reach - distance) / (im + jm);
  i.position.addScaledVector(_n, -correction * im);
  j.position.addScaledVector(_n, correction * jm);

  // Velocity of each surface at the contact point, spin included.
  _ri.copy(_n).multiplyScalar(i.radius);
  _rj.copy(_n).multiplyScalar(-j.radius);
  _rel
    .copy(j.velocity)
    .add(_t.crossVectors(j.spin, _rj))
    .sub(i.velocity)
    .sub(_p.crossVectors(i.spin, _ri));
  const closing = _rel.dot(_n);
  if (closing >= 0) return;

  const normal = (-(1 + FIELD.restitution) * closing) / (im + jm);

  // Friction: opposes the surfaces sliding, capped by Coulomb's law.
  _t.copy(_rel).addScaledVector(_n, -closing);
  const sliding = _t.length();
  let tangent = 0;
  if (sliding > 1e-6) {
    _t.divideScalar(sliding);
    tangent = Math.min(
      FIELD.friction * normal,
      sliding / (im + jm + (i.radius * i.radius) / i.inertia + (j.radius * j.radius) / j.inertia),
    );
  }

  // Impulse on j; i takes the opposite.
  _p.copy(_n).multiplyScalar(normal).addScaledVector(_t, -tangent);
  i.velocity.addScaledVector(_p, -im);
  j.velocity.addScaledVector(_p, jm);
  i.spin.add(_w.crossVectors(_ri, _p).multiplyScalar(-1 / i.inertia));
  j.spin.add(_w.crossVectors(_rj, _p).multiplyScalar(1 / j.inertia));
};

/** A bean against the cup, which does not move for it. */
const bounceOff = (bean: Bean, obstacle: FieldObstacle) => {
  closestOnSegment(obstacle, bean.position, _p);
  _d.subVectors(bean.position, _p);
  const reach = bean.radius + obstacle.radius;
  const distanceSq = _d.lengthSq();
  if (distanceSq >= reach * reach || distanceSq < 1e-12) return;

  const distance = Math.sqrt(distanceSq);
  _n.copy(_d).divideScalar(distance);
  bean.position.copy(_p).addScaledVector(_n, reach);

  const closing = bean.velocity.dot(_n);
  if (closing >= 0) return;
  // The sliding part rolls the bean along the cup's wall.
  _t.copy(bean.velocity).addScaledVector(_n, -closing);
  bean.spin.addScaledVector(_w.crossVectors(_n, _t), FIELD.friction / bean.radius);
  _t.multiplyScalar(-FIELD.friction);
  bean.velocity.addScaledVector(_n, -(1 + FIELD.restitution) * closing).add(_t);
};

/** Advances the field by `dt` seconds of real time. */
export const stepField = (field: Field, dt: number, world: FieldWorld) => {
  field.debt = Math.min(field.debt + dt, FIELD.step * FIELD.maxSteps);
  const { beans } = field;

  while (field.debt >= FIELD.step) {
    for (const bean of beans) moveBean(bean, FIELD.step, field.time, world);
    for (let i = 0; i < beans.length; i++) {
      for (let j = i + 1; j < beans.length; j++) collide(beans[i], beans[j]);
    }
    if (world.obstacle) for (const bean of beans) bounceOff(bean, world.obstacle);
    field.debt -= FIELD.step;
    field.time += FIELD.step;
  }
};

```

## 6 — The order block (the thermal printer)

```html
<section aria-label="Order ahead" id="order" data-header-theme="dark" class="relative w-full bg-background" style="height: 100vh">   <!-- 100lvh under reduced motion -->
  <div class="sticky top-0 h-lvh w-full overflow-hidden lg:max-xl:h-(--size-order-frame)">
    <span class="absolute inset-x-0 top-0 block" style="height: 130%"><span class="block size-full">   <!-- SpringTrigger scrub trigger=section "top bottom"→"top top" {y: "-23.0769%"}→{y: "0%"} (reduced: 0%→0%) -->
      <img src="…/order/bg-table.webp" alt="Two matcha cakes on brewns plates on a steel side table" width="1440" height="800" class="size-full object-cover lg:max-xl:h-[112%] lg:max-xl:object-top">
    </span></span>
    <div class="absolute inset-x-0 top-0 h-lvh lg:max-xl:h-(--size-order-frame)">
      <div class="absolute left-1/2 top-0" style="width:1440px; height:800px; transform-origin: top center; transform: translateX(-50%) translate({nudgeX}px, {shift}px) scale({scale})">
        <img src="…/order/printer.webp" alt="" width="404" height="72" style="position:absolute; left:519px; top:133px; width:400px; height:71.287px; z-index:1">
        <div style="position:absolute; left:553px; top:176px; width:328px; height:624px; overflow:hidden; z-index:2">
          <div style="--p:0; width:328px; transform: translate3d(0, calc(-100% + var(--p) * 100%), 0); filter: drop-shadow(0 calc(var(--p) * 26px) calc(var(--p) * 20px) rgba(0, 0, 0, 0.42)); will-change: transform">
            <!-- the receipt -->
          </div>
        </div>
      </div>
      <!-- OrderCopy -->
    </div>
  </div>
</section>
```

The scene is a fixed 1440×800 stage hung from the frame's top centre; the photograph covers the
viewport on its own layer (30% taller than the frame, lagging by up to 30% and landing flush as the
block reaches the top). The track is exactly one screen, so the block never pins: the receipt feeds
while the block rises into view and is fully out the moment the block fills the screen. The tuning
constants and the scale/shift logic, verbatim:

```ts

/* ════════════ TUNING — everything adjustable lives here ════════════ */

/** The board's coordinate system. BG.webp is exactly this size. */
const SCENE = { width: 1440, height: 800 };

/**
 * Scroll track length. 100 = the block is exactly one screen and never pins:
 * the receipt feeds while the block rises into view and is fully out the
 * moment the block fills the screen, after which it simply scrolls on into
 * the footer — no pinned travel, so no pause. Over 100 would pin the stage for
 * the difference with nothing left to feed.
 */
const TRACK_VH = 100;

/** Printer feed steps — the receipt advances in this many jerks, not smoothly. */
const STEPS = 46;
/**
 * Feed progress at which the barcode has cleared the printer's slot. Below it
 * the code is still behind the housing, so its settle would play unseen.
 */
const BARCODE_FED = 0.88;
/**
 * Background parallax: how far the photograph lags behind the frame as the
 * block scrolls in, as a fraction of the frame's height. The photograph is
 * made that much taller than the frame so the lag never opens a gap, and it
 * lands flush with the frame's top as the block reaches the top of the screen.
 * Bigger = stronger parallax, at the cost of cropping the photograph's sides.
 */
const BG_PARALLAX = 0.3;

/**
 * Where the paper starts to appear and where it is fully out, in viewport
 * heights of scroll measured from the moment the block's top reaches the top
 * of the screen. -0.85: the feed begins once the printer has risen into view;
 * 0: it ends as the block fills the screen.
 */
const START_VH = -0.85;
const END_VH = 0;
/** >1 eases the feed out toward the end. */
const EASE_POWER = 1.6;

/**
 * Clipping window. Its TOP EDGE is the slot line — the paper emerges from the
 * middle of the bar and then hangs in FRONT of the bar's lower lip, so the bar
 * sits behind it (z-index 1 against the window's 2). Only the slot's own upper
 * edge hides anything, and that is the window's clip, not the art.
 *
 * `height` runs to the bottom of the scene rather than the board's quoted 572.
 * The window also clips the paper's shadow, so stopping it short of the scene
 * cut the shadow off against a straight edge a few pixels under the torn one.
 */
const PRINT_WINDOW = {
  left: 553,
  top: 176,
  width: 328,
  get height() {
    return SCENE.height - this.top;
  },
};

/** Printer art, in its own pixels. Its slot sits at y 36–44 of the 72. */
const PRINTER_ART = { width: 404, height: 72 };

/**
 * The printer bar, in scene px. `height` comes from the art's aspect rather than
 * the board's quoted 46 — the asset is 404×72 and squashing it distorts the
 * slot. At width 400 the bar is ~71 tall, putting its slot at y≈169–176, flush
 * with PRINT_WINDOW.top. The bar is 400 wide against the paper's 328, so its
 * lower lip stays visible either side of the sheet.
 */
const PRINTER = {
  left: 519,
  top: 133,
  width: 400,
  get height() {
    return (this.width * PRINTER_ART.height) / PRINTER_ART.width;
  },
};

/**
 * Shadow under the paper, scaled by progress.
 *
 * `drop-shadow`, not `box-shadow`: the paper's bottom edge is torn, and a box
 * shadow follows the border box, so it drew a straight bar of shadow across the
 * teeth instead of tracing them.
 */
const SHADOW = { y: 26, blur: 20, color: "rgba(0, 0, 0, 0.42)" };

/**
 * Below the desktop board the stage stops covering the viewport.
 *
 * Covering a portrait phone with a 1440×800 landscape stage puts the printer and
 * the paper across the middle two thirds of the screen, and the copy then has
 * nowhere to go but on top of them. Instead the machine is fitted into a band
 * with the copy above and below it, and the photograph is promoted out of the
 * stage to cover the viewport on its own — it is a background, and a background
 * has no reason to be tied to the machine's scale.
 */
/** Root font-size the board is measured against — the grid's own base. */
const FONT_BASE = 16;

/**
 * Narrow-desktop nudge for the machine.
 *
 * Between 1024 and 1280 the frame is far squarer than the board's 1.8, and the
 * machine — placed at the board's proportion — lands low and crowds the
 * headline beside it. Over that range it lifts to the headline's own top line
 * and steps right, clear of the text. Both are zero from 1280 up, so the
 * reference frame and the width the last pass was tuned on are untouched.
 */
/**
 * Two corrections, one for each band under the board.
 *
 * `grow` scales the machine up toward the reference size over 1280-1439, where
 * the stage rides the root font-size and reads small beside copy that is set on
 * the same grid; the lift that comes with it is cancelled so the printer's top
 * line does not move. Both taper to nothing at 1440.
 *
 * On the 1024-1279 band the machine also hangs from the headline's own top line:
 * the printer's top edge sits on it, and the receipt feeds out below.
 */
const BOARD_WIDTH = 1440;
const TIGHT_WIDTH = 1280;
const GROW = 0.085;
const HEADLINE_TOP = 126;
/**
 * How much bigger the machine runs on the 1024-1279 band. The stage rides the
 * root size while the block's width rides the window, so at 1024 the receipt
 * came out a sixth narrower than the space drawn for it. The frame there is the
 * board's full height rather than the shortened one, which is what the taller
 * paper needs — and it is the height blocks 2-4 already stand at.
 */
const NARROW_GROW = 1.096;
/** Step right on the 1024-1279 band, in board px. */
const NARROW_NUDGE_X = 20;

const NARROW = {
  /** Viewport width below which the stage is fitted rather than covering. */
  maxWidth: 1024,
  /** Slice of the stage the machine occupies: printer top to paper bottom. */
  top: PRINTER.top,
  /** Where the finished paper's torn edge lands, not where the window ends —
      the window runs to the bottom of the stage and the paper does not. */
  paperBottom: 748,
  get height() {
    return this.paperBottom - this.top;
  },
  /** Room kept for the copy above and below, as a share of the viewport. */
  bandTop: 0.217,
  bandBottom: 0.22,
  /** A phone is squarer again, and the copy under the paper had nowhere to sit. */
  phoneWidth: 640,
  phoneBandTop: 0.223,
  phoneBandBottom: 0.277,
};

/* ═══════════════════════════════════════════════════════════════════ */

const clamp = (v: number, lo: number, hi: number) =>
  v < lo ? lo : v > hi ? hi : v;

export interface OrderProps {
  background: { src: string; alt: string; width: number; height: number };
  printer: { src: string; alt: string; width: number; height: number };
  /** `fed` is scene state, not content — the block supplies it itself. */
  receipt: Omit<OrderReceiptProps, "fed">;
  copy: OrderCopyProps;
}

/**
 * The scene is a fixed 1440×800 stage scaled to COVER the viewport, so the
 * board's coordinates stay literal at every window size — the alternative,
 * re-expressing each element in percentages, drifts as soon as the aspect ratio
 * changes and there is no second board to check it against.
 *
 * Progress is read straight off `getBoundingClientRect` inside a rAF-throttled
 * passive scroll listener. No animation library is involved, and the CSS
 * variable is only written when the quantised value actually changes, so a fast
 * scroll costs one style write per feed step rather than one per frame.
 */
export const Order = ({ background, printer, receipt, copy }: OrderProps) => {
  const trackRef = useRef<HTMLElement>(null);
  const paperRef = useRef<HTMLDivElement>(null);
  const frame = useRef(0);
  const queued = useRef(false);
  const progress = useRef(-1);
  const [scale, setScale] = useState(1);
  /** Vertical nudge that centres the machine on its band; 0 on the desktop board. */
  const [shift, setShift] = useState(0);
  /** Horizontal nudge on narrow desktops; 0 from 1280 up. */
  const [nudgeX, setNudgeX] = useState(0);
  const [fed, setFed] = useState(false);
  const [reduced, setReduced] = useState(false);

  const write = useCallback((p: number) => {
    if (p === progress.current) return;
    progress.current = p;
    paperRef.current?.style.setProperty("--p", String(p));
    // One state flip for the whole scroll, not one per step: React only hears
    // about the feed when the barcode crosses into view and when it leaves.
    setFed((was) => {
      const now = p >= BARCODE_FED;
      return was === now ? was : now;
    });
  }, []);

  const measure = useCallback(() => {
    const track = trackRef.current;
    if (!track) return;
    // How far the block's top is past the top of the screen, in px;
    // negative while the block rises in.
    const scrolled = -track.getBoundingClientRect().top;
    const from = START_VH * window.innerHeight;
    const to = END_VH * window.innerHeight;
    let p = clamp((scrolled - from) / (to - from), 0, 1);
    p = 1 - Math.pow(1 - p, EASE_POWER);
    write(Math.round(p * STEPS) / STEPS);
  }, [write]);

  const fit = useCallback(() => {
    const vw = window.innerWidth;
    const vh = window.innerHeight;

    if (vw >= NARROW.maxWidth) {
      // The stage rides the root font-size, not the viewport, and hangs from the
      // top of the frame. Covering instead put the machine on a different scale
      // from the copy beside it — at 1280x800 the stage stayed at 1.0 while the
      // type dropped to 0.92, so the printer sat 17px below the headline's top
      // line instead of the board's 7. The photograph still covers; it is its own
      // layer and has no reason to follow the machine.
      const root =
        parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
      const base = root / FONT_BASE;
      setNudgeX(0);

      if (vw >= BOARD_WIDTH) {
        setScale(base);
        setShift(0);
        return;
      }

      if (vw >= TIGHT_WIDTH) {
        const t = clamp((BOARD_WIDTH - vw) / (BOARD_WIDTH - TIGHT_WIDTH), 0, 1);
        const grown = base * (1 + GROW * t);
        setScale(grown);
        // The stage's origin is its top centre, so growing it would push the
        // machine down the frame. This puts the printer back on its own line.
        setShift(PRINTER.top * (base - grown));
        return;
      }

      const grown = base * NARROW_GROW;
      setScale(grown);
      // Keep the paper leaving the slot on the headline's own top line whatever
      // the machine is scaled to.
      setShift(HEADLINE_TOP * base - PRINTER.top * grown);
      setNudgeX(NARROW_NUDGE_X * base);
      return;
    }

    // Fit the machine into the band left between the two copy bands, and never
    // let it grow past the width either — a tall narrow window would otherwise
    // push the printer wider than the screen.
    const phone = vw < NARROW.phoneWidth;
    const bandTop = phone ? NARROW.phoneBandTop : NARROW.bandTop;
    const bandBottom = phone ? NARROW.phoneBandBottom : NARROW.bandBottom;
    const band = vh * (1 - bandTop - bandBottom);
    const next = Math.min(band / NARROW.height, (vw * 0.94) / PRINTER.width);
    setScale(next);

    // Centre the machine on the band rather than the stage on the viewport: the
    // stage's own centre sits well above the printer, so centring it would hang
    // the paper off the bottom of the screen.
    const machineTop = vh * bandTop + (band - NARROW.height * next) / 2;
    setShift(machineTop - NARROW.top * next);
    setNudgeX(0);
  }, []);
```

**The feed.** On the ticker (a rAF-throttled passive scroll listener in the project): `scrolled =
-section.getBoundingClientRect().top`, `from = -0.85 * innerHeight`, `to = 0`, `p = clamp((scrolled
- from) / (to - from), 0, 1)`, `p = 1 - (1 - p)^1.6`, then **quantised to 46 steps**
(`Math.round(p * 46) / 46`) and written as `--p` only when it changed — the paper advances in jerks,
not smoothly. `fed = p >= 0.88` flips once, when the barcode has cleared the slot. Under reduced
motion `--p` is 1 from the start.

**The receipt** is HTML + CSS, never an image, 328 wide, `color: #111111`, `font-family: var(--font-space-mono)`:

```html
<div style="width:328px; color:#111111">
  <div style="background:#F2F0EA; padding:32px 22px 10px; display:flex; flex-direction:column; gap:10px; font-family:var(--font-space-mono)">
    <p style="font-size:11px; font-weight:700; letter-spacing:0.16em; text-align:center">BREWNS COFFEE HOUSE</p>
    <div style="height:1px; background-image: repeating-linear-gradient(90deg, #111111 0 5px, transparent 5px 10px)"></div>   <!-- Rule -->
    <img src="…/order/matcha-cup.webp" alt="A brewns iced matcha latte" width="155" height="252" style="width:150px; height:auto; margin:0 auto; display:block">
    <!-- Rule -->
    <div style="display:flex; justify-content:space-between; gap:12px; font-size:8px; letter-spacing:0.07em; line-height:1.85">
      <div><p>GOOD COFFEE.</p><p>MADE FOR YOUR DAY.</p></div>
      <div style="text-align:right"><p>ORDER #00025</p><p>21/05/2025</p><p>12:45</p></div>
    </div>
    <div style="font-size:8px; letter-spacing:0.07em; line-height:1.85"><p>THANK YOU FOR CHOOSING BREWNS.</p><p>SEE YOU SOON!</p></div>
    <!-- Rule -->
    <div style="display:flex; gap:12px">
      <p style="font-size:16px; font-weight:700; line-height:1.25; flex:1"><span style="display:block">SKIP THE LINE.</span><span style="display:block">ORDER AHEAD.</span></p>
      <div style="width:132px; padding-left:12px; border-left:1px solid rgba(17, 17, 17, 0.25); display:flex; flex-direction:column; gap:8px">
        <p style="font-size:7px; letter-spacing:0.07em; line-height:1.6; color:#5b5b58"><span style="display:block">SAVE TIME.</span><span style="display:block">GET YOUR COFFEE FASTER.</span></p>
        <span style="background:#111111; color:#F2F0EA; font-size:8px; font-weight:700; letter-spacing:0.09em; padding:6px 9px; display:flex; justify-content:space-between; align-items:center; gap:8px">ORDER NOW<span aria-hidden="true">→</span></span>
      </div>
    </div>
    <!-- the barcode -->
    <p style="font-size:7px; letter-spacing:0.16em; text-align:center">BREWNS.COFFEE</p>
  </div>
  <svg aria-hidden="true" width="328" height="9" viewBox="0 0 328 9" preserveAspectRatio="none" style="display:block"><path d="{tornEdgePath(328)}" fill="#F2F0EA"/></svg>
</div>
```

`tornEdgePath(width)`: `M 0 0 L {width} 0`, then for `x = width; x > 0; x -= 8`: `L {max(0, x-4)} 9
L {max(0, x-8)} 0`, then `Z` — teeth pointing down, 8px pitch, 9px deep.

**The barcode** — `RECEIPT.barcode = { height: 42, gap: 1, thin: 1, thick: 2.5, seed: 20250521 }`.
Bars are generated from a fixed LCG seed until they fill the paper's content width (328 − 44), so
the code is identical every render; each bar starts at its own height and they all settle to the
same line, left to right, once `fed`. Verbatim:

```ts

const SETTLE = { floor: 0.42, ceiling: 0.94, span: 0.55, seed: 90210 };

const clamp = (v: number, lo: number, hi: number) =>
  v < lo ? lo : v > hi ? hi : v;

/**
 * Bar widths from a fixed seed, so the code is identical on every render and
 * never flickers when React re-renders the receipt mid-scroll.
 *
 * The run is generated until it fills the paper's content width rather than to
 * a fixed count: a fixed count left a bald strip at the right-hand end, which no
 * printed code has. The last bar takes whatever is left and is always inked, so
 * the code closes on a bar the way a real one does.
 */
const barcodeBars = () => {
  const span = RECEIPT.width - RECEIPT.paddingX * 2;
  let s = RECEIPT.barcode.seed >>> 0;
  const next = () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };

  const bars: { key: number; width: number; on: boolean }[] = [];
  let left = span;
  let key = 0;

  while (left > 0) {
    const width = next() < 0.55 ? RECEIPT.barcode.thin : RECEIPT.barcode.thick;
    const on = next() > 0.22;

    if (left <= width + RECEIPT.barcode.gap) {
      bars.push({ key: key++, width: left, on: true });
      break;
    }

    bars.push({ key: key++, width, on });
    left -= width + RECEIPT.barcode.gap;
  }

  return bars;
};

/**
 * Each bar's starting height, on its own seed so the printed code — widths and
 * inking, which come off RECEIPT.barcode.seed — is byte-for-byte unchanged.
 */
const startHeights = (count: number) => {
  let s = SETTLE.seed >>> 0;
  return Array.from({ length: count }, () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    const r = s / 4294967296;
    return SETTLE.floor + r * (SETTLE.ceiling - SETTLE.floor);
  });
};

/** Height multiplier for bar `i` at sweep position `v`. */
const scaleAt = (v: number, i: number, count: number, from: number) => {
  const t0 = (i / count) * (1 - SETTLE.span);
  const local = clamp((v - t0) / SETTLE.span, 0, 1);
  const eased = 1 - Math.pow(1 - local, 3);
  return from + (1 - from) * eased;
};
```

The row is `display:flex; align-items:flex-end; gap:1px; height:42px`; each bar `width: b.width;
height: 100%; background: b.on ? #111111 : transparent; transform-origin: bottom center;
transform: scaleY(scaleAt(sweep, i, count, from[i]))`, `sweep` springing 0 → 1 on 70/34 when `fed`.

### The copy — outside the scaled scene, on the page's rem grid

```html
<div class="absolute inset-0 flex flex-col justify-between px-5 pb-5 pt-18 text-foreground sm:px-10 sm:pb-10 sm:pt-24 max-lg:items-center max-lg:text-center lg:block lg:p-0">
  <div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-64 bg-linear-to-b from-background/90 via-background/55 to-transparent lg:hidden"></div>
  <div aria-hidden="true" class="pointer-events-none absolute inset-x-0 bottom-0 h-72 bg-linear-to-t from-background/92 via-background/60 to-transparent lg:hidden"></div>
  <div class="relative flex flex-col gap-4 sm:max-lg:gap-6 lg:contents">
    <div class="max-sm:max-w-[17rem] sm:max-lg:mx-auto sm:max-lg:max-w-104 lg:absolute lg:left-10 lg:top-(--size-order-headline-y) lg:w-(--size-order-headline)">
      <h2 class="justify-start text-left font-display font-semibold text-headline-phone leading-headline tracking-tight uppercase max-lg:justify-center max-lg:text-center sm:text-headline-tablet lg:text-headline">COFFEE FOR RIGHT NOW.</h2>   <!-- TextEngine lines · rootMargin -25% -->
    </div>
    <p class="w-(--size-order-note) max-lg:hidden font-mono text-base leading-copy tracking-tight uppercase lg:absolute lg:bottom-10 lg:left-10 lg:mt-0">WE HANDLE THE CRAFT. YOU HANDLE THE DAY.</p>   <!-- Inview clipPath inset(0 100% 0 0)→ · 46/26 · delayIn 180 -->
  </div>
  <span class="relative flex w-full flex-col items-start gap-4 max-sm:gap-9 sm:w-(--size-order-aside) sm:gap-6 sm:max-lg:w-120 sm:max-lg:gap-10 max-lg:items-center lg:absolute lg:bottom-10 lg:right-10 lg:gap-(--size-order-aside-gap)">   <!-- Inview {opacity 0,y 16}→ · 80/26 · delayIn 280 -->
    <span class="w-full justify-start text-left font-display font-bold text-base leading-copy tracking-tight uppercase sm:max-lg:text-lede-tablet max-lg:justify-center max-lg:text-center">WE WORK TO ONE SCHEDULE: YOURS. ORDER AHEAD AND YOUR CUP IS POURED TO MEET YOU, NOT TO SIT WAITING. FAST BECAUSE IT’S TIMED TO YOU, NOT BECAUSE IT’S RUSHED.</span>   <!-- TextEngine words · 620ms · 14 · delayIn 320, no rootMargin (the block is sticky) -->
    <!-- CtaLink "order right now" href="#order" class="w-(--size-order-cta)" -->
  </span>
</div>
```

## 7 — The footer (a 385px band)

```html
<footer data-header-theme="dark" class="relative flex w-full flex-col gap-8 overflow-hidden bg-background px-5 py-12 max-sm:pb-8 font-mono text-label leading-copy tracking-tight uppercase text-foreground sm:gap-10 sm:px-10 sm:text-base lg:block lg:h-(--size-footer-height) lg:p-0">
  <span aria-hidden="true" class="pointer-events-none absolute left-(--size-swirl-x) top-(--size-swirl-y) block h-(--size-swirl-height) w-(--size-swirl-width) bg-backdrop-soft [mask-position:center] [mask-repeat:no-repeat] [mask-size:100%_100%]" style="mask-image:url(…/shared/swirl-mask.webp)"></span>   <!-- Inview {opacity 0, scale 1.05}→{1, 1} · 40/26 -->
  <span class="block h-(--size-logo-height) w-(--size-logo-width) lg:absolute lg:left-10 lg:top-10"><a href="/" aria-label="brewns" class="block size-full bg-foreground [mask …]" style="mask-image:url(…/shared/wordmark-mask.webp)"></a></span>   <!-- Inview clipPath inset(0 100% 0 0)→ · 42/26 · delayIn 180 -->
  <p class="-mt-4 w-(--size-footer-tagline) text-fine text-foreground/50 max-lg:hidden lg:absolute lg:left-10 lg:top-(--size-footer-tagline-y) lg:mt-0">COFFEE FOR RIGHT NOW.MADE FOR YOUR DAY.</p>   <!-- Rise · delayIn 260 -->
  <div class="grid grid-cols-2 gap-x-6 gap-y-8 sm:grid-cols-3 sm:gap-x-10 lg:contents">
    <div class="contents lg:absolute lg:left-(--size-footer-nav-x) lg:top-10 lg:flex lg:gap-12">
      <!-- three columns, each: div.flex.flex-col.gap-3.lg:min-w-(--size-footer-col) → p (Rise delayIn 160 + i*130) heading; ul.flex.flex-col.gap-1.5.text-foreground/50 → li (Rise delayIn 160 + i*130 + (row+1)*70) UnderlineLink -->
    </div>
    <span aria-hidden="true" class="hidden h-px bg-foreground/10 max-sm:col-span-2 max-sm:block sm:max-lg:col-span-3 sm:max-lg:block"></span>
    <div class="flex flex-col gap-3 max-sm:col-span-2 lg:absolute lg:left-(--size-footer-contact-x) lg:top-10">
      <p>contact</p>   <!-- Rise delayIn 550 -->
      <ul class="flex flex-col gap-1.5 text-foreground/50"><li>hellbrews@gmail.com</li><li>(415) 123-4567</li></ul>   <!-- Rise delayIn 550 + (row+1)*70; hrefs mailto:hellbrews@gmail.com, tel:+14151234567 -->
    </div>
  </div>
  <!-- Rule top=var(--size-footer-rule-top) delayIn 120: span.block.h-px.w-full.bg-foreground/10.lg:absolute.lg:inset-x-10.lg:w-auto, RuleDraw x -->
  <p class="text-fine text-foreground/50 lg:absolute lg:left-10 lg:top-(--size-footer-row-y)"><span class="flex items-center gap-1.5"><!-- PulseDot size-(--size-footer-dot) -->OUR LOCATIONS</span></p>   <!-- Rise delayIn 300 -->
  <div class="grid gap-x-6 gap-y-6 sm:grid-cols-2 sm:gap-x-10 lg:contents">
    <address class="not-italic lg:absolute lg:left-(--size-footer-nav-x) lg:top-(--size-footer-row-y) lg:w-(--size-footer-address)"><span class="block">139 Coffee Street</span><span class="block">San Francisco, CA</span></address>   <!-- Rise per line · delayIn 340 + row*70 -->
    <!-- Divider left=var(--size-footer-divider-a) delayIn 300: span.hidden.h-(--size-hero-rule).w-px.bg-foreground/35.lg:absolute.lg:top-(--size-footer-row-y).lg:block, RuleDraw y -->
    <address class="not-italic lg:whitespace-nowrap lg:absolute lg:left-(--size-footer-address-b) lg:top-(--size-footer-row-y)">310 Valencia Street / San Francisco, CA</address>   <!-- 400 + row*70 -->
    <!-- Divider left=var(--size-footer-divider-b) delayIn 360 -->
    <address class="not-italic lg:whitespace-nowrap lg:absolute lg:left-(--size-footer-address-c) lg:top-(--size-footer-row-y)">56 Columbus Avenue / San Francisco, CA</address>   <!-- 460 + row*70 -->
    <p class="max-sm:mt-4 max-sm:border-t max-sm:border-foreground/10 max-sm:pt-6 lg:whitespace-nowrap lg:absolute lg:left-(--size-footer-contact-x) lg:top-(--size-footer-row-y)"><span class="block">OPEN DAILY</span><span class="block">07:00 - 21:00</span></p>   <!-- Rise 500 + row*70; DigitRoll delayIn 560 + row*70 -->
  </div>
  <!-- Rule top=var(--size-footer-rule-bottom) delayIn 220 -->
  <div class="flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between lg:contents">
    <p class="whitespace-nowrap text-fine text-foreground/50 max-sm:order-last max-sm:mt-3 max-sm:border-t max-sm:border-foreground/10 max-sm:pt-6 lg:absolute lg:left-10 lg:top-(--size-footer-legal-y)">© 2026 BREWNS COFFEE HOUSE.</p>   <!-- Rise 560 -->
    <span class="block whitespace-nowrap text-fine lg:absolute lg:left-(--size-footer-nav-x) lg:top-(--size-footer-legal-y)">INSTAGRAM<span class="whitespace-pre text-foreground/50"> & </span>TIKTOK</span>   <!-- Rise 600; two UnderlineLinks text-foreground/50 hover:text-foreground -->
    <span class="block whitespace-nowrap text-fine lg:absolute lg:left-(--size-footer-contact-x) lg:top-(--size-footer-legal-y)">PRIVACY POLICY<span class="whitespace-pre text-foreground/50"> / </span>TERMS OF USE</span>   <!-- Rise 640; hrefs #privacy, #terms -->
  </div>
</footer>
```

**`Rise`** — one line rising out of its own mask: outer element `[clip-path:inset(0_0_-4px_0)]`
(the clip runs 4px past the bottom so the hover rule is not shaved), inner `span.block` springing
`translateY(150%) → translateY(0%)`, config 80/26, `delayIn` as given; one per *line*, never per
column. The three columns: **Shop** (COFFEE `#shop-coffee`, SUBSCRIPTIONS `#subscriptions`, MERCH
`#merch`, GIFT CARDS `#gift-cards`), **Menu** (COFFEE `#menu-coffee`, NON-COFFEE `#non-coffee`,
SIGNATURE DRINKS `#signature`, FOOD `#food`), **about us** (OUR STORY `#story`, COFFEE & SOURCING
`#sourcing`, journal `#journal`, CAREERS `#careers`). Links `[overflow-wrap:anywhere]
transition-colors 150ms ease-entrance hover:text-foreground lg:whitespace-nowrap`. The footer swirl
is the same mask painted `rgba(23, 23, 21, 0.5)`.

## Responsive rules, in one place

- **≥ 1440 (`board`)**: every block is the 1440×800 board in rem, pinned to the board's own
  coordinates; the root grows above 1440 (damped 0.6666). A section taller than 50rem drops its
  composition by `--size-board-drop` (the surplus, capped at 6.5rem) so the bottom gutter stays 40px.
- **1280–1439**: the same board with the root damped (`16px - (1440 - vw)/128.57`); the menu stills
  lift 0.75rem; the hero still gains up to 40px of nudge as the window narrows toward 1024; the
  order machine grows by up to 8.5% toward 1280 with its top line held.
- **1024–1279**: the blocks are `--size-board-height` (50rem) tall rather than `100lvh` (the menu
  50rem + 2.5rem); the footer's left half is placed on the same fraction of the width the board
  gives it (`vw` tokens); the menu stills lift 0.9rem at 0.92 scale; the hero product is scaled
  0.928 and moved right 1% (`PLACEMENT`); the order machine is 1.096× and hangs from the headline's
  top line, stepped right 20 board px.
- **640–1023 (`sm:max-lg`)**: everything in flow. Root 16px. The hero is a column; the product is an
  absolute layer between 42% (< 768) / 35.5% (≥ 768) and the bottom with the info bar's scrim over
  it (product scale 1.02, +4.54%, +5% on 768–1023); the menu is a 2×2 grid `items-end`; the
  locations block is a 2-column grid (dial first, addresses in 3 columns below, hours right); the
  philosophy block is a `21rem 1fr` grid; the order block fits its machine into the band between two
  scrims (`bandTop` 0.217, `bandBottom` 0.22) with the copy centred above and below; the footer is
  a 3-column grid.
- **< 640**: root `4.444444vw` (360 base). Hero type re-cut (`text-display-hero-phone` 52px,
  `text-script-phone` 62px, script offset 156/84); the product fills from 40% down; the handles
  drop from the info bar; the menu cards stack in one column at 22.5rem tall (stills lifted
  1.375rem, capped at 100% width); the locations dial leads and the addresses follow it; the
  philosophy claims are hidden; the order band is 0.223/0.277; the footer groups sit in one
  2-column grid; the header bar hides over the footer.
- Below 768 there is no hover: `Hover`, `PointerLean`, the locations lean and the hero handle are
  off; the philosophy scene runs 16 beans at DPR 1 without antialiasing at ≤ 30fps drawing (the
  physics keeps its fixed step); the hero canvas keeps its 1.5 pixel-ratio floor.

## Accessibility

One `h1` (the hero headline); every other headline an `h2`; `section`s carry `aria-label`; the two
canvases are `aria-hidden` with a `role="img"` box naming the product / the cup beside them; the
wordmark links carry `aria-label="brewns"`; every text reveal keeps an `sr-only` copy of the real
string; the digit rolls keep their transparent copy in the tree; the phone menu toggle carries
`aria-expanded`/`aria-controls`/`aria-label`; `prefers-reduced-motion` removes the preloader, stills
the dot, the swirl fill, the leans, the hero intro/drag spin/scroll drift, the bean physics and the
receipt feed (paper fully out), and Opalesce draws once.

## Fixed parameters (bake these in)

Everything in the token block above; plus, by section: preloader `STEP {0.1, 0.6, 1}`,
`MIN_VISIBLE 620`, `MAX_WAIT 4000`, `HOLD 180`, `FILL 120/26`, `POUR 90/18`; header `PROBE_OFFSET
35`, probe every 100ms; hero `REVEAL` delays 0 / 420 / 260 / 520 / 620 / 700 / 760 / 840 / 900;
the hero model's `FRAME`, `ASSET`, `PLACEMENT`, `LIFT`, `LEAN`, `POINTER`, `KEYS`, `STUDIO`,
`INTRO`, `SCROLL_DROP 0.22`, `SCROLL_EASE 0.14`, `BLEED 0.22`, `FIELD_OF_VIEW 8`, exposure 1.15,
`ENVIRONMENT 0.045`, `MIN_PIXEL_RATIO 1.5`, `savedHeroLayout`; Opalesce `CONFIG`; menu
`STILL_PARALLAX 11`, card delays `order*90` (+160 index, +240 price, +300 name), receipt
`{150,150,0}` 120/26; swirl `BLOBS`, `REACH 115`, `FEATHER 14`, 3400ms easeInOutSine, `DELAY 100`,
`THRESHOLD 0.12`, `PARALLAX 100`; locations `DIAL_PARALLAX 26`, `CUP_PARALLAX 80`,
`CUP_MAX_ROTATION 38`, `CUP_MAX_TILT 14`; philosophy `VIEW`, `DEPTH`, `BEANS`, `CUP`, `LIGHT`,
`FOG`, `VORTEX`, `POINTER_SETTLE 8`, `POINTER_MAX_SPEED 24`, `MOBILE_FRAME 1000/30`, `FIELD`; order
`SCENE`, `TRACK_VH 100`, `STEPS 46`, `BARCODE_FED 0.88`, `BG_PARALLAX 0.3`, `START_VH -0.85`,
`END_VH 0`, `EASE_POWER 1.6`, `PRINT_WINDOW`, `PRINTER`, `SHADOW`, `NARROW`, `GROW 0.085`,
`NARROW_GROW 1.096`, `NARROW_NUDGE_X 20`, `HEADLINE_TOP 126`, `RECEIPT`, `SETTLE`; footer rise
delays as listed.

## Assets

All fetched from `https://storage.getlayers.ai/assets/brewns-b9f02b6657/`. Set that as one
`ASSET_BASE_URL` constant and build every path from it. Load the model through three's loaders
(which request anonymously); the images below are plain `<img>` or CSS masks and need no CORS
attribute unless you draw them onto a canvas.

| file | size | used by |
|---|---|---|
| `hero/models.glb` | 353 KB | **the one model — Draco-compressed, needs `DRACOLoader`.** Three scenes: the bag + cup composition (the hero), the cup alone (materials `CupCoffee…`, the philosophy block), a single bean (material `COFFEE_MAT`, instanced 34×/16×). Every texture is embedded |
| `shared/wordmark-mask.webp` | 4 KB | the `brewns` wordmark — an **alpha mask**, painted with the current foreground in the header, preloader and footer |
| `shared/swirl-mask.webp` | 85 KB | the brand swirl — an **alpha mask** (2235×745 box), filled behind the menu and locations blocks, painted at 50% behind the footer |
| `menu/menu-espresso.webp` | 150 KB | card 01, 1216×1293 |
| `menu/menu-latte.webp` | 126 KB | card 02, 1024×1536 |
| `menu/menu-iced-coffee.webp` | 232 KB | card 03, 1024×1536 |
| `menu/menu-cinnamon.webp` | 293 KB | card 04, 1536×1024 |
| `menu/menu-receipt.webp` | 79 KB | the receipt behind VIEW FULL MENU, 832×1890 |
| `locations/clock.webp` | 38 KB | the dial, 1308×1308 |
| `locations/cup.webp` | 293 KB | the iced latte on the dial, 1024×1536 |
| `order/bg-table.webp` | 80 KB | the order block's photograph, 1440×800 |
| `order/printer.webp` | 4 KB | the printer bar, 404×72 (its slot at y 36–44) |
| `order/matcha-cup.webp` | 18 KB | the cup printed on the receipt, 155×252 |
| `fonts/SpaceMono-Regular.ttf` | 98 KB | Space Mono 400 |
| `fonts/Allura-Regular.ttf` | 234 KB | Allura 400 |

The DRACO decoder comes from the three.js CDN, **not** from this bucket:
`new DRACOLoader().setDecoderPath("https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/libs/draco/gltf/")`.
Geist and Onest come from Google Fonts.

**A failed asset load must be visible.** Both model loads (`loader.load(src, …, undefined, onError)`)
and the two font faces must surface a banner on the page naming the URL that failed — this page has
two things that would otherwise fail silently: a hero whose model never decodes is a finished
headline over an empty frame with a card that never appears, and a philosophy block whose model
never arrives is a gradient with a scrim and no cup. If WebGL2 is unavailable the Opalesce fallback
gradient stays; if WebGL is unavailable the two model frames stay empty and the page stands.
[/hidecontent]
← 返回首页