motionsites中文站 / Herbier Blanc
Herbier Blanc
aadmin2026-11-05 发布Site模板0 阅读

Herbier Blanc

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

[hidecontent type="vip1"]
<!-- MOTDES 国内版交付说明:以下英文规格保留完整正文,仅将素材基址改为本地路径。 -->

> **本地素材准备(MOTDES)**:本提示词用于重建单个 `index.html`。先把配套 Next.js 源码中的整个 `public/assets/` 复制到该 HTML 同目录的 `assets/` 文件夹;以这个目录作为 HTTP 服务根目录进行预览,例如在目录里运行 `python3 -m http.server 8080` 后访问 `http://localhost:8080/`。本版 `ASSET_BASE_URL` 使用 `/assets`,没有虚构或依赖 MOTDES CDN。图片与逐帧序列全部随源包提供;Google Fonts 与 Lenis CDN 仍需要联网。这里的“单 HTML”指代码写在一个 HTML 中,图片仍为同目录素材文件。

# Recreate this site as a single HTML file: Herbier Blanc — Small-batch perfume house in Lyon

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.
Use pure HTML/CSS/JS in one file, with no build step, framework or bundler. ES modules go inline in a
`<script type="module">`. **Lenis 1.3.26 is the only external code**, pulled through an import map; the
three type faces come from one Google Fonts `<link>`. You write everything else by hand: the spring
engine, the shared ticker, the sheet stack, the black-curtain preloader, the scroll-scrubbed
124-frame sequence on a canvas, the envelope and letter that open with the scroll, the WebGL "mist"
that condenses the perfume bottle out of 260 000 droplets, the WebGL cursor ripple over the photos,
the letter-by-letter blur-in headings, the pen-written script words, the rolling odometer figures, the
hide-on-scroll header and its paper menu sheet. Hardcode every value given here as a fixed constant.
Where this document gives code, it is the shipped behaviour: use it as it stands, and treat every
number in it as the shipped default. GLSL is given verbatim — do not edit it.

```js
const ASSET_BASE_URL = "/assets";
```

Every image below loads from that base. A path written like `product/product-bottle-clear-2x.webp` in
this document means `${ASSET_BASE_URL}/product/product-bottle-clear-2x.webp` (served from the same HTTP origin as the HTML). **Give every `<img>` that a WebGL texture or a 2D `getImageData`
reads `crossOrigin="anonymous"`** (the product bottle, the chapters photo, the three shelf photos, the
footer flower) — without it the canvas is tainted and the effect throws. The full list is in
**Assets** at the end.

## What it is

The one-page landing of **Herbier Blanc**, a three-person perfume house in a former silk workshop in
Lyon that releases a perfume only "when it is finished" — six in ten years. The page sells
**Formula No. 04, "Marguerite Fauve"** (chamomile, honey, hay). The look is **pressed-flower stationery
over oxblood paper**: a warm cinematic photo of the bottle on a daisy field, light paper sheets
(`#f5f5f5` / `#f3f3f3`) with near-black ink (`#141414` / `#131313`), and deep crimson print-textured
paper blocks (`#9a2219`) with a ghostly grey gerbera screened over them. Type is three faces only:
**Instrument Serif** for body, nav and headlines, **Mrs Saint Delafield** — a flowing copperplate hand —
for every "written" word (titles, product names, asides, the handwritten quote), and **Jost Medium** in
tiny wide-tracked capitals for eyebrows, labels and buttons. Script words are *written* on screen by a
soft pen mask sweeping left to right; headings resolve letter by letter out of a blur.

Order, top to bottom:

0. **Preloader — two black curtains.** The screen opens closed: two black halves meeting at the
   middle. A hairline of light draws out from the centre along the seam, and under it a counter runs
   `FORMULA NO. 04 — 000 → 100` as fonts and the first 40 video frames load. At 100 the counter goes,
   the photo behind starts a slow push-in, and the curtains part — top half up, bottom half down —
   uncovering the hero.
1. **Header** (fixed, over everything): five serif links stacked in a column top-left, "HERBIER BLANC,
   LYON" in tiny caps at the centre, "Bag (0)" top-right. It hides on scroll down, returns on scroll up,
   and below the first screen becomes a one-row strip in the tone of the block under it. On tablets /
   phones a single "Menu" word opens a paper sheet that drops over the page.
2. **Hero** (one visible screen, `100svh`): no picture of its own — a transparent sheet over Block 02's
   first video frame. "Marguerite Fauve" in huge script blurs in letter by letter; then the tagline
   "a small perfume house, working one harvest at a time" and "Fr / En" rise in.
3. **Block 02 · Product stage** (400 `lvh` of scroll, pinned): a 5.17 s video of the bottle in a daisy
   field, played as a **scroll-scrubbed WebP frame sequence on a canvas**, zooming 100 → 133 %. The
   hero copy scrolls away; the product copy ("Marguerite / Fauve" in script, "A daisy field at four in
   the afternoon, still warm.", size chips 50 ML / 100 ML, an "ADD TO BAG €140" button) and a paper
   **envelope** scroll in from below. Scrolling on, the envelope's flap folds open, a **letter slides out**
   and its three notes (Top / Heart / Base) are written by pen, while the **bottle condenses out of an
   aerosol mist** — two braided streams of droplets flying in from opposite screen corners and coiling
   into the bottle shape (WebGL points). The flowers ripple like water under the cursor.
4. **Block 03 · Chapters** (light): "A BOTTLE, IN FIVE CHAPTERS", the script title "{ the harvest }" in
   madder red, a paragraph, a bottle straddling a paper / harvest-photo split, and a five-step index.
5. **Block 04 · Atelier** (crimson): "We release a perfume when it is *finished.* / So far that is six in
   ten years." with a hand-drawn underline, a soft photo of hands arranging vials, two paragraphs, an
   outline button, and three rolling odometer stats (2016 · 6 · 400) with a handwritten aside "we know
   them by heart" and a hand-drawn arrow.
6. **Block 05 · Shelf** (light): "Three from *the shelf*", four filter labels, three product cards — a
   blurred photo backdrop, the bottle, the name written in script over it, description, price and
   "QUICK ADD". On tablets / phones the cards are a swipe row with a progress thumb.
7. **Interlude · Note** (crimson paper): a handwritten quote, rotated −4°, "a daisy has almost no /
   scent of its own, so we / asked its cousin." — the last line and a half written by pen, then a
   double underline. Four tiny caps labels sit in the corners.
8. **Block 06 · Discovery set** (light): "Wear it for *a week* before you decide.", an "ORDER THE SET ·
   €24" button with the handwritten aside "it comes off your first bottle" and an arrow, and six vial
   columns (number, script name, notes, vial, "2 ML").
9. **Footer** (crimson, uncovered last): it lies under the page and the last block lifts off it like a
   curtain; a giant "Herbier Blanc" wordmark is written by pen across a gerbera, with address, link
   columns, a tagline, a "SUBSCRIBE" button and the legal line.

**Paper over red.** On the 1440 layout every light block is a sheet laid over the previous block, and
every red block lies underneath the sheet before it and is *uncovered* as that sheet lifts off. A
block being covered (or not yet uncovered) sinks into shade and blur and moves slower than the sheet
above it. Below 1200 px every block is simply a sheet laid over the previous one.

**Springs are the motion model.** Every entrance, hover glide and menu move is a spring (react-spring
semantics, exact tension/friction below) or a fixed-duration eased tween; the only CSS transitions are
the 250 ms colour swaps on hover, the 700 ms pen underline under links and the 250 ms lift of a
hovered bottle / vial. Smooth scroll is Lenis.

## Page shell & libraries

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#9a2219">
<title>Herbier Blanc — Small-batch perfume house in Lyon</title>
<meta name="description" content="A small perfume house in Lyon, working one harvest at a time. Six perfumes in ten years, composed in Grasse and bottled by the batch.">
<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=Instrument+Serif&family=Jost:wght@400;500&family=Mrs+Saint+Delafield&display=swap" rel="stylesheet">
<link rel="preload" as="image" href="/assets/product/frames/product-frame-001.webp">
<script type="importmap">
{ "imports": { "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs" } }
</script>
```

**Faces** — exactly three, and only these are drawn:

```css
:root {
  --font-sans:   "Jost", sans-serif;               /* 400 / 500 — eyebrows, labels, buttons (always 500 where shown) */
  --font-serif:  "Instrument Serif", serif;        /* 400 — body, nav, headlines */
  --font-script: "Mrs Saint Delafield", cursive;   /* 400 — every handwritten word */
}
body { font-family: var(--font-sans); }
```

Throughout this document "**caps**" means `font-family: var(--font-sans); font-weight: 500;
text-transform: uppercase; letter-spacing: 0.2em; font-size: 12px` (the eyebrow style) unless a size
or tracking is given; "**serif**" means Instrument Serif 400; "**script**" means Mrs Saint Delafield 400.
Many script headings carry `text-box: trim-both cap alphabetic` (written "**trim**" below): the line box
is trimmed to the cap height and baseline so the absolute `top` lands on the cap line. Keep it.

**Reset** (Tailwind v4 preflight equivalent): `*, ::before, ::after { box-sizing: border-box; margin: 0;
padding: 0; border: 0 solid; }`, `html { line-height: 1.5; -webkit-text-size-adjust: 100% }`,
`h1,h2,h3,p,blockquote,figure,dl,dd { margin: 0; font-size: inherit; font-weight: inherit }`,
`ol, ul { list-style: none }`, `a { color: inherit; text-decoration: inherit }`,
`img, svg, canvas { display: block; max-width: 100% }`, buttons and inputs unstyled
(`background: none; font: inherit; color: inherit; letter-spacing: inherit`), `button { cursor: pointer }`,
`address { font-style: normal }`, `fieldset { min-width: 0 }`, and `.sr-only` (the standard
visually-hidden clip).

```css
body {
  background: #ffffff; color: #171717;
  display: flex; flex-direction: column; align-items: center; justify-content: safe center;
  height: 100vh; width: 100%; overflow-x: clip;   /* clip, never hidden: hidden breaks the sticky sheets */
}
@media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #ededed; } }
```

**Transforms.** Wherever a layer below has a static `translate`, `scale` or `rotate`, write it with the
CSS **individual transform properties** (`translate: -50% -50%; scale: .8; rotate: -4deg`) and keep the
`transform` property free: JavaScript writes `transform` on many of the same elements every frame, and
the browser composes them as `translate → rotate → scale → transform`. That composition order is part
of the result.

**Page structure.** `<body>` holds one `<div style="width:100%">` wrapping one auto-height
`<div>` (the sticky footer needs an auto-height parent — `<body>` itself is `100vh`), which holds, in
order: the preloader curtain (`position: fixed; z-index: 60`), the header (`fixed; z-index: 50`),
`<main style="position:relative; z-index:10; width:100%">` with the seven block wrappers (hero · product ·
chapters · atelier · shelf · note · discovery), and after `<main>` the footer wrapper. The menu sheet
(`fixed; z-index: 100`) is appended to `<body>` last.

**Smooth scroll.** On load, if there is no `location.hash`, call `window.scrollTo(0, 0)`. Then

```js
import Lenis from "lenis";
const lenis = new Lenis({
  smoothWheel: true,
  naiveDimensions: true,          // read the page height live: spacers are added after mount
  anchors: { immediate: true },   // in-page links jump at once, through Lenis
});
if (location.hash) requestAnimationFrame(() => lenis.scrollTo(location.hash, { immediate: true }));
```

Lenis is driven by the shared ticker (below) and runs **first** in every frame, before every other
subscriber, so every scroll-linked read sees this frame's scroll. **Stopping scroll** (while the
preloader counter runs, and while the menu sheet is open) means `lenis.stop()` plus
`html.style.position = "relative"; html.style.overflow = "hidden"; html.style.height = "100%"`;
**starting it** means `lenis.start()` and removing those three properties.

## Tokens (copy verbatim into `:root`)

Colours are given resolved. `color-mix(in srgb, X N%, transparent)` is written as `X / N%` below —
write it in CSS as that `color-mix` (or the equivalent `rgb(r g b / N%)`).

```css
:root {
  /* Base */
  --white: #ffffff;
  --on-media: #f9f9f9;                         /* light ink over photo / video */
  --on-media-muted: #f9f9f9 / 85%;
  --on-media-subtle: #f9f9f9 / 75%;
  --on-media-border-quiet: #f9f9f9 / 45%;
  --on-media-tagline: #f5f5f5;
  --on-media-strong: #ffffff;
  --on-media-shadow: 0 1px 14px color-mix(in srgb, #290f05 45%, transparent);
  --curtain: #000000;
  --sheet-shade: #0a0a0a;                       /* the shade on a block lying under a sheet */

  /* Header */
  --header-ink: #f9f9f9;
  --header-ink-light: #131313;
  --header-label: #f9f9f9 / 75%;
  --header-label-light: #131313 / 75%;
  --header-sheet: #f5f5f5;                      /* the paper strip */
  --header-sheet-rule: #131313 / 6%;
  --header-sheet-dark: #9a2219;                 /* the red strip */
  --header-sheet-rule-dark: #f9f9f9 / 14%;
  --header-sheet-blur: 18px;
  --header-scrim: #290f05 / 55%;

  /* Menu sheet (stays paper in dark mode too) */
  --menu-surface: #f5f5f5;
  --menu-foreground: #131313;
  --menu-foreground-muted: #131313 / 60%;
  --menu-rule: #131313 / 14%;

  /* Block 02 */
  --product-backdrop: #331f14;
  --action-inverse: #f9f9f9;
  --action-inverse-foreground: #131313;
  --product-chip-glass: #f9f9f9 / 12%;

  /* Envelope + letter */
  --stationery-ink: #2b1a12;
  --stationery-ink-script: #2b1a12 / 90%;
  --stationery-ink-muted: #2b1a12 / 80%;
  --stationery-ink-subtle: #2b1a12 / 65%;
  --stationery-rule: #5c331f / 30%;
  --stationery-light-from: #d99e4d / 0%;
  --stationery-light-to: #d99e4d / 16%;

  /* Block 03 chapters */
  --chapter-surface: #f5f5f5;
  --chapter-ink: #141414;
  --chapter-ink-muted: #141414 / 62%;
  --chapter-rule: #141414 / 22%;
  --chapter-title: #9a2219;
  --chapter-photo-wash: #d95c1f / 10%;          /* mix-blend-mode: soft-light */

  /* Block 04 atelier (and the Note's paper tone) */
  --atelier-background: #9a2219;
  --atelier-sheen: #ffffff / 1%;
  --atelier-ink: #ffffff;
  --atelier-ink-muted: #ffffff / 85%;
  --atelier-rule: #ffffff / 45%;
  --atelier-action-border: #ffffff / 60%;
  --atelier-photo-wash: #b8331a / 16%;          /* mix-blend-mode: soft-light */

  /* Block 05 shelf */
  --shelf-paper: #f3f3f3;
  --shelf-ink: #131313;
  --shelf-ink-muted: #131313 / 62%;
  --shelf-ink-description: #131313 / 78%;
  --shelf-rule: #131313 / 12%;
  --shelf-button-border: #131313 / 60%;
  --shelf-accent: #9a2219;
  --shelf-photo-frame: #664d40;
  --shelf-photo-wash: #fae5cc / 30%;
  --shelf-on-photo: #ffffff;
  --shelf-progress-track: #290f05 / 12%;
  --shelf-progress-thumb: #290f05 / 85%;

  /* Interlude note */
  --note-paper: #9a2219;
  --note-paper-from: #a3261c;
  --note-paper-to: #8a1c15;
  --note-paper-sheen: #ffffff / 1%;
  --note-eyebrow: #f8f8f8;
  --note-ink: #ffffff;

  /* Block 06 discovery */
  --discovery-surface: #f5f5f5;
  --discovery-ink: #141414;
  --discovery-ink-muted: #141414 / 80%;
  --discovery-ink-soft: #141414 / 75%;
  --discovery-ink-faint: #141414 / 60%;
  --discovery-rule-strong: #141414 / 50%;
  --discovery-rule: #141414 / 20%;
  --discovery-accent: #9a2219;
  --discovery-action: #141414;
  --discovery-action-foreground: #ffffff;

  /* Footer */
  --footer-background: #9a2219;
  --footer-ink: #ffffff;
  --footer-ink-muted: #ffffff / 80%;
  --footer-border: #ffffff / 60%;

  /* Motion */
  --duration-normal: 250ms;                      /* colour swaps, hover lifts */
  --duration-pen: 700ms;                         /* the pen underline under links */
  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
}
```

**The "uneven tone"** — the radial printed on both red paper blocks (atelier and note), at `opacity: .4`
over the base red, centred on the frame:

```css
background-image: radial-gradient(
  var(--tone-x) var(--tone-y) at 50% 50%,
  color-mix(in srgb, #ffd9cc 7%, transparent) 0%,
  color-mix(in srgb, #936c66 11.5%, transparent) 50%,
  color-mix(in srgb, #5c3633 13.75%, transparent) 75%,
  color-mix(in srgb, #260000 16%, transparent) 100%
);
```

| radii (design px) | 1440 | 1024 | 768 | 390 |
|---|---|---|---|---|
| atelier `--tone-x × --tone-y` | 654.55 × 335.38 | 465.45 × 260 | 349.09 × 385.77 | 177.27 × 476.15 |
| note `--tone-x × --tone-y` | 654.55 × 300 | 465.45 × 246.15 | 349.09 × 246.15 | 177.27 × 215.38 |

**Type sizes** (design px; the face, leading and tracking are given where each is used):

| role | 1440 | 1024 | 768 | 390 |
|---|---|---|---|---|
| caps eyebrow | 12 | 13 where noted (header label) | — | — |
| button caps | 13 (tracking .2em, line-height 20) | | | |
| nav (serif, lh 1.18, tracking −0.01em) | 24 | 22 | 24 | 20 |
| locale "Fr / En" (serif) | 21 | 22 | 24 | — |
| lede (serif) | 22 | 20 | 20 | 18 |
| hero title (script) | 168 | 128 | 100 | 60 |
| hero tagline (serif) | 22 | 22 | 24 | 20 |
| product title (script, lh .84) | 114 | 80 | 96 | 64 |
| envelope print (script) | 250 | | | |
| letter note (script, lh 1.04) | 34 | | | |
| letter label / caption (caps, tracking .18em) | 10 / 9 | | | |
| envelope note (caps, tracking .12em, lh 1.4) / flap words (tracking .1em) | 8 | | | |
| chapter title (script) | 120 | 88 | 72 | 52 |
| chapter body (serif) | 22 (lh 1.4) | 20 (lh 1.4) | 20 (lh 1.45) | 18 (lh 1.45) |
| atelier headline (serif, tracking −0.02em; −1.76px on laptop / tablet) | 88 / lh 98 | 64 / lh 71 | 48 / lh 53 | 38 / lh 43 |
| atelier script word | 150 | 109 | 82 | 65 |
| atelier body (serif, lh 1.45) | 22 | 20 | 20 | 18 |
| atelier stat (serif) | 84 | 64 | 72 | 56 |
| atelier note (script) | 40 | 32 | 36 | 26 |
| shelf title (serif, tracking −0.01em) | 50 / lh 53 | 44 / lh 47 | 44 / lh 47 | 36 / lh 40 |
| shelf title script words | 92 | 81 | 81 | 66 |
| shelf card name (script) | 72 | 50 | 51 | 48 |
| shelf description (serif, lh 1.26, tracking −0.01em) | 18 | 18 | 18 | 18 |
| note quote (script, lh .9) | 150 | 120 | 99 | 60 |
| discovery title (serif, tracking −0.01em) | 50 / lh 53 | 44 / lh 47 | 44 / lh 47 | 36 / lh 40 |
| discovery title script words | 92 | 81 | 81 | 66 |
| discovery lede (serif, lh 1.45) | 18 | 18 | 18 | 16 |
| discovery aside (script) | 40 | 34 | 36 | 30 |
| discovery vial name (script) | 40 | 26 | 34 | 28 |
| discovery notes (serif, lh 1.35) | 16 | 16 | 16 | 14 |
| footer wordmark (script) | 230 | 170 | 128 | 64 |
| footer link (sans 400, 13, tracking .04em, lh 1.9) | 13 | 13 | 13 | 13 |
| footer tagline (serif, lh 1.5) | 22 | 20 | 20 | 18 |
| menu sheet links (serif, lh 1.18) | 44 | 44 | 44 | 44 |

## The four layout ranges and the rem grid

The page is drawn as four Figma frames, and each viewport range renders **its own frame, scaled to the
width**:

| range | viewport | frame |
|---|---|---|
| **desktop** | `@media (width >= 1200px)` | 1440 |
| **laptop** | `@media (896px <= width < 1200px)` | 1024 |
| **tablet** | `@media (700px <= width < 896px)` | 768 |
| **mobile** | `@media (width < 700px)` | 390 |

Group names used below: **compact** = `< 1200` (laptop + tablet + mobile), **handheld** = `< 896`
(tablet + mobile). The four ranges never overlap. A value given without a range is the desktop value
and holds on every range that does not override it.

**Every length in this document is in design px of the range's own frame. Write each one in CSS as
`rem = px / 16`** (e.g. `top 408` → `top: 25.5rem`). The root font-size tracks the viewport so each frame
renders 1:1 at its reference width:

```css
html { font-size: 1.111111vw; }                                           /* 16 · 100 / 1440 — keeps growing above 1440 */
@media (max-width: 1199px) { html { font-size: 1.5625vw; } }              /* 16 · 100 / 1024 */
@media (max-width: 895px)  { html { font-size: 2.083333vw; } }            /* 16 · 100 / 768 */
@media (max-width: 699px)  { html { font-size: min(4.102564vw, 19.2px); } } /* 16 · 100 / 390, capped at 1.2× */
```

(Above 1440 the root keeps scaling 1:1 with the width, `16 × width / 1440` px — full-bleed blocks fill
any screen with no side gutters.)

**Phone column (469–699 px).** Past the 1.2× cap the 390 composition stays one 1.2× column in the
middle of the screen, while each block's colour and the layers marked `data-bleed` (photos, paper) still
run edge to edge. Copy this CSS verbatim; mark the layers named "bleed" / "col" / "bleed-flow" /
"bleed-right" below with those attributes, and give each section the `--col-bg` named with it:

```css
@media (468px < width < 700px) {
  [data-block] { container-type: inline-size; overflow-x: clip; }
  [data-block] > :is(section, footer) {
    width: 24.375rem; margin-inline: auto; overflow: visible;
    clip-path: inset(0 -50cqw);
    box-shadow: 0 0 0 50cqw var(--col-bg, transparent);
  }
  [data-bleed] { left: calc(50% - 50cqw) !important; right: auto !important; width: 100cqw !important; }
  [data-bleed-flow] { width: 100cqw; margin-inline: calc(50% - 50cqw); }
  [data-col] { left: calc(50% - 12.1875rem); right: auto; width: 24.375rem; }
  [data-bleed-right] { width: calc(100% + 50cqw - 12.1875rem); }
}
```

### Fit factors — every screen seen whole

The first screens (hero, Block 02) and the screen-tall blocks are fixed-height Figma compositions in a
viewport-tall box. On a wide, short window (a 14" laptop, ~1512 × 860) a 900-tall frame would overflow,
so **before the first paint** (an inline classic `<script>` at the top of `<body>`) and again on every
`resize` (debounced to two `requestAnimationFrame`s; on touch devices only when the width changed),
write these custom properties on `<html>`:

```js
const STAGE_RANGES = [
  // 1440: stage 900 · atelier 872 · chapters 900 · shelf 885 · discovery 759 · note 900 (in rem of the frame)
  { min: 1200, ref: 1440, frames: { stage: 56.25, atelier: 54.5, chapters: 56.25, shelf: 55.3125, discovery: 47.4375, note: 56.25 } },
  { min: 896,  ref: 1024, frames: { stage: 48, atelier: 0, chapters: 48, shelf: 0, discovery: 0, note: 0 } },
  { min: 700,  ref: 768,  frames: { stage: 64, atelier: 0, chapters: 0, shelf: 0, discovery: 0, note: 0 } },
  { min: 0,    ref: 390,  cap: 19.2, frames: { stage: 52.75, atelier: 0, chapters: 0, shelf: 0, discovery: 0, note: 0 } },
];
function writeStageFit(w = innerWidth, h = innerHeight) {
  let r = STAGE_RANGES[STAGE_RANGES.length - 1];
  for (const range of STAGE_RANGES) if (w >= range.min) { r = range; break; }
  const px = Math.min((16 * w) / r.ref, r.cap ?? Infinity);       // the root font-size, px
  const across = w / ((r.ref / 16) * px);                           // > 1 past the phone cap
  const s = document.documentElement.style;
  for (const [name, frame] of Object.entries(r.frames)) {
    const ratio = frame ? h / (frame * px) : 1;
    s.setProperty(`--${name}-fit`, Math.min(1, ratio).toFixed(4));  // ≤ 1: scale a frame down to fit
    s.setProperty(`--${name}-ratio`, ratio.toFixed(4));             // uncapped window/frame height
    if (name === "stage") s.setProperty("--stage-cover", Math.max(1, ratio, across).toFixed(4)); // ≥ 1: the photo covers
  }
}
```

So: `--stage-fit` scales the hero title and Block 02's copy/envelope to the window height, and
`--stage-cover` scales Block 02's photo up to cover the window. `--chapters-fit/-ratio`,
`--atelier-fit/-ratio`, `--shelf-fit`, `--discovery-fit/-ratio` and `--note-ratio` are read by those blocks.

## Motion engine you write

### Springs (react-spring 10 semantics)

Integrate exactly like react-spring: 1 ms sub-steps, `springForce = −tension × 0.000001 × (pos − to)`,
`dampingForce = −friction × 0.001 × vel`, `vel += springForce + dampingForce`, `pos += vel` (mass 1); at
rest when `|vel| ≤ restVelocity` and `|to − pos| ≤ precision` (`precision = min(1, |to − from| × 0.001)`,
`restVelocity = precision / 10`). Retargeting keeps the velocity. A `delay` (ms) holds the spring at its
current value before it starts toward the target. `immediate` jumps to the value.

A spring may instead be given a **duration config** `{ duration, easing }`: it then runs as a tween —
`value = from + (to − from) × easing(min(1, elapsed / duration))` — with no velocity. When `easing` is
not named it is linear. The easings used (react-spring's):

```js
const easings = {
  linear: (x) => x,
  easeOutCubic: (x) => 1 - Math.pow(1 - x, 3),
  easeInOutCubic: (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
  easeInOutQuart: (x) => (x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2),
  easeInOutSine: (x) => -(Math.cos(Math.PI * x) - 1) / 2,
};
```

```js
// ticker.add(fn, gapMs = 0): fn(now) runs every frame (no more often than gapMs) until it returns false.
function spring({ from = 0, to = from, tension = 170, friction = 26, duration, easing = easings.linear, delay = 0, onUpdate, onRest } = {}) {
  let pos = from, vel = 0, target = to, origin = from, startAt = performance.now() + delay, last = null, done = true, running = false;
  let cfg = { tension, friction, duration, easing };
  const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
  const run = () => { if (!running) { running = true; ticker.add((now) => (running = step(now))); } };
  const s = {
    get value() { return pos; },
    // start(to, { delay, immediate, config, onRest }) — config may be {tension, friction} or {duration, easing}
    start(v, { delay: d = 0, immediate = false, config, onRest: rest } = {}) {
      if (config) cfg = { tension: 170, friction: 26, easing: easings.linear, ...config };
      if (rest) onRest = rest;
      target = v; origin = pos; startAt = performance.now() + d; done = false; last = null;   // velocity is kept
      if (immediate || reduce()) { pos = v; vel = 0; onUpdate?.(pos); done = true; onRest?.(); return; }
      run();
    },
  };
  function step(now) {
    if (done) return false;
    if (now < startAt) return true;
    if (cfg.duration != null) {
      const t = Math.min(1, (now - startAt) / cfg.duration);
      pos = origin + (target - origin) * cfg.easing(t);
      if (t >= 1) { pos = target; done = true; }
    } else {
      const dt = Math.min(64, last == null ? 16 : now - last); last = now;
      const precision = Math.min(1, Math.abs(target - origin) * 0.001) || 0.001, restV = precision / 10;
      for (let i = 0; i < Math.ceil(dt); i++) {
        const f = -cfg.tension * 0.000001 * (pos - target) - cfg.friction * 0.001 * vel;
        vel += f; pos += vel;
      }
      if (Math.abs(vel) <= restV && Math.abs(target - pos) <= precision) { pos = target; vel = 0; done = true; }
    }
    onUpdate?.(pos);
    if (done) onRest?.();
    return !done;
  }
  onUpdate?.(pos);
  return s;
}
```

**Reduced motion** (`prefers-reduced-motion: reduce`) makes every spring and tween immediate.

### One shared ticker

A single app-wide `requestAnimationFrame` loop. Lenis's `raf(time)` is its **lead** and runs first every
frame; then every subscriber, each throttled by its own minimum gap in ms (0 = every frame; the
header's tone probe and the preloader gate use 100). The loop is reference-counted — it stops when the
last subscriber leaves — and a subscriber that throws is logged and isolated, never killing the loop.

**The stack frame.** The sheet stack, the footer reveal, the stage's "cover" reading and the note's
in-sight test share one ticker subscriber that runs every job's `read()` (layout reads only) first and
then every job's `write()` (DOM writes only), so a frame costs one style recalc.

### The sheet stack — blocks, arrival and depth

Every block is wrapped in `<div data-block data-header-tone="dark|light">` (the hero also gets
`data-fixed`, Block 02 `data-stage`). Inside each wrapper, after the section, add two empty layers:

```html
<div class="focus" aria-hidden style="pointer-events:none; visibility:hidden; position:absolute; inset:0"></div>
<div class="shade" aria-hidden style="pointer-events:none; visibility:hidden; position:absolute; inset:0; background:var(--sheet-shade); opacity:0"></div>
```

A block with an `id` (atelier → `#atelier`, discovery → `#discovery`) gets a zero-height
`<div id="…" data-spacer aria-hidden>` **before** its wrapper as the jump target. The wrappers in order
and their tones:

| block | tone | notes |
|---|---|---|
| hero | dark | `data-fixed`, `position:relative; z-index:10`, never sticky |
| product (Block 02) | dark | `data-stage`, `margin-top: -100svh` — it starts at the very top of the page, under the hero |
| chapters | light | |
| atelier | dark | id `atelier` |
| shelf | light | |
| note | dark | |
| discovery | light | id `discovery`, last |
| footer wrapper (after `<main>`) | dark | see Footer |

**Roles** (re-worked whenever `(width >= 1200px)` flips): a block's role is **cover** (a sheet laid on
top) or **reveal** (lying underneath, uncovered). Stage → cover. Below 1200 every block → cover. At
≥ 1200 a `light` block → cover, a `dark` block → reveal. The hero has no role. The neighbour after the
last block (discovery) counts as "underneath" (the footer always lies under the page).

```js
const MAX_SHADE = 0.45, MAX_BLUR = 10, DEPTH = 0.22;
const ARRIVAL_LINE = 0.35, ENTER_LINE = 0.9;

// per block el (next/prev = nearest [data-block] sibling, skipping [data-spacer])
function setup() {
  clear el's inline position, zIndex, top, marginTop, minHeight, display, flexDirection, justifyContent, backgroundColor, transform;
  remove this block's spacer;
  const stacked = matchMedia("(width >= 1200px)").matches;
  const role = fixed ? null : roleOf(el, stacked);
  const nextRole = roleOf(next, stacked);
  const nextIsUnder = nextRole === "reveal" || !next;
  reveal = role === "reveal";
  sticky = fixed ? false : reveal || !nextIsUnder;
  coverNext = sticky && next && (nextRole === "cover" || fixed);
  if (!fixed) {
    el.style.position = sticky ? "sticky" : "relative";
    const index = indexOf(el among main > [data-block]);          // hero 0, product 1, chapters 2 …
    el.style.zIndex = String(reveal ? 2 * index - 3 : 2 * index); // a reveal block slots just under the sheet above it
  }
  if (stacked && !fixed && !stage) {                               // every sheet is at least a screen tall
    el.style.minHeight = "100lvh"; el.style.display = "flex"; el.style.flexDirection = "column";
    el.style.justifyContent = "center";
    el.style.backgroundColor = getComputedStyle(el.firstElementChild).backgroundColor;
  }
  if (reveal && !stage) {                                          // it starts one screen up, under the sheet above
    el.style.marginTop = "-100lvh";
    spacer = <div data-spacer aria-hidden> inserted right after el;
  }
  place();
}
function place() {   // also on ResizeObserver(el)
  const h = el.offsetHeight;
  el.style.top = sticky ? `min(0px, calc(100lvh - ${h}px))` : "";  // sticks when its bottom reaches the screen bottom
  if (spacer) spacer.style.height = `max(0px, calc(200lvh - ${h}px))`;
}

// stack-frame job
read() {
  const vh = innerHeight;
  const nextTop = coverNext ? next.getBoundingClientRect().top : vh;
  const covered = coverNext ? clamp01((vh - nextTop) / vh) : 0;
  const prevBottom = reveal ? prev.getBoundingClientRect().bottom : 0;
  const hidden = clamp01(prevBottom / vh);
  under = Math.max(covered, hidden);
  shift = fixed || reducedMotion ? 0 : (hidden - covered) * DEPTH * vh;   // it moves slower than the sheet above
  if (shift < 0 && coverNext) {                                           // never drift up off the sheet coming over it
    const bottom = el.getBoundingClientRect().bottom - applied;
    shift = Math.max(shift, Math.min(0, nextTop - bottom));
  }
  if (!arrived) {
    const visibleTop = Math.max(el.getBoundingClientRect().top, prevBottom);
    arrived = visibleTop <= ARRIVAL_LINE * vh;
    entered ||= visibleTop <= ENTER_LINE * vh;
  }
}
write() {
  if (entered) markEntered(el);   // latched once: data-entered + "block:enter" event
  if (arrived) markArrived(el);   // latched once: data-arrived + "block:arrive" event (implies entered)
  shade.style.opacity = under * MAX_SHADE; shade.style.visibility = under > 0 ? "visible" : "hidden";
  focus.style.backdropFilter = focus.style.webkitBackdropFilter = under > 0 ? `blur(${(under * MAX_BLUR).toFixed(1)}px)` : "";
  focus.style.visibility = under > 0 ? "visible" : "hidden";
  applied = +shift.toFixed(1);
  el.style.transform = shift ? `translate3d(0, ${shift.toFixed(1)}px, 0)` : "";
}
```

The hero is marked arrived at once. (Write only when `under`/`shift` changed — key them to 3 / 1
decimals.) What this produces at ≥ 1200: the stage pins under the hero for three screens, then chapters
slides over it while it shades and blurs; chapters scrolls away revealing atelier, which was waiting
pinned underneath (its spacer buys one screen in which it is fully seen); shelf slides over atelier;
shelf lifts off note; discovery slides over note; discovery lifts off the footer. Below 1200 each
block sticks when its bottom reaches the screen bottom and the next one slides over it.

**Reveal triggers.** Unless an element is given an explicit trigger, every reveal below (Blur-in,
Pen, Fade, Odometer) waits for **its block to arrive** (`block:arrive` — the block's visible top has
reached 35 % of the screen height, i.e. the sheet above has lifted far enough) and plays once. Every
reveal in a block starts together, at that moment, plus its own delay. The footer wrapper is a block too
(it arrives when half uncovered, see Footer).

### The reveal kinds

**Blur-in — every heading.** The text resolves out of a soft blur, letter by letter. Split the text
into words (whitespace separates words; the space itself is not a letter) and each word into letter
`<span>`s; nested inline elements (a script-face `<span>` inside a serif heading) keep their own
styling and are split the same way. Each word is a flex item: the container is
`display:flex (inline-flex when "inline"); flex-wrap: wrap (nowrap when "nowrap"); column-gap: 0.3em
(or the gap given); align-items: baseline; white-space: pre-wrap; transform: translate(0)`. Letter
*i* (counting letters across the whole text, in order) starts at `delay + i × 36` ms:

```js
const BLUR_IN = {
  letterOut: { opacity: 0, filter: "blur(8px)" },
  letterIn:  { opacity: 1, filter: "blur(0px)" },
  letterConfig: { tension: 98, friction: 17 },
  letterStagger: 36,
};
```

One spring `p: 0 → 1` per letter drives `opacity: p` and `filter: blur(${8 × (1 − p)}px)`. It plays once,
when its trigger has fired **and** the element intersects the viewport (`IntersectionObserver`,
threshold 0). **Blur lines** — several lines that blur in as one run: each line is its own
`display:block` line holding an inline blur-in, and line *k* starts after the letters of the lines
before it: `delay + 36 × (letters before it)`.

**Pen — written by hand.** A soft-edged mask sweeps left to right over the content (script words,
hand-drawn underlines and arrows):

```js
const FEATHER = 18;   // width of the mask's soft edge, % of the element
const penMask = (p, from = "left") => {
  const head = p * (100 + FEATHER);
  const angle = from === "left" ? 90 : 270;
  return `linear-gradient(${angle}deg, #000 ${head - FEATHER}%, transparent ${head}%)`;
};
```

The element is an `inline-block` span with `mask-image` (and `-webkit-mask-image`) `= penMask(p)`;
`p` tweens 0 → 1 with `{ duration, easing: easeInOutSine }` after its `delay` (default duration
1400 ms). With **bleed** (the default, for text) the span carries `padding: 0.5em 0.25em; margin:
-0.5em -0.25em` so script flourishes past the line box are not cut; with `bleed: false` (images) no
padding. `from: "right"` sweeps right to left.

**Fade.** `opacity` 0 → 1, `{ duration: 900 (or given), easing: easeOutCubic }` after its delay. With a
`blur` (px) it also resolves out of `blur((1 − o) × blur px)` (`filter: none` once `o ≥ 0.999`).

**Odometer** (atelier stats). Each digit column rolls one full turn and settles on its digit, left to
right. Per character: a non-digit is plain; a digit is `<span style="position:relative; display:inline-block;
clip-path: inset(0 -50%)">` holding an invisible copy of the digit (sizes the column) and an absolutely
positioned column `top:0; left:0; display:flex; flex-direction:column` of 20 rows `0 1 2 … 9 0 1 … 9`,
each `display:block; height:1em`. The column's `transform: translate3d(0, ${−v}em, 0)`, `v` from 0 to
`digit + 10`, `{ duration: 1500, easing: easeOutCubic }`, delay `i × 140` ms (character index). A
`.sr-only` copy of the plain value is read out.

**Parallax** (photos in a clipped frame). The frame is `position:absolute; inset:0`; the layer inside is
`position:absolute; left:0; right:0; top:-10%; bottom:-10%; will-change:transform` (a deeper overscan
where given). With `progress` = how far the frame has crossed the screen (0 when its top meets the
screen bottom, 1 when its bottom meets the screen top), the layer gets
`transform: translate3d(0, ${((0.5 − progress) × 2 × range).toFixed(3)}%, 0)`. Reduced motion: progress
0.5. A `strong` range replaces `range` inside its media query.

**Drift** (a free-floating bottle). The same progress; the layer (`position:absolute; inset:0`) gets
`translate3d(0, ${((progress − 0.5) × 2 × −range).toFixed(3)}rem, 0)` — positive range runs ahead of the
page. Only within its media query (otherwise, and with reduced motion, progress 0.5 — at rest).

Progress for both is measured every frame while the frame is in view (and for 10 frames after it
leaves): `progress = clamp01((innerHeight − rect.top) / (innerHeight + rect.height))`.

**Pen link** — every text link in the header, menu and footer: `position:relative; display:inline-block`
with an `aria-hidden` underline `<span>`: `position:absolute; left:0; right:0; bottom:0.06em; height:1px;
background:currentColor; pointer-events:none; transform-origin:right; scale: 0 1; transition: scale
var(--duration-pen) var(--ease-entrance)`. On hover / `:focus-visible` of the link: `transform-origin:
left; scale: 1 1`. So the hairline runs out from the left under the word, and on leave runs on off to
the right.

### The cursor ripple (WebGL) — over every photo

Four pictures ripple like water under a mouse pointer: Block 02's frame canvas (live), the chapters
harvest photo, each shelf card's backdrop photo, and the footer flower. Each gets a ripple **host**
`<div aria-hidden style="pointer-events:none; position:absolute; inset:0; opacity:0">` as the last
child of the picture's box. Only on `matchMedia("(hover: hover) and (pointer: fine)")`, and never with
reduced motion (then no host at all). Wherever the pointer moves, rings spread from it and push the
picture's pixels along their radius; a moving pointer leaves a wake. At rest the host is hidden and the
picture itself shows — an idle page draws nothing.

```js
const MAX_RIPPLES = 14;      // concurrent ripples (matches the shader)
const SPAWN_DISTANCE = 26;   // CSS px of pointer travel between two spawns
const RIPPLE_LIFE = 1.5;     // s
const NEVER = -1000;
const MAX_DPR = 2;
```

Vertex shader (verbatim):

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

Fragment shader (verbatim; `MAX_RIPPLES` is 14 and `LIFE` is `1.5`):

```glsl
  precision highp float;

  #define MAX_RIPPLES 14

  varying vec2 vUv;

  uniform sampler2D uTexture;
  uniform vec2 uResolution;
  uniform vec2 uMedia;
  uniform float uTime;
  uniform float uIntensity;
  uniform vec3 uRipples[MAX_RIPPLES];

  const float SPEED = 340.0;
  const float LIFE = 1.5;
  const float WIDTH = 90.0;
  const float AMPLITUDE = 24.0;
  const float FREQ = 0.055;

  /** Maps the box's uv onto the texture the way CSS object-fit: cover does. */
  vec2 coverUv(vec2 uv) {
    float canvasAspect = uResolution.x / uResolution.y;
    float mediaAspect = uMedia.x / uMedia.y;
    vec2 scale = canvasAspect > mediaAspect
      ? vec2(1.0, mediaAspect / canvasAspect)
      : vec2(canvasAspect / mediaAspect, 1.0);
    return (uv - 0.5) * scale + 0.5;
  }

  /** Keeps lookups off the outermost texels, so a push past the border never smears the edge. */
  vec2 safeUv(vec2 uv) {
    vec2 guard = 1.5 / uMedia;
    return clamp(uv, guard, 1.0 - guard);
  }

  void main() {
    vec2 fragment = vUv * uResolution;
    vec2 offset = vec2(0.0);

    for (int i = 0; i < MAX_RIPPLES; i++) {
      vec3 ripple = uRipples[i];
      float age = uTime - ripple.z;
      if (age < 0.0 || age > LIFE) continue;

      vec2 delta = fragment - ripple.xy;
      float dist = length(delta);
      if (dist < 0.001) continue;

      float radius = age * SPEED;
      float envelope = exp(-pow((dist - radius) / WIDTH, 2.0));
      float decay = 1.0 - age / LIFE;
      float wave = sin((dist - radius) * FREQ);

      offset += (delta / dist) * wave * envelope * decay * decay * AMPLITUDE;
    }

    vec2 uv = coverUv(vUv + (offset * uIntensity) / uResolution);
    gl_FragColor = texture2D(uTexture, safeUv(uv));
  }
```

Setup: a fresh `<canvas style="position:absolute; inset:0; width:100%; height:100%">` inside the host;
`getContext("webgl", { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil:
false })`; a full-screen `TRIANGLE_STRIP` quad `[-1,-1, 1,-1, -1,1, 1,1]` on `aPosition`; one texture,
first filled with one transparent texel `[0,0,0,0]` so it is complete from the first draw,
`CLAMP_TO_EDGE` both ways, `LINEAR` min/mag, `UNPACK_FLIP_Y_WEBGL = true`,
`UNPACK_PREMULTIPLY_ALPHA_WEBGL = true`. If WebGL is unavailable, there is simply no ripple. On
`webglcontextlost` call `preventDefault()` and rebuild on a fresh canvas.

- **Intensity spring** `{ tension: 120, friction: 26 }`: 1 while the pointer is inside the picture's box,
  0 when it leaves (the distortion eases in and out).
- **Pointer** (`pointermove` on `window`, passive; ignored for `pointerType === "touch"` and while the box
  is out of view per an `IntersectionObserver`): map into the box's own *unscaled* pixels, y up — `x =
  (clientX − rect.left) × box.offsetWidth / rect.width`, `y = box.offsetHeight − (clientY − rect.top) ×
  box.offsetHeight / rect.height` (the pictures sit under scaled / zoomed layers). If it moved at least
  `SPAWN_DISTANCE` from the last spawn, write `(x, y, now)` into the next of the 14 slots (round robin);
  `now` is seconds since setup. Unused slots hold time `NEVER`.
- **Every frame**: if there is no scene, or more than `RIPPLE_LIFE` s since the last spawn, or intensity
  `< 0.001` → hide (host `opacity: 0`, the picture's own opacity restored) and draw nothing. Otherwise
  find the picture — the first matching `<img>` (complete, with a natural width) or `<canvas>` (with a
  width) in the host's parent box that is laid out (`offsetWidth > 0`) — size the canvas to the box ×
  `min(2, devicePixelRatio)`, upload the picture with `texImage2D` (every frame when **live**, else
  only when it changed), set `uMedia` = natural size (canvas: `width/height`), `uResolution` = box
  `offsetWidth/offsetHeight`, `uTime`, `uIntensity`, `uRipples` (`uniform3fv`), clear and draw — then
  show: host `opacity: 1` **and the picture under it `opacity: 0`** (a picture with alpha, like the footer
  flower, drawn twice would read denser).

## The loader / reveal — black curtains, then the hero (the hero moment)

The intro is one state machine with four flags, in this order: **`counted`** (the counter has run out
and gone: scrolling is released and the photo starts its push-in), **`opened`** (the curtains have
begun to part: the hero title starts), **`done`** (the hero copy has risen: the header comes in) — plus
**`frames`**, the share (0–1) of the first `INTRO_FRAMES = 40` frames of Block 02 that have settled
(loaded or failed), reported by the frame loader.

### Scroll is held from the first paint

```css
@media (scripting: enabled) {
  html:has([data-intro-lock]) { overflow: hidden; scrollbar-gutter: stable; }
}
```

Serve `<span hidden data-intro-lock></span>` inside the hero and remove it when `counted`. Until then
also `lenis.stop()` (Lenis scrolls the page itself) and cancel `wheel` and `touchmove` (listeners with
`{ passive: false }` → `preventDefault()`), and `keydown` for `ArrowUp ArrowDown PageUp PageDown Home End
" "` unless the target is inside `input, textarea, select, button, a, [contenteditable]`. Release all of
it on `counted`. A scroll that still gets through (`scrollY > 0` — a dragged scrollbar, a restored
position) **skips the intro**: everything jumps to its end state at once, the curtains are removed and
`counted`, `opened`, `done` are all set.

### The curtains — markup

```html
<div data-curtain aria-hidden style="pointer-events:none; position:fixed; inset:0; z-index:60; overflow:hidden">
  <noscript><style>[data-curtain]{display:none}</style></noscript>
  <div class="curtain-top"    style="position:absolute; left:0; right:0; top:0;   bottom:50%; background:#000; will-change:transform"></div>
  <div class="curtain-bottom" style="position:absolute; left:0; right:0; top:50%; bottom:0;   background:#000; will-change:transform"></div>
  <div class="seam"           style="position:absolute; left:0; right:0; top:50%; height:1px; background:var(--on-media-subtle)"></div>
  <span class="counter"       style="position:absolute; top:50%; left:50%; margin-top:24px; translate:-50% 0; opacity:0;
        white-space:nowrap; font-variant-numeric:tabular-nums; color:var(--on-media-muted)">FORMULA NO. 04 — 000</span>
</div>
```

The counter is **caps** (Jost 500, 12, tracking 0.2em, uppercase); its `margin-top: 24px` is design px
(1.5rem). It sits on the bottom curtain just under the seam.

### The curtains — timing (current values; keep them)

```js
const LINE_MS = 325;           // the hairline draws out
const CAP_MS = 1500;           // never hold the curtains closed longer than this; frames keep streaming
const FONT_SHARE = 0.15;
const COUNTER_IN_MS = 150;
const COUNTER_FINISH_MS = 250;
const COUNTER_OUT_MS = 200;
const HOLD_MS = 60;
const PART_MS = 650;
const REVEAL_AT = 0.12;        // share of the parting at which the hero title starts
const LABEL = "Formula No. 04";
```

Two springs objects: `line` / `part` (both 0), and the counter's `p` (0, config `{ tension: 90,
friction: 26 }`) and `o` (0).

- `seam.transform = translate3d(0, -50%, 0) scaleX(line)`; `seam.opacity = part > 0 ? 0 : 1`.
- `curtain-top.transform = translate3d(0, ${−part × 100}%, 0)`; `curtain-bottom.transform =
  translate3d(0, ${part × 100}%, 0)`.
- `counter.opacity = o`; its text = `` `${LABEL} — ${String(Math.round(p * 100)).padStart(3, "0")}` ``
  (shown uppercase), e.g. `FORMULA NO. 04 — 087`.

Sequence:

1. At once: `line → 1` with `{ duration: LINE_MS, easing: easeInOutCubic }`; `o → 1` with `{ duration:
   COUNTER_IN_MS }` (linear).
2. **Loading progress** `target = FONT_SHARE × fontsReady + (1 − FONT_SHARE) × frames`, where
   `fontsReady` becomes 1 when `document.fonts.ready` resolves. Every time `target` changes, `p` chases
   it with its spring `{90, 26}` — a needle, never a jump.
3. A ticker gate (every 100 ms) waits until `elapsed ≥ LINE_MS` **and** (the page is loaded —
   `target ≥ 1` and the shown `p > 0.99` — **or** `elapsed ≥ CAP_MS`). Then, once:
4. `p → 1` with `{ duration: COUNTER_FINISH_MS, easing: easeOutCubic }`; then `o → 0` with `{ duration:
   COUNTER_OUT_MS }`; then **`counted`** (scroll released, the photo push-in starts — see Block 02).
5. Then `part → 1` (and `line` stays 1) with `delay: HOLD_MS`, `{ duration: PART_MS, easing:
   easeInOutQuart }`. The moment `part ≥ REVEAL_AT` → **`opened`**. When the parting ends, remove the
   curtain element.

### The hero — `<section>` in the hero block

```html
<section aria-labelledby="hero-title" style="pointer-events:none; position:relative; height:100svh; width:100%; overflow:hidden">
```

It has **no picture of its own**: it is a transparent sheet over Block 02's pinned first video frame
(the same still), so 01 → 02 has no seam — the copy scrolls away, the photo stays. Three layers:

**Title `<h1 id="hero-title">Marguerite Fauve</h1>`** — `position:absolute; top:50%; left:50%;
margin-top:-59` (laptop `-45`, tablet `-35`, mobile `-21`), `translate: -50% -0.5em; scale:
var(--stage-fit)`, `padding: 0.5em 0.25em` (room for the script's flourishes and the blur), **script**
168 (laptop 128, tablet 100, mobile 60), `line-height:1; white-space:nowrap; color:#ffffff`, **trim**.
Its text is a **blur-in** (inline, nowrap), shown when **`opened`**: "Marguerite Fauve" resolves letter
by letter, 36 ms apart.

**Locale `<p>Fr / En</p>`** — `position:absolute; top:100%; left:48; margin-top:-59` (laptop `left:40;
margin-top:-64`, tablet `left:32; margin-top:-58`, hidden on mobile), **serif** 21 (laptop 22, tablet
24), `letter-spacing:-0.01em; color:var(--on-media)`.

**Tagline** — a wrapper `position:absolute; top:100%; left:50%; margin-top:-59; translate:-50% 0`
(laptop `-64`, tablet `-58`, mobile `-72`) holding `<p>` **serif** 22 (laptop 22, tablet 24, mobile
20), `white-space:nowrap; color:var(--on-media-tagline)`, text "a small perfume house, working one
harvest at a time". On mobile the `<p>` is `width:320; text-align:center` and shows instead two block
lines: "a small perfume house," / "working one harvest at a time".

On compact ranges the locale and tagline carry `text-shadow: var(--on-media-shadow)`.

**The rise.** Locale and tagline share one spring: from `opacity:0; transform: translate3d(0, 0.75rem,
0)` to `opacity:1; translate3d(0,0,0)`, `{ duration: 450, easing: easeOutCubic }`, **delay 350 ms from
`opened`**. When it comes to rest → **`done`** (the header enters).

So the full intro reads: black screen → a hairline draws across the middle (0.33 s) while the counter
fades in and climbs → at 100 it settles, fades, and the photo behind begins a 1.6 s push-in → 60 ms
later the curtains part over 0.65 s → at 12 % of the parting the script name starts to resolve → 350 ms
later tagline and locale rise → then the header slides down with its links blurring in one after another.

## The header (fixed, over every block)

```html
<header data-tone="dark" data-shown="false" data-layout="column" data-over=""
  style="pointer-events:none; position:fixed; left:0; right:0; top:0; z-index:50; height:180; color:var(--header-ink);
         transition: color var(--duration-normal) var(--ease-entrance)">
```

`height: 180` in the column layout, `72` in the row layout. Its own spring `{ tension: 260, friction: 32 }`
drives `opacity` (0 → 1) and `y` (`transform: translate3d(0, ${y}%, 0)`, −100 = slid out above the
screen); it starts at `opacity 0, y −100`.

**Ink** (`color`): `--header-ink` (#f9f9f9) by default; `[data-tone=light]` → `--header-ink-light`
(#131313); `[data-layout=row][data-tone=photo]` → `--header-ink-light`, **except on laptop**, where it stays
`--header-ink` with `text-shadow: var(--on-media-shadow)`. On compact ranges in the column layout the
header text carries `text-shadow: var(--on-media-shadow)`.

**Backgrounds** (three `aria-hidden` layers, first children, all `opacity:0` with `transition: opacity
var(--duration-normal) var(--ease-entrance)`):

1. Paper strip — `position:absolute; inset:0; border-bottom:1px solid var(--header-sheet-rule);
   background:var(--header-sheet); backdrop-filter:blur(18px)`. Opacity 1 when `row` + tone `light`, or
   `row` + tone `photo` (but not on laptop).
2. Red strip — same, with `border-bottom:1px solid var(--header-sheet-rule-dark);
   background:var(--header-sheet-dark)`. Opacity 1 when `row` + tone `dark`.
3. Scrim — `position:absolute; left:0; right:0; top:0; height:180%; background:linear-gradient(to bottom,
   var(--header-scrim), transparent)`. Opacity 1 only on **laptop** in `row` + tone `photo`.

**Contents** (each interactive part gets `pointer-events:auto` only while `[data-shown=true]`):

- **Nav** `<nav aria-label="Main">` — `position:absolute; top:24; left:48` (row: `top:22`; laptop
  `left:40`, **hidden on laptop in the row layout**; hidden on handheld). `<ul>` is a flex column, `gap:2`,
  **serif** 24 (laptop 22), `line-height:1.18; letter-spacing:-0.01em`; in the row layout a flex row with
  `gap:28`. Five pen links: **Perfumes** `#` · **Discovery set** `#discovery` · **Atelier** `#atelier` ·
  **Journal** `#` · **Stockists** `#`.
- **Menu toggle** `<button aria-expanded aria-controls="site-menu">Menu</button>` — `position:absolute;
  top:24; left:32; display:none`, **serif** 24, lh 1.18, tracking −0.01em; row `top:22`; laptop `left:40`,
  22, shown (`display:block`) **only in the row layout**; handheld shown always; mobile `left:20`, 20.
- **Label** `<p>` — `position:absolute; top:30; left:50%; margin-left:-77`, **caps** 12 (compact 13), colour
  `--header-label` (tone light → `--header-label-light`; row + photo → `--header-label-light`, except on
  laptop → `--header-label`). Row: `top:28`. Laptop `top:29; margin-left:-86` (row: `-60`); tablet
  `top:28; margin-left:-60`; mobile `top:26; margin-left:-60`. Text "Herbier Blanc, Lyon"; on handheld,
  and on laptop in the row layout, "Herbier Blanc" instead.
- **Bag** pen link "Bag (0)" `#` — `position:absolute; top:24; right:48`, **serif** 24, lh 1.18, tracking
  −0.01em; row `top:22`; laptop `right:40`, 22; tablet `right:32`, 24; mobile `right:20`, 20.

**Entrance** (on **`done`**): the header springs in (see below), and the five nav links rise out of a blur
as a **trail** — each `<li>` has `o` (0 → 1), `y` (0.6 → 0, `transform: translate3d(0, ${y}em, 0)`) and `b`
(6 → 0, `filter: blur(${b}px)`, `none` below 0.01), config `{ tension: 170, friction: 24 }`; link 0
springs to the target, and every frame link *i* springs toward link *i − 1*'s current values (that chase
is the stagger).

**Tone** (a ticker job every 100 ms): the probe line is `2.25rem` from the top. Walk every
`[data-header-tone]` element **in reverse document order** (sheets overlap: the last one under the probe
is on top) and take the first with `top ≤ probe < bottom`: `data-tone` = `"photo"` if it is the stage
block, else its `data-header-tone`; `data-over` = `"stage"` for the stage block, else `""` (fire an update
when `data-over` changes).

**Show / hide** (from `done` on):

```js
const HOVER_REM = 7.5, TOP_ZONE = 24, DIRECTION_SLOP = 6;
update():
  if (swapping) return;
  const focused = header.contains(document.activeElement);
  const wantRow = scrollY >= innerHeight;                                 // the hero only ever has the column
  const overStage = handheld && header.dataset.over === "stage";          // 768 / 390: no header over Block 02
  const pinned   = laptop   && header.dataset.over === "stage";           // 1024: the row stays put over Block 02
  const wantShown = scrollY <= TOP_ZONE || focused || pinned || ((scrolledUp || hovered) && !overStage);
  if (wantRow !== row) {
    if (!shown) setLayout(wantRow);
    else { swapping = true; shown = false; data-shown = false;
           spring to { opacity: 0, y: -100 }, onRest: () => { swapping = false; setLayout(wantRow); update(); };
           return; }                                                      // out → swap → in, never a jump
  }
  if (wantShown !== shown) { shown = wantShown; data-shown = wantShown; spring to wantShown ? {1, 0} : {0, -100}; }
```

`scrolledUp` is updated on `scroll` only when `|Δy| ≥ DIRECTION_SLOP`; `hovered` = pointer within
`HOVER_REM` rem of the top edge (`pointermove`), false on `pointerleave` of `<html>`; also update on
`focusin`, on `focusout` (next frame) and when `data-over` changes.

### The menu sheet (handheld, and laptop in the row layout)

Clicking **Menu** opens a paper sheet that drops from the top over the page; "Close" is **written by pen**
where "Menu" stood, then the links are written one by one, and the footer gerbera comes up in the corner,
graded rose-white on paper.

```html
<div id="site-menu" role="dialog" aria-modal="true" aria-label="Menu" inert
  style="position:fixed; left:0; right:0; top:0; z-index:100; height:100dvh; overflow:hidden; overscroll-behavior:contain;
         background:var(--menu-surface); color:var(--menu-foreground)">
  <svg aria-hidden width="0" height="0" style="position:absolute">
    <filter id="site-menu-flower-grade" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"/>
      <feComponentTransfer>
        <feFuncR type="table" tableValues="0.776 0.937 0.961"/>
        <feFuncG type="table" tableValues="0.420 0.773 0.961"/>
        <feFuncB type="table" tableValues="0.373 0.722 0.961"/>
      </feComponentTransfer>
    </filter>
  </svg>
  …
</div>
```

- **Sheet spring** `{ tension: 210, friction: 32 }` on `y`: 0 open, −100 closed;
  `transform: translate3d(0, ${y}%, 0)`; `visibility: visible` while open or `y > −99.5`, else `hidden`.
  `inert` while closed.
- **Ink clock** `t`: on open, from 0 to 1 over `{ duration: 1700, easing: linear }` after `delay: 280`; on
  close, back to 0 **immediately** after a 500 ms delay. Line *i* is written with
  `mask-image = penMask(clamp01((t − i × 0.095) / 0.4))` (`STEP 0.095`, `STROKE 0.4`).
- **Gerbera** — `aria-hidden; pointer-events:none; position:absolute; right:-120; bottom:-190; height:647;
  width:470` (laptop and tablet `height:836; width:608`; laptop `right:-40; bottom:-160`; tablet
  `right:-80; bottom:-160`), holding `footer/footer-flower.webp` `width:100%; height:100%;
  object-fit:contain; filter:url(#site-menu-flower-grade)`. `opacity = min(1, t / 0.45)`;
  `transform = translate3d(0, ${((1 − min(1, t / 0.6)) × 2).toFixed(3)}rem, 0) rotate(6deg)`.
- **Inner column** `data-lenis-prevent` — `position:relative; display:flex; flex-direction:column;
  height:100%; overflow-y:auto; overscroll-behavior:contain; padding:0 32 calc(2rem +
  env(safe-area-inset-bottom))` (laptop side padding 40, mobile 20). It holds:
  - **Close** `<button>` — `position:absolute; top:24; left:32` (laptop 40, mobile 20), **serif** 24
    (laptop 22, mobile 20), lh 1.18, tracking −0.01em; inside, a `.sr-only` "Close" and an `aria-hidden`
    span written as line 0: **script**, `font-size:1.75em; line-height:1; display:inline-block;
    padding:0.5em 0.25em; margin:-0.5em -0.25em`, text "Close". Clicking closes and returns focus to the
    toggle.
  - **Label** `<p>` "Herbier Blanc" — `position:absolute; top:28; left:50%; margin-left:-60` (mobile
    `top:26`), **caps** 12, colour `--menu-foreground-muted`.
  - **Bag** `<a href="#">Bag (0)</a>` — `position:absolute; top:24; right:32` (laptop 40, mobile 20), same
    type as Close's row.
  - `<nav aria-label="Main">` — `margin-top:120` (laptop 128, tablet 160); `<ul>` flex column `gap:6`,
    **serif** 44, lh 1.18, tracking −0.01em. Each of the five links is a pen link inside a span written as
    line *i + 1* (`display:inline-block; padding:0.5em 0.25em; margin:-0.5em -0.25em`); clicking a link
    closes the sheet.
  - **Bottom row**, written as line 6 — `position:relative; margin-top:auto; display:flex;
    align-items:baseline; justify-content:space-between; border-top:1px solid var(--menu-rule);
    padding-top:18`, **serif** 20, lh 1.18: "Fr / En" · "Lyon · Grasse" (**caps** 12, muted) · a "Bag (0)"
    link.
- Open: scroll stopped (as above), focus moves to the first link (`preventScroll`), `Escape` closes.

## Layout & sections (in order)

### 01 Hero

See **The loader / reveal** above: the hero block holds only the transparent hero section.

### 02 Product stage — `<section aria-labelledby="product-title">` (scroll × video)

The stage starts at the very top of the page, **under the hero** (its block has `margin-top:-100svh`),
and is 400 `lvh` tall with a screen-tall sticky inner box: the first screen of scroll is the hero lifting
off (**arrival**), the next two scrub a 5.17 s, 24 fps, 2304 × 1440 clip as **124 WebP frames on a
canvas** while the photo zooms and the envelope opens — and the storyboard runs on through the screen in
which the chapters sheet comes over the stage, so nothing stops while it is being covered. Every
per-frame write goes straight to the DOM.

```html
<section aria-labelledby="product-title" style="position:relative; height:400lvh; width:100%; background:var(--product-backdrop); --col-bg:var(--product-backdrop)">
  <div data-bleed-flow style="position:sticky; top:0; height:100lvh; width:100%; overflow:hidden">

    <!-- 1. Photo layer: covers the window (--stage-cover), punches in on the intro -->
    <div class="stage-photo" data-col style="position:absolute; top:50%; left:0; width:100%; height:900; translate:0 -50%; scale:var(--stage-cover)">
      <div class="stage-zoom" style="position:absolute; inset:0; transform-origin:50% 8.5933%; will-change:transform">
        <img src="product/frames/product-frame-001.webp" alt="" fetchpriority="high"
             style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover">
        <canvas data-frames width="2304" height="1440" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover"></canvas>
        <!-- ripple host (live, source = canvas[data-frames]) -->
        <!-- desktop + laptop: the bottle-mist box lives here (it zooms with the photo) -->
      </div>
    </div>

    <!-- 2. Media texture: film grain + dust over the photo -->
    <div aria-hidden style="pointer-events:none; position:absolute; inset:0">
      <div style="position:absolute; inset:0; background-image:url(texture/texture-grain.webp); background-size:160; opacity:.2; mix-blend-mode:overlay"></div>
      <img src="atelier/atelier-scratches-dust.svg" alt="" style="position:absolute; inset:0; width:100%; height:100%; max-width:none; opacity:.6; mix-blend-mode:screen">
    </div>

    <!-- 3. Copy + paper goods: fit the window height (--stage-fit) -->
    <div data-col style="position:absolute; top:50%; left:0; width:100%; height:900; translate:0 -50%">
      <div class="stage-envelope" data-arrive="scroll" style="pointer-events:none; position:absolute; inset:0; transform-origin:right; scale:var(--stage-fit); opacity:0">…envelope…</div>
      <!-- handheld only: <div class="stage-front" style="pointer-events:none; position:absolute; inset:0; scale:var(--stage-fit)">…bottle-mist box…</div> -->
      <div class="stage-copy" data-arrive="scroll" style="position:absolute; inset:0; transform-origin:left; scale:var(--stage-fit); opacity:0">…copy…</div>
    </div>
  </div>
</section>
```

Per range, both `height:900` layers are laptop `768`, tablet `1024`, mobile `844`; the zoom layer's
`transform-origin` is laptop `50% 23.4375%`, tablet `50% 45.1172%`, mobile `50% 47.2749%`; on handheld the
copy layer's `transform-origin` is `center`. The grain's `background-size:160` is design px (10rem).

**The bottle box** (one bottle; it lives in the zoom layer on ≥ 896, and in its own `stage-front` layer —
in front of the photo and the envelope, under the copy — on handheld; move it when the range flips):
`position:absolute; left:50%; top:50%; translate:-50% -50%; scale:.8; height:745; width:427` (laptop
`524 × 300`, tablet `545 × 312`, mobile `437 × 250`), holding
`<img src="product/product-bottle-clear-2x.webp" crossorigin="anonymous" width="910" height="1590"
alt="Herbier Blanc Marguerite Fauve bottle, daisy-shaped glass stopper" style="position:relative;
width:100%; height:100%; opacity:0">`. It floats free — no table, shadow or reflection — and
**condenses out of mist** (see **The bottle mist** below).

#### Block 02 copy (in `.stage-copy`)

Every piece here waits for the stage's **arrival** flag (`stageArrived`, set in the scrub below), not for the block: the eyebrow, description and buy group are **fades**, the title a run of **blur lines**.

1. **Eyebrow** — fade, delay 200. Box `position:absolute; top:300; left:48` (laptop `top:271; left:40`;
   tablet `top:40; left:50%; translate:-50% 0`; mobile `top:28; left:50%; translate:-50% 0`). `<p>` **caps**
   12, `white-space:pre; color:var(--on-media-muted)`: "Formula No. 04  ·  Eau de parfum" (two spaces each
   side of the dot). On laptop it is two block lines of `line-height:20` instead: "Formula No. 04" /
   "Eau de parfum".
2. **Title `<h2 id="product-title">`** — `position:absolute; top:399; left:52` (laptop `top:347; left:44`;
   tablet `top:85`, mobile `top:65`, both `left:50%; translate:-50% 0; text-align:center`), **script** 114
   (laptop 80, tablet 96, mobile 64), `white-space:nowrap; color:var(--on-media)`, **trim**. Its text is
   **blur lines** "Marguerite" / "Fauve", each line `display:block; line-height:0.84`, nowrap, shown on
   arrival (line 2 starts 360 ms after line 1 — ten letters × 36).
3. **Description** — fade, delay **1500**. Box `position:absolute; top:602; left:48; width:280` (laptop
   `top:491; left:40; width:248`; tablet `top:254; width:340`, mobile `top:178; width:350`, both
   `left:50%; translate:-50% 0`). `<p data-stage-copy-end>` **serif** 22 (laptop / tablet 20, mobile 18),
   `line-height:1.28; color:var(--on-media)`, centred on handheld: "A daisy field at four in the afternoon,
   still warm." — on tablet as two block lines "A daisy field at four in the afternoon," / "still warm.".
4. **Buy group** — fade, delay **1650**. Desktop / laptop: `position:absolute; top:743; left:48;
   width:280; display:flex; flex-direction:column; gap:14` (laptop `top:619; left:40; width:248`).
   Handheld: one row at the foot of the *visible* screen — `top:auto; bottom:calc(50% + (50lvh - 100svh) /
   var(--stage-fit, 1) + 2rem); left:50%; translate:-50% 0; width:max-content; flex-direction:row;
   align-items:flex-end; gap:8`; on mobile additionally `transform-origin:bottom; scale:.81` (the 431-px
   row is wider than the column).
   - **Size picker** `<fieldset>` with `<legend class="sr-only">Size</legend>` and `<div role="radiogroup"
     aria-label="Size" style="display:flex; gap:8">` of two `role="radio"` buttons **"50 ML"** and
     **"100 ML"** (the first selected; click selects): `display:flex; flex:1; height:44; align-items:center;
     justify-content:center; padding:0 20; border:1px solid; background:var(--product-chip-glass);
     backdrop-filter: blur(12px) brightness(.75)`, **caps** 13, `line-height:20`, colour transition 250 ms
     `var(--ease-entrance)`. Selected: border and text `var(--on-media)`. Unselected: border
     `var(--on-media-border-quiet)`, text `var(--on-media-subtle)`, hover → `var(--on-media)`. Handheld:
     `height:52; flex:none`.
   - **Button** `<button data-stage-buy>` — `display:flex; height:52; width:280; align-items:center;
     justify-content:space-between; border:1px solid var(--action-inverse); background:var(--action-inverse);
     color:var(--action-inverse-foreground); padding:0 22`, **caps** 13, `line-height:20`; hover →
     `background:transparent; color:var(--on-media)` (250 ms, `var(--ease-entrance)`). Laptop `width:248`,
     handheld `width:224; flex-shrink:0`. Two spans: "ADD TO BAG" and "€140".

**Handheld bottle alignment.** On handheld the bottle stands midway between the end of the description
and the top of the buy row, so the space above and below it is equal. Measure in the copy layer's own
(unscaled) units and move the `stage-front` layer: on load, `document.fonts.ready`, `resize` and a
`ResizeObserver` on the copy layer:

```js
const box = copy.getBoundingClientRect();
const fit = box.height / copy.offsetHeight;
const above = (copy.querySelector("[data-stage-copy-end]").getBoundingClientRect().bottom - box.top) / fit;
const below = (copy.querySelector("[data-stage-buy]").getBoundingClientRect().top - box.top) / fit;
front.style.transform = `translate3d(0, ${((above + below) / 2 - copy.offsetHeight / 2).toFixed(1)}px, 0)`;
```

#### The envelope and letter (in `.stage-envelope`)

One drawing in the envelope's own **420 × 810** frame (design px), moved as a whole and in parts by the
scroll. All `<img>`s here are decorative SVGs (`alt=""`); "fill" means `position:absolute; inset:0;
width:100%; height:100%; max-width:none`.

```html
<div data-part="envelope" style="position:absolute; top:0; left:0; width:420; height:810; transform-origin:top left">
  <!-- back -->
  <div style="position:absolute; top:520; left:0; width:420; height:290">
    <div style="position:absolute; inset:-10.34% -16.67% -31.03% -11.9%"><img src="product/product-envelope-back.svg" style="display:block; width:100%; height:100%; max-width:none"></div>
  </div>
  <!-- the flap's open half (unfolds upward from the fold) -->
  <div data-part="flap-open" style="position:absolute; top:360; left:0; width:420; height:160; transform-origin:bottom; transform:scaleY(0)">
    <img src="product/product-envelope-flap-open.svg" class="fill">
  </div>
  <!-- the letter, clipped at the envelope's bottom fold only -->
  <div style="position:absolute; top:0; left:0; width:420; height:802; clip-path:inset(-100% -100% 0 -100%)">
    …letter…
  </div>
  <!-- front pocket: V-cut at the top, revealed once the flap opens -->
  <div style="position:absolute; top:520; left:0; width:420; height:290; overflow:hidden;
              mask-image:url(product/product-envelope-pocket-mask.svg); mask-size:100% 100%">
    <img src="product/product-envelope-paper.svg" class="fill">
    <img src="product/product-envelope-fibres.svg" class="fill">
    <div style="position:absolute; inset:0; background:linear-gradient(145.38deg, var(--stationery-light-from) 14.286%, var(--stationery-light-to) 85.714%); opacity:.3"></div>
    <p aria-hidden style="position:absolute; top:92; left:-58">…"Herbier Blanc" (pen)…</p>
    <p style="position:absolute; top:252; left:24">…note…</p>
    <img src="product/product-envelope-edge.svg" class="fill">
  </div>
  <!-- the flap's closed half (folds away downward from the fold) -->
  <div data-part="flap-closed" style="position:absolute; top:520; left:0; width:420; height:170; transform-origin:top">
    <div style="position:absolute; inset:-3.76% -3.41% -6.76% -3.41%"><img src="product/product-envelope-flap.svg" style="display:block; width:100%; height:100%; max-width:none"></div>
    <ul aria-hidden>…eight words…</ul>
  </div>
</div>
```

- **Printed name** on the pocket: **script** 250, `line-height:1; white-space:nowrap;
  color:var(--stationery-ink-script)`, text "Herbier Blanc", a **pen** (bleed) shown on the stage's
  arrival, delay 500, duration 1600.
- **Pocket note**: **caps** 8, `line-height:1.4; letter-spacing:0.12em; white-space:nowrap;
  color:var(--stationery-ink)`, two block lines "The formula is enclosed." / "Scroll to open.".
- **Flap words** — each an absolute `<li>`, **caps** 8, `letter-spacing:0.1em; white-space:nowrap;
  color:var(--stationery-ink)` at `left / top` (design px): "Bitter orange?" 44 / 14 · "Green apple?"
  112 / 30 · "Chamomile?" 70 / 47 · "Daisy?" 262 / 14 · "Honey?" 326 / 30 · "Hay?" 262 / 47 ·
  "Beeswax?" 178 / 106 · "Sandalwood?" 176 / 121.

**The letter** (`320 × 476`):

```html
<div data-part="letter" style="position:absolute; top:0; left:0; width:320; height:476; transform-origin:top left">
  <div style="position:absolute; inset:0 0.22% 0 0">
    <div style="position:absolute; inset:-3.78% -11.27% -11.34% -11.27%"><img src="product/product-letter-paper.svg" style="display:block; width:100%; height:100%; max-width:none"></div>
  </div>
  <img src="product/product-letter-fibres.svg" style="position:absolute; top:3; left:9; width:306; height:470; max-width:none">
  <div style="position:absolute; top:3; left:9; width:306; height:470; background:linear-gradient(123.07deg, var(--stationery-light-from) 14.286%, var(--stationery-light-to) 85.714%); opacity:.3"></div>
  <div style="position:absolute; top:30; left:38; width:252; display:flex; flex-direction:column; gap:16">
    <p>A note on Formula No. 04</p>
    <dl style="display:contents">
      <div style="display:flex; width:252; flex-direction:column; gap:4; border-bottom:1px solid var(--stationery-rule); padding-bottom:10">
        <dt>Top</dt><dd><span data-scroll-pen="0.25 0.42">bitter orange, green apple</span></dd>
      </div>
      … Heart · "roman chamomile, honey, hay" · data-scroll-pen="0.42 0.6"
      … Base  · "beeswax, sandalwood, immortelle" · data-scroll-pen="0.6 0.78"
    </dl>
    <p><span style="display:block">Composed in Grasse.</span><span style="display:block">Bottled in Lyon, batch 07.</span></p>
  </div>
</div>
```

Heading and footer `<p>`: **caps** 10, `letter-spacing:0.18em; color:var(--stationery-ink-muted)` (the
footer with `line-height:1.5`). `<dt>`: **caps** 9, tracking 0.18em, `var(--stationery-ink-subtle)`. `<dd>`:
**script** 34, `line-height:1.04; color:var(--stationery-ink)`; its span is `display:inline-block;
padding:0.5em 0.25em; margin:-0.5em -0.25em` and is masked by `penMask(p)` scrubbed by the scroll (below).

#### The storyboard (verbatim — the envelope's poses per range)

Values are design px of each frame; the stage converts them to rem.

```js
const STORIES = [
  {
    min: 1200,
    scale: 1,
    envelope: [
      { x: 1052.59, y: -127.75, r: 8 },
      { x: 1046.59, y: -57.75, r: 8 },
      { x: 1034.59, y: 32.25, r: 8 },
      { x: 1040.75, y: 237.15, r: 11 },
    ],
    letter: { x: 1052.99, y: 143.42, r: 4 },
  },
  // 1024 / 768 / 390: the storyboard envelope grown (×1.2 / ×1.2 / ×1.4) so its print stays legible
  {
    min: 896,
    scale: (276.316 / 420) * 1.2,
    envelope: [
      { x: 724.20, y: -38.77, r: 8 },
      { x: 719.46, y: 16.49, r: 8 },
      { x: 709.98, y: 87.54, r: 8 },
      { x: 714.84, y: 249.31, r: 11 },
    ],
    letter: { x: 730, y: 120, r: 4 },
  },
  {
    min: 700,
    scale: (276.316 / 420) * 1.2,
    envelope: [
      { x: 468.20, y: 104.23, r: 8 },
      { x: 463.46, y: 159.49, r: 8 },
      { x: 453.98, y: 230.54, r: 8 },
      { x: 458.84, y: 392.31, r: 11 },
    ],
    letter: { x: 498, y: 282, r: 4 },
  },
  {
    min: 0,
    scale: (230.263 / 420) * 1.4,
    envelope: [
      { x: 120.92, y: 381.69, r: 8 },
      { x: 116.33, y: 435.41, r: 8 },
      { x: 107.12, y: 504.50, r: 8 },
      { x: 111.83, y: 661.76, r: 11 },
    ],
    letter: { x: 140, y: 462, r: 4 },
  },
];

const STEPS = [0, 1 / 3, 2 / 3, 1];
const storyFor = (width) => STORIES.find((s) => width >= s.min) ?? STORIES[STORIES.length - 1];

// The free letter at 100 %, in the 100 % envelope's own frame (unrotated, 420×810 units).
const letterInEnvelope = (story) => {
  const env = story.envelope[3];
  const a = (-env.r * Math.PI) / 180;
  const dx = story.letter.x - env.x;
  const dy = story.letter.y - env.y;
  return {
    x: (dx * Math.cos(a) - dy * Math.sin(a)) / story.scale,
    y: (dx * Math.sin(a) + dy * Math.cos(a)) / story.scale,
    r: story.letter.r - env.r,
  };
};

// The letter waits inside until the flap is open (0.15).
const letterKeys = (story) => [
  { at: 0, value: { x: 50, y: 540, r: 0 } },
  { at: 0.15, value: { x: 50, y: 540, r: 0 } },
  { at: 1 / 3, value: { x: 50, y: 398, r: 0 } },
  { at: 2 / 3, value: { x: 50, y: 178, r: 0 } },
  { at: 1, value: letterInEnvelope(story) },
];

const ZOOM_END = 4 / 3;                  // the photo zooms 100 → 133 % about its own origin
const FLAP_CLOSE = [0.04, 0.11];         // the closed half folds away
const FLAP_OPEN = [0.11, 0.18];          // the open half unfolds

const clamp01 = (v) => Math.min(1, Math.max(0, v));
const lerp = (a, b, t) => a + (b - a) * t;
const span = (p, [a, b]) => clamp01((p - a) / (b - a));

const poseAt = (keys, p) => {
  let i = 0;
  while (i < keys.length - 2 && p > keys[i + 1].at) i++;
  const from = keys[i];
  const to = keys[i + 1];
  const t = clamp01((p - from.at) / (to.at - from.at));
  return { x: lerp(from.value.x, to.value.x, t), y: lerp(from.value.y, to.value.y, t), r: lerp(from.value.r, to.value.r, t) };
};

const PX = 1 / 16;
const toTransform = ({ x, y, r }, scale = 1) =>
  `translate3d(${(x * PX).toFixed(4)}rem, ${(y * PX).toFixed(4)}rem, 0) rotate(${r.toFixed(3)}deg)` +
  (scale === 1 ? "" : ` scale(${scale.toFixed(5)})`);

const stageAt = (progress, width) => {
  const p = clamp01(progress);
  const story = storyFor(width);
  const envelope = story.envelope.map((value, i) => ({ at: STEPS[i], value }));
  return {
    zoom: lerp(1, ZOOM_END, p),
    envelope: toTransform(poseAt(envelope, p), story.scale),
    letter: toTransform(poseAt(letterKeys(story), p)),
    flapClosed: 1 - span(p, FLAP_CLOSE),
    flapOpen: span(p, FLAP_OPEN),
  };
};
```

#### The scrub — what one scroll position does

`value` is the section's own scroll progress: `clamp01(−rect.top / (rect.height − innerHeight))`
(0 at the page top, 1 after three screens), read every frame. `cover` is how far the chapters sheet has
come over the stage, `clamp01((innerHeight − chaptersRect.top) / innerHeight)` (a stack-frame job; re-run
`apply` when it changes by ≥ 0.0005). Write each style only when its string changed.

```js
const COPY_REVEAL = 0.6;      // share of the arrival at which the stage copy's reveals play
const ENVELOPE_FROM = 0.35;   // share of the arrival at which the envelope starts to open
const MIST_END = 0.7;         // block scroll by which the bottle has gathered out of mist
const INTRO_ZOOM = 0.4;
const INTRO_ZOOM_MS = 1600;

function apply(value) {
  const run = value + cover / 3;                          // 0 → 4/3: the storyboard runs on under the next sheet
  const arrival = Math.min(1, value * 3);                 // the first screen: the hero lifting off
  const progress = clamp01((run * 3 - 1) / 3);            // storyboard 0–100 %
  arrive(arrival);
  intro.back = arrival;                                   // the first screen of scroll gives the push-in back
  mist.start(Math.min(1, value / MIST_END));              // spring { tension: 90, friction: 26 }
  const s = stageAt(progress, innerWidth);
  const openFrom = ENVELOPE_FROM / 3;                     // the envelope's own clock
  const opening = clamp01((run - openFrom) / (4 / 3 - openFrom));
  const o = stageAt(opening, innerWidth);
  zoomLayer.style.transform = `scale(${s.zoom})`;
  envelope.style.transform = s.envelope;
  letter.style.transform = o.letter;
  flapClosed.style.transform = `scaleY(${o.flapClosed})`;
  flapOpen.style.transform = `scaleY(${o.flapOpen})`;
  for (const { el, from, to } of pens)                    // [data-scroll-pen="from to"]
    el.style.maskImage = el.style.webkitMaskImage = penMask(clamp01((opening - from) / (to - from)));
  const playback = Math.min(1, run / (4 / 3));            // the frames play from the very first scroll
  wanted = Math.round(playback * (124 - 1));
  draw(wanted);
}

// The copy and envelope scroll in from below with the page, one-for-one, over the pinned still.
function arrive(a) {
  const rest = 1 - a;
  for (const el of [envelopeLayer, copyLayer]) {          // the two [data-arrive="scroll"] layers
    el.style.opacity = rest < 1 ? "1" : "0";
    el.style.transform = rest > 0 ? `translate3d(0, calc(${rest.toFixed(4)} * 100lvh), 0)` : "none";
  }
  if (a >= COPY_REVEAL) stageArrived = true;              // latched: fires the copy fades, title blur and envelope pen
}
```

Also call `apply` once at load and whenever the viewport crosses `(width >= 700px)`, `(width >= 896px)`
or `(width >= 1200px)` (a tablet turned sideways re-poses the envelope without a scroll).

**The intro push-in.** A spring `z` (0) starts on **`counted`**: `z → 1`, `{ duration: INTRO_ZOOM_MS,
easing: easeInOutQuart }`. The photo layer (`.stage-photo`) gets `transform: scale(${1 + INTRO_ZOOM × z ×
(1 − back)})` every frame, on top of its static `translate` / `scale: var(--stage-cover)` — so the
photo punches in 40 % about the screen's centre as the curtains part, and the first screen of scroll
gives it back one-for-one, leaving the storyboard's own framing untouched.

#### The frame sequence on the canvas

- Frames: `product/frames/product-frame-NNN.webp`, `NNN` = 1-based, 3-digit (`001` … `124`), 2304 × 1440.
  On `(width < 896px)`, the lighter copy `product/frames/product-frame-sm-NNN.webp`, 1152 × 720 (set the
  canvas `width/height` to match; re-run the loader when that media query flips).
- The `<img>` under the canvas is frame 001 — the hero's photo and the first paint; the canvas covers it
  once a frame is drawn.
- **Loading**: once the section is within a screen (`IntersectionObserver`, `rootMargin: "100% 0px"` —
  at once, it is at the top), fetch frames in **batches of 4**, nearest the current `wanted` frame first
  (alternating `wanted + d`, `wanted − d`), each with `fetch()` → `blob()`; a failed frame is skipped (its
  neighbour stands in). Each of the first 40 frames, when it settles, reports
  `frames = settled / 40` to the intro.
- **Decoding**: keep decoded (`createImageBitmap(blob)`) only the frames within **±10** of `wanted`; close
  bitmaps further than 12 away. Re-centre that window every frame `wanted` moves. When a bitmap arrives that
  is nearer `wanted` than the one drawn, redraw.
- **Drawing**: `draw(index)` picks the nearest decoded frame (`index − d`, then `index + d`, for d = 0, 1, …),
  and if it differs from the last drawn, `ctx.drawImage(bitmap, 0, 0, width, height)`.

#### The bottle mist (WebGL points) — the bottle condenses out of an aerosol

The bottle's own pixels — more than one droplet per CSS pixel, drawn one device pixel fine, lit as they
fly — are drawn in as one vortex fed from two opposite sides. Nothing fades in: the mist flies in from off
screen as two braids of fine threads — one from beyond the top-left corner of the screen, one from beyond
the bottom-right — tapering at both ends, the threads winding round one another as they flow, along
curves that bend the same way round the bottle and coil into it like the arms of a spiral; they meet in a
two-armed whirl and wind into the bottle; mid-flight each droplet curls on its own and eddies shear the
ribbons, and they land nearest-first, the ribbons draining in. A few droplets pass close to the lens as
soft bokeh discs that focus down as they land, and the photo itself pulls into focus through the settled
droplets. The mist thins out towards the canvas edges, so the layer's box never shows. Every position is
computed in the vertex shader from the progress: a frame costs a few uniforms and one draw call.

**Progress** is the `mist` spring from the scrub (`{ tension: 90, friction: 26 }` chasing
`min(1, value / 0.7)`): 0 at the top of the page, 1 once the block is 70 % scrolled — so the vortex has
the screen to itself for a while and the bottle lands late, with the copy, the envelope and the letter
already on screen.

Constants and shaders — **verbatim** (the shaders are JavaScript template literals; keep the
`${…}` interpolations, they bake the constants into the GLSL):

```js
/** The canvas reaches this far past the bottle box on each side (share of the box): room for the streams. */
const BLEED_X = 1.5;
const BLEED_Y = 0.7;
/** Sampling grid, CSS px: finer than a pixel, coarser only on a very big box. */
const MIN_STEP = 0.7;
const MAX_DROPLETS = 260000;
/** Share of droplets that pass close to the lens, and their disc size (CSS px). */
const BOKEH_SHARE = 0.02;
const BOKEH_MIN = 5;
const BOKEH_MAX = 16;
/** Cells more transparent than this are left out (the glass edge stays soft). */
const MIN_ALPHA = 24;
/** Every droplet has landed by this progress; the rest is the photo sharpening. */
const LAND = 0.88;
/** Share of the progress each droplet spends in flight; the rest of LAND staggers the starts. */
const FLIGHT = 0.55;
/** Barely a fade at the very start — the ribbons are off screen then anyway. */
const MIST_IN = 0.01;
/**
 * The ribbons fly in from off screen over this share of the progress, sliding
 * along their own curve, ENTRY_SPAN stream-lengths out at the start.
 */
const ENTRY_END = 0.38;
const ENTRY_SPAN = 0.9;
/**
 * Each stream is a braid of this many fine threads, each arriving LAG
 * stream-lengths after the one before. The threads wind round one another
 * (BRAID_TURNS crossings along the stream, BRAID_SPREAD stream-widths apart at
 * the widest), and the braid turns as it flows (BRAID_FLOW rad over the run).
 */
const STRANDS = 3;
const STRAND_LAG = 0.35;
const BRAID_TURNS = 2.5;
const BRAID_SPREAD = 2;
const BRAID_FLOW = 7;
/** The braid's width at the bottle and at the corner, as a share of the stream's own width there. */
const FLARE_NEAR = 0.3;
const FLARE_FAR = 1.5;
/**
 * Disorder, so the two streams are not mirror images and the braids are not
 * machine-made: each stream draws its own curve, coil, flare and braid pace
 * within ±ASYMMETRY of the design (and its corner angle within ±ANGLE_JITTER
 * rad); its line meanders (MEANDER rad, two waves); the braid's pace and width
 * drift along it; STRAY of the droplets fly loose of their thread, up to
 * STRAY_SPREAD times further; the second stream sets off STREAM_STAGGER later.
 */
const ASYMMETRY = 0.3;
const ANGLE_JITTER = 0.18;
const MEANDER = 0.35;
const STRAY = 0.2;
const STRAY_SPREAD = 5;
const STREAM_STAGGER = 0.18;
/** How loose a thread is about its line in the braid (stream-widths). */
const THREAD = 0.4;
/** The photo shows through from here on, as the settled droplets fade out. */
const SHARPEN = 0.86;
const MIST_OUT = 0.9;
/**
 * The vortex is fed by two streams from opposite corners of the screen (top
 * left, bottom right — measured, since the stage is scaled to cover the
 * window). Each droplet comes down the stream on its own side of the bottle's
 * diagonal (CROSS of them from the other one, so the two mix in the middle).
 */
const CROSS = 0.1;
/** Where a stream starts: this share of the way from the bottle to its corner. */
const STREAM_REACH = 0.98;
/** How far a stream bends round the bottle from its corner to the middle (rad). */
const STREAM_CURVE = 1.3;
/**
 * Extra bend that grows towards the bottle (rad, cubic in the way still to
 * go), with the inner part drawn in — so each stream coils into it like the
 * arm of a spiral rather than meeting it head on.
 */
const STREAM_COIL = 1.5;
/** Width of a stream: a share of its distance out, plus a share of the bottle width. */
const STREAM_WIDTH = 0.065;
const STREAM_CORE = 0.055;
/** How far the whole vortex turns as the droplets come in (rad); the streams curve the same way. */
const TWIST = 2.4;
/** Eddies wandering along the streams, two to each and one at the bottle. */
const EDDIES = 5;
/** Blur (px) of the photo as it first shows. */
const FOCUS_BLUR = 10;
/** Width of the fade at the canvas edges, as a share of its shorter side. */
const EDGE_FADE = 0.12;
/** Device-pixel ceiling for the canvas. */
const MAX_DPR = 2;
/** A droplet in flight: its size (device px), how far it is lit towards white, and the light it adds. */
const FLIGHT_SIZE = 1.7;
const FLIGHT_LIGHT = 0.6;
const FLIGHT_GLOW = 0.8;
/** Opacity of a droplet in flight (1 once landed): the braids read airy rather than solid. */
const FLIGHT_ALPHA = 0.55;

const VERTEX = `
attribute vec2 a_target;
attribute vec4 a_stream;
attribute vec4 a_motion;
attribute vec3 a_extra;
attribute vec4 a_color;
uniform vec2 u_size;
uniform vec2 u_center;
uniform vec2 u_stream0;
uniform vec2 u_stream1;
uniform vec4 u_shape0;
uniform vec4 u_shape1;
uniform float u_core;
uniform float u_p;
uniform float u_time;
uniform float u_mist;
uniform float u_cell;
uniform float u_dpr;
uniform vec4 u_eddy[${EDDIES}];
varying vec4 v_color;
varying float v_soft;

vec2 turn(vec2 v, float a) {
  float c = cos(a);
  float s = sin(a);
  return vec2(v.x * c - v.y * s, v.x * s + v.y * c);
}

void main() {
  // Its spot in its ribbon, which slides in along the stream's curve from
  // off screen: u (0 at the bottle, 1 at the corner) further out until the
  // ribbon has come in. Past the corner the curve carries on, off screen.
  vec2 stream = a_stream.w < 0.5 ? u_stream0 : u_stream1;
  // Its own curve, coil, flare and braid pace (each stream draws its own).
  vec4 shape = a_stream.w < 0.5 ? u_shape0 : u_shape1;
  // Smootherstep: the ribbons start and finish their entry with no jolt.
  float entry = clamp(u_p / ${ENTRY_END.toFixed(2)}, 0.0, 1.0);
  entry = entry * entry * entry * (entry * (entry * 6.0 - 15.0) + 10.0);
  float u = a_stream.x + (1.0 - entry) * (${ENTRY_SPAN.toFixed(2)} + a_stream.z);
  // The stream's line: bending round the bottle, coiling in near it.
  float inward = 1.0 - u;
  float r = stream.y * u * (0.82 + 0.18 * clamp(u, 0.0, 1.0));
  // …and a meander: two uneven waves along it, growing outwards.
  float meander = ${MEANDER.toFixed(2)} * u * (sin(u * 5.3 + stream.x * 3.0) + 0.6 * sin(u * 12.7 + stream.x * 7.0));
  float phi = stream.x - ${STREAM_CURVE.toFixed(2)} * shape.x * inward - ${STREAM_COIL.toFixed(2)} * shape.y * max(inward, 0.0) * inward * inward + meander;
  vec2 radial = vec2(cos(phi), sin(phi));
  vec2 across = vec2(-radial.y, radial.x);
  // Flared: fine at the bottle, widening out to the corner of the screen,
  // where it is widest (and wider still past it, off screen).
  float taper = ${FLARE_NEAR.toFixed(2)} + (${FLARE_FAR.toFixed(2)} * shape.z - ${FLARE_NEAR.toFixed(2)}) * pow(clamp(u, 0.0, 1.3), 1.2);
  float width = (r * ${STREAM_WIDTH.toFixed(3)} + u_core) * taper;
  // Its thread in the braid, winding round the others as the stream flows —
  // at a pace and a width that drift along it, so no two crossings match.
  float wave = u * ${(BRAID_TURNS * 2 * Math.PI).toFixed(3)} * shape.w + a_stream.y + u_p * ${BRAID_FLOW.toFixed(1)} + 0.9 * sin(u * 3.7 + a_stream.y * 1.3);
  float spread = ${BRAID_SPREAD.toFixed(2)} * (0.65 + 0.35 * sin(u * 4.3 + a_stream.y * 2.1));
  float braid = sin(wave) * spread;
  // Loose droplets also scatter along the stream, not only across it.
  vec2 spot = u_center + radial * (r + a_extra.z * width * 0.5) + across * (braid + a_extra.z) * width;
  // Un-turned: the vortex below turns it back by TWIST at the start of its flight.
  vec2 start = u_center + turn(spot - u_center, ${(-TWIST).toFixed(2)});

  float t = clamp((u_p - a_motion.x) / ${FLIGHT.toFixed(2)}, 0.0, 1.0);
  // Smootherstep: a droplet leaves its ribbon and settles with zero speed
  // and zero acceleration at both ends — the flow reads as one glide.
  float e = t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
  float rest = 1.0 - e;
  float rest2 = rest * rest;
  // Mid-flight only (none at the start, so the streams keep their shape,
  // none on landing): the droplet's own curl and the eddies.
  float mid = 4.0 * e * rest;
  // Its own curl: the way still to go, turned about its landing spot.
  vec2 pos = a_target + turn((start - a_target) * rest, a_motion.y * mid);
  // The vortex: everything turns about the bottle, unwinding as it lands.
  pos = u_center + turn(pos - u_center, ${TWIST.toFixed(2)} * rest2);
  // Eddies: a turn that fades with the distance from each centre.
  for (int k = 0; k < ${EDDIES}; k++) {
    vec2 d = pos - u_eddy[k].xy;
    pos = u_eddy[k].xy + turn(d, u_eddy[k].w * mid / (1.0 + dot(d, d) * u_eddy[k].z));
  }
  float a = a_motion.z + u_time * a_motion.w;
  pos += vec2(sin(a), cos(a * 0.8)) * a_extra.x * rest;

  // Thin out towards the canvas edges, so the layer's box never shows.
  vec2 margin = min(pos, u_size - pos) / (min(u_size.x, u_size.y) * ${EDGE_FADE.toFixed(2)});
  float edge = smoothstep(0.0, 1.0, min(margin.x, margin.y));
  vec2 clip = pos / u_size * 2.0 - 1.0;
  gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
  // A fine bright speck in flight; a landed droplet covers its cell, so the
  // settled mist is solid.
  float size = mix(${FLIGHT_SIZE.toFixed(2)}, u_cell, smoothstep(0.6, 1.0, e));
  // Close to the lens: a soft disc that focuses down to a droplet as it lands.
  float bokeh = step(0.5, a_extra.y) * rest;
  size = mix(size, a_extra.y * u_dpr, bokeh);
  gl_PointSize = size;
  v_soft = bokeh;
  // Droplets in flight catch the light — lit towards white, and adding light
  // (premultiplied colour above its alpha) so the braids glow over the photo.
  vec3 rgb = mix(a_color.rgb, vec3(1.0), ${FLIGHT_LIGHT.toFixed(2)} * rest);
  float alpha = a_color.a * u_mist * edge * (${FLIGHT_ALPHA.toFixed(2)} + ${(1 - FLIGHT_ALPHA).toFixed(2)} * e) * mix(1.0, 0.35, bokeh);
  v_color = vec4(rgb * alpha * (1.0 + ${FLIGHT_GLOW.toFixed(2)} * rest), alpha);
}
`;

const FRAGMENT = `
precision mediump float;
varying vec4 v_color;
varying float v_soft;
void main() {
  float disc = 1.0 - smoothstep(0.15, 1.0, length(gl_PointCoord - 0.5) * 2.0);
  gl_FragColor = v_color * mix(1.0, disc, v_soft);
}
`;
```

The scene — sampling the bottle into droplets, the two streams, the eddies, the per-frame draw
(verbatim apart from types; `releaseContext(gl)` is `gl.getExtension("WEBGL_lose_context")?.loseContext()`):

```js
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const smooth = (a, b, v) => {
  const t = clamp01((v - a) / (b - a));
  return t * t * (3 - 2 * t);
};
/** Standard normal from two uniforms (Box–Muller). */
const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
const between = (a, b) => a + Math.random() * (b - a);

const compile = (gl, type, source) => {
  const shader = gl.createShader(type);
  if (!shader) throw new Error("bottle-mist: no shader");
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) ?? "bottle-mist: shader");
  return shader;
};

/**
 * Main-thread time per slice of the layout job (ms). Sampling the bottle and
 * filling up to MAX_DROPLETS droplets was 150 ms in one task on a 4× phone,
 * inside the page's hydration (the phone's TBT). Sliced, one per frame, it
 * finishes under the preloader curtain, long before the first scroll draws
 * the mist.
 */
const FILL_SLICE_MS = 6;

/**
 * Floats per droplet: target (2), stream (4: place along it, its thread's
 * phase in the braid, entry lag, which stream), motion (4: delay, curl, phase,
 * rate), extra (3: reach, bokeh size, looseness about its thread).
 */
const STRIDE = 13;

const createMistScene = (box, img) => {
  const canvas = document.createElement("canvas");
  canvas.setAttribute("aria-hidden", "true");
  canvas.style.cssText = "pointer-events:none; position:absolute; top:-70%; left:-150%; height:240%; width:400%";
  const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: true, antialias: false, depth: false });
  if (!gl) throw new Error("bottle-mist: no WebGL");

  const program = gl.createProgram();
  if (!program) throw new Error("bottle-mist: no program");
  gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
  gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) ?? "bottle-mist: link");
  gl.useProgram(program);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
  gl.clearColor(0, 0, 0, 0);

  const at = (name) => gl.getAttribLocation(program, name);
  const u = (name) => gl.getUniformLocation(program, name);
  const uniforms = {
    size: u("u_size"),
    center: u("u_center"),
    stream0: u("u_stream0"),
    stream1: u("u_stream1"),
    shape0: u("u_shape0"),
    shape1: u("u_shape1"),
    core: u("u_core"),
    p: u("u_p"),
    time: u("u_time"),
    mist: u("u_mist"),
    cell: u("u_cell"),
    dpr: u("u_dpr"),
    eddy: u("u_eddy"),
  };
  const data = gl.createBuffer();
  const colours = gl.createBuffer();
  const eddyValues = new Float32Array(EDDIES * 4);
  let layout = null;
  /** The fill in flight: a newer layout() supersedes it. */
  let generation = 0;
  let pending = 0;

  const bind = () => {
    const F = Float32Array.BYTES_PER_ELEMENT;
    gl.bindBuffer(gl.ARRAY_BUFFER, data);
    const fields = [["a_target", 2, 0], ["a_stream", 4, 2], ["a_motion", 4, 6], ["a_extra", 3, 10]];
    for (const [name, size, offset] of fields) {
      const loc = at(name);
      gl.enableVertexAttribArray(loc);
      gl.vertexAttribPointer(loc, size, gl.FLOAT, false, STRIDE * F, offset * F);
    }
    gl.bindBuffer(gl.ARRAY_BUFFER, colours);
    const colour = at("a_color");
    gl.enableVertexAttribArray(colour);
    gl.vertexAttribPointer(colour, 4, gl.UNSIGNED_BYTE, true, 0, 0);
  };

  const scene = {
    canvas,
    gl,
    // Sizes the canvas now; sampling and the droplet fill run in slices
    // (FILL_SLICE_MS a frame) and the mist stays undrawn until they land.
    layout() {
      const bw = Math.round(img.clientWidth);
      const bh = Math.round(img.clientHeight);
      if (!bw || !bh || !img.naturalWidth) return false;
      const ox = Math.round(bw * BLEED_X);
      const oy = Math.round(bh * BLEED_Y);
      const W = bw + ox * 2;
      const H = bh + oy * 2;
      const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
      canvas.width = Math.round(W * dpr);
      canvas.height = Math.round(H * dpr);
      gl.viewport(0, 0, canvas.width, canvas.height);
      // The window in canvas px: the stage is scaled to cover it, so the
      // streams start from the corners actually on screen. Read now — the
      // rest runs over the next frames.
      const rect = canvas.getBoundingClientRect();

      // Everything below is one resumable job: it yields every few hundred
      // pixels or droplets and the driver runs it FILL_SLICE_MS per frame.
      const work = function* () {
        const sample = document.createElement("canvas");
        sample.width = bw;
        sample.height = bh;
        const sctx = sample.getContext("2d", { willReadFrequently: true });
        if (!sctx) return null;
        sctx.drawImage(img, 0, 0, bw, bh);
        const src = sctx.getImageData(0, 0, bw, bh).data;
        yield;

        let opaque = 0;
        for (let k = 3; k < src.length; k += 4) {
          if (src[k] >= MIN_ALPHA) opaque++;
          if ((k & 4095) === 3) yield;
        }
        const step = Math.max(MIN_STEP, Math.sqrt(opaque / MAX_DROPLETS));
        const cells = [];
        for (let y = 0; y < bh; y += step) {
          for (let x = 0; x < bw; x += step) {
            if (src[(Math.floor(y) * bw + Math.floor(x)) * 4 + 3] >= MIN_ALPHA) cells.push(x, y);
          }
          yield;
        }
        const count = cells.length / 2;
        const cx = ox + bw / 2;
        const cy = oy + bh / 2;
        const k2 = rect.width / W || 1;
        const clampX = (v) => Math.min(W, Math.max(0, v));
        const clampY = (v) => Math.min(H, Math.max(0, v));
        const vx0 = clampX(-rect.left / k2);
        const vx1 = clampX((window.innerWidth - rect.left) / k2);
        const vy0 = clampY(-rect.top / k2);
        const vy1 = clampY((window.innerHeight - rect.top) / k2);
        // The two streams: from the top-left and the bottom-right corner.
        const streams = [
          [vx0, vy0],
          [vx1, vy1],
        ].map(([x, y]) => ({
          angle: Math.atan2(y - cy, x - cx) + between(-ANGLE_JITTER, ANGLE_JITTER),
          reach: Math.hypot(x - cx, y - cy) * STREAM_REACH,
          // Curve, coil, flare, braid pace — each its own.
          shape: Array.from({ length: 4 }, () => between(1 - ASYMMETRY, 1 + ASYMMETRY)),
        }));
        // Which side of the bottle's diagonal (perpendicular to the streams) a droplet is on.
        const dx = Math.cos(streams[0].angle);
        const dy = Math.sin(streams[0].angle);
        const values = new Float32Array(count * STRIDE);
        const rgba = new Uint8Array(count * 4);
        // Flights start once the ribbons are on their way in.
        const delayFrom = ENTRY_END * 0.5;
        const fillOne = (i) => {
          const x = cells[i * 2];
          const y = cells[i * 2 + 1];
          const k = (Math.floor(y) * bw + Math.floor(x)) * 4;
          const o = i * STRIDE;
          // Anywhere in its cell, so the grid never shows.
          const tx = ox + x + Math.random() * step;
          const ty = oy + y + Math.random() * step;
          values[o] = tx;
          values[o + 1] = ty;
          const own = (tx - cx) * dx + (ty - cy) * dy > 0 ? 0 : 1;
          // Where on the stream: u = 1 at its corner, 0 at the bottle (the
          // shader bends it round the bottle the way the vortex turns, so the
          // droplets coming in carry on along it). Which of its ribbons: set
          // apart across the stream, each coming in after the one before.
          const u = Math.pow(Math.random(), 0.8);
          const strand = Math.floor(Math.random() * STRANDS);
          values[o + 2] = u;
          values[o + 3] = (strand * 2 * Math.PI) / STRANDS;
          const which = Math.random() < CROSS ? 1 - own : own;
          values[o + 4] = strand * STRAND_LAG + which * STREAM_STAGGER + Math.random() * 0.12;
          values[o + 5] = which;
          // Nearest the bottle first: the ribbons drain into it.
          values[o + 6] = delayFrom + (LAND - FLIGHT - delayFrom) * (0.2 * Math.random() + 0.8 * u);
          // Its own curl mid-flight, mostly with the vortex.
          values[o + 7] = (Math.random() < 0.75 ? 1 : -1) * between(0.2, 0.6);
          values[o + 8] = Math.random() * Math.PI * 2;
          values[o + 9] = between(0.3, 0.8);
          values[o + 10] = between(4, 12);
          values[o + 11] = Math.random() < BOKEH_SHARE ? between(BOKEH_MIN, BOKEH_MAX) : 0;
          values[o + 12] = gauss() * THREAD * (Math.random() < STRAY ? between(2, STRAY_SPREAD) : 1);
          rgba[i * 4] = src[k];
          rgba[i * 4 + 1] = src[k + 1];
          rgba[i * 4 + 2] = src[k + 2];
          rgba[i * 4 + 3] = src[k + 3];
        };

        const next = {
          count,
          width: W,
          height: H,
          cx,
          cy,
          streams: streams.map((st) => [st.angle, st.reach]),
          shapes: streams.map((st) => st.shape),
          core: bw * STREAM_CORE,
          cell: Math.max(1, Math.ceil(step * dpr)),
          dpr,
          eddies: Array.from({ length: EDDIES }, (_, k) => {
            // Two along each stream (a third and two thirds of the way out),
            // the last at the bottle; all turning with the vortex.
            const stream = streams[k % 2];
            const u = k === EDDIES - 1 ? 0 : k < 2 ? 0.35 : 0.65;
            const phi = stream.angle - STREAM_CURVE * (1 - u) - STREAM_COIL * (1 - u) ** 3;
            const size = Math.max(bw * 0.3, stream.reach * 0.18);
            const r = size * between(0.8, 1.2);
            return {
              x: cx + Math.cos(phi) * stream.reach * u,
              y: cy + Math.sin(phi) * stream.reach * u,
              ax: size * between(0.2, 0.4),
              ay: size * between(0.2, 0.4),
              rate: between(0.5, 1.1),
              phase: Math.random() * Math.PI * 2,
              inv: 1 / (r * r),
              turn: between(0.9, 1.6),
            };
          }),
        };

        for (let i = 0; i < count; i++) {
          fillOne(i);
          if ((i & 255) === 255) yield;
        }
        gl.bindBuffer(gl.ARRAY_BUFFER, data);
        gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW);
        gl.bindBuffer(gl.ARRAY_BUFFER, colours);
        gl.bufferData(gl.ARRAY_BUFFER, rgba, gl.STATIC_DRAW);
        bind();
        return next;
      };

      // The canvas was resized (cleared): nothing is drawn until this job lands.
      layout = null;
      const own = ++generation;
      cancelAnimationFrame(pending);
      const job = work();
      const slice = () => {
        if (own !== generation) return;
        const end = performance.now() + FILL_SLICE_MS;
        for (;;) {
          const r = job.next();
          if (r.done) {
            layout = r.value;
            return;
          }
          if (performance.now() > end) break;
        }
        pending = requestAnimationFrame(slice);
      };
      pending = requestAnimationFrame(slice);
      return true;
    },
    draw(p, mist, time) {
      gl.clear(gl.COLOR_BUFFER_BIT);
      if (!layout || mist <= 0) return;
      const { eddies } = layout;
      eddies.forEach((e, k) => {
        // Where the eddies have wandered to this frame.
        eddyValues[k * 4] = e.x + Math.sin(time * e.rate + e.phase) * e.ax;
        eddyValues[k * 4 + 1] = e.y + Math.cos(time * e.rate * 0.7 + e.phase) * e.ay;
        eddyValues[k * 4 + 2] = e.inv;
        eddyValues[k * 4 + 3] = e.turn;
      });
      gl.uniform2f(uniforms.size, layout.width, layout.height);
      gl.uniform2f(uniforms.center, layout.cx, layout.cy);
      gl.uniform2f(uniforms.stream0, ...layout.streams[0]);
      gl.uniform2f(uniforms.stream1, ...layout.streams[1]);
      gl.uniform4f(uniforms.shape0, ...layout.shapes[0]);
      gl.uniform4f(uniforms.shape1, ...layout.shapes[1]);
      gl.uniform1f(uniforms.core, layout.core);
      gl.uniform1f(uniforms.p, p);
      gl.uniform1f(uniforms.time, time);
      gl.uniform1f(uniforms.mist, mist);
      gl.uniform1f(uniforms.cell, layout.cell);
      gl.uniform1f(uniforms.dpr, layout.dpr);
      gl.uniform4fv(uniforms.eddy, eddyValues);
      gl.drawArrays(gl.POINTS, 0, layout.count);
    },
    dispose() {
      generation++;
      cancelAnimationFrame(pending);
      releaseContext(gl);
      canvas.remove();
    },
  };
  // Behind the photo.
  box.insertBefore(canvas, img);
  return scene;
};
```

**The driver** (per bottle box; `img` is the bottle `<img>` inside it):

```js
const put = (el, prop, value) => { if (el.style[prop] !== value) el.style[prop] = value; };
const blur = (px) => (px < 0.25 ? "none" : `blur(${(Math.round(px * 4) / 4).toFixed(2)}px)`);

// Reduced motion, or no WebGL: a plain fade — img.style.opacity = clamp01(progress).toFixed(3) every frame.
await imgLoaded(img); await img.decode().catch(() => {});      // decode first: the sampling must not decode
const scene = createMistScene(box, img);                        // throws → fall back to the plain fade
scene.layout();                                                 // and again on ResizeObserver(img) when
                                                                // `${clientWidth}x${clientHeight}@${devicePixelRatio}` changed
let lastP = -1; const t0 = performance.now();
ticker.add((now) => {
  const t = (now - t0) / 1000;                                  // seconds
  const p = clamp01(mist.value);
  if (p === lastP && (p === 0 || p === 1)) return true;         // at rest at either end there is nothing to draw
  lastP = p;
  const shown = smooth(SHARPEN, 0.98, p);                       // the photo pulls into focus as it shows through
  put(img, "opacity", shown.toFixed(3));
  put(img, "filter", blur(FOCUS_BLUR * (1 - shown)));
  const mistAlpha = smooth(0, MIST_IN, p) * (1 - smooth(MIST_OUT, 1, p));
  scene.draw(p, mistAlpha, t);
  return true;
});
```

The layout job runs in slices of `FILL_SLICE_MS` per frame (it finishes under the preloader, long before
the first scroll). On `webglcontextlost` (`preventDefault()`), dispose and rebuild the scene on a fresh
canvas when the box nears the viewport again.

### 03 Chapters — `<section aria-labelledby="chapters-title">` (light sheet)

`position:relative; height:100lvh; width:100%; overflow:hidden; background:var(--chapter-surface);
color:var(--chapter-ink); --col-bg:var(--chapter-surface)`; tablet `height:1012`, mobile `height:792`.
On desktop the left half is paper and the right half is the harvest photo, the bottle straddling the
split; on 1024 / 768 / 390 the photo fills the block and the copy sits on a paper card the bottle
stands on.

**Photo** (`data-bleed`) — `position:absolute; top:0; left:720; height:100%; width:720` (compact `left:0;
width:100%`). Inside, a **parallax** frame with `range: -8` (the photo lags as the sheet comes up) holding
`<img src="chapters/chapters-harvest-photo.webp" alt="" crossorigin="anonymous">` (`position:absolute;
inset:0; width:100%; height:100%; object-fit:cover`) and a **ripple** host. Over it (outside the parallax)
a wash `position:absolute; inset:0; background:var(--chapter-photo-wash); mix-blend-mode:soft-light`.

**The frame** — every piece below sits in one absolutely positioned frame:
`position:absolute; left:0; right:0; top:50%; height:900; translate:0 -50%; transform-origin:center;
scale:var(--chapters-fit)`. Desktop: `top:0; height:100%; translate:none; scale:1` (never scaled — the
title, bottle and copy are placed by the window height through `--chapters-ratio`, the eyebrow 48 from
the top and the index 48 from the bottom). Laptop: `height:768` (still centred and fitted). Tablet:
`height:1024; top:-20; translate:none`. Mobile: `height:844; top:-72; translate:none`.

- **Text card** (compact only, `aria-hidden`, `background:var(--chapter-surface)`): laptop `top:360;
  left:40; height:360; width:944`; tablet `top:572; left:32; height:420; width:704`; mobile `top:484;
  left:20; height:340; width:350`.
- **Eyebrow** `<p>` "A bottle, in five chapters" — **caps** 12; `position:absolute; top:48; left:48`;
  laptop `top:396; left:50%; translate:-50% 0`; tablet `top:660; left:272`; mobile `top:544; left:83`.
- **Title** `<h2 id="chapters-title">` "{ the harvest }" — `position:absolute; left:0; width:720;
  text-align:center`, **script** 120, `line-height:1; white-space:nowrap; color:var(--chapter-title)`,
  **trim**. Desktop `top:calc(23.4375rem * var(--chapters-ratio, 1))`; laptop `top:434; width:1024`, 88;
  tablet `top:697; width:769`, 72; mobile `top:582; width:391`, 52. Text: a **blur-in** (inline, nowrap).
- **Body** `<p>` — `position:absolute; left:360; width:420; translate:-50% 0; text-align:center`, **serif**
  22, `line-height:1.4`. Desktop `top:calc(41.9375rem * var(--chapters-ratio, 1))`; laptop `top:540;
  left:512; width:600`, 20; tablet `top:772; left:384; width:500`, 20, `line-height:1.45`; mobile
  `top:642; left:195; width:310`, 18, `line-height:1.45`. Text: "Roman chamomile is cut in July around
  Chemillé, in Anjou. Only the flower heads are taken, in the morning, once the dew has gone."
- **Bottle shadows** (compact only, `aria-hidden`, each an SVG `<img>` filling its box): a soft cast
  shadow `chapters/chapters-bottle-shadow-768.svg` at laptop `top:359; left:421; height:27; width:185`,
  tablet `top:570; left:233; height:44; width:304`; a contact shadow
  `chapters/chapters-bottle-shadow-contact-768.svg` at laptop `top:368; left:442; height:8; width:142`,
  tablet `top:586; left:268; height:14; width:233`; on mobile instead
  `chapters/chapters-bottle-shadow-390.svg` at `top:496; left:82; height:32; width:226` and
  `chapters/chapters-bottle-shadow-contact-390.svg` at `top:507; left:108; height:11; width:174`.
- **Bottle** — box `position:absolute; left:623; height:339; width:194`, desktop
  `top:calc(16.375rem * var(--chapters-ratio, 1))`; laptop `top:48; left:415; height:341; width:195`;
  tablet `top:60; left:224; height:560; width:321`; mobile `top:112; left:75; height:420; width:240`.
  Inside, a **drift** with `range: 5` on desktop only (it floats over the split and runs ahead of the
  page; on the card layouts it stands still) holding `<img src="chapters/chapters-bottle-2x.webp"
  alt="Herbier Blanc bottle">` filling it, `object-fit:cover`.
- **Chapter index** `<ul>` — `position:absolute; left:48; width:624; display:flex; gap:26`, desktop
  `bottom:48`; laptop `top:654; left:80; width:864; gap:16`; tablet `top:920; left:76; width:auto;
  gap:14`; mobile `top:776; left:40; width:330; gap:16; overflow-x:auto; scrollbar-width:none`. Five
  `<li style="flex:1">` (tablet / mobile `width:112; flex:none`), each a `<button>` `width:100%;
  border-top:1px solid; padding-top:10; text-align:left`, **caps** 12, `white-space:pre`, colour
  transition 250 ms `var(--ease-entrance)`, text `"01  Harvest"`, `"02  Still"`, `"03  Formula"`, `"04  Rest"`,
  `"05  Glass"` (two spaces). The first is active (`aria-current="step"`): border and text
  `var(--chapter-ink)`; the others border `var(--chapter-rule)`, text `var(--chapter-ink-muted)`, hover →
  ink. Only chapter 01 has content; the index does not switch.
- **Index fade** (mobile only, `aria-hidden`, `pointer-events:none`) — `position:absolute; top:776;
  left:306; height:28; width:64; background:linear-gradient(to right, transparent, var(--chapter-surface))`
  (`transparent` here is the surface colour at 0 %).

### 04 Atelier — `<section aria-labelledby="atelier-title">` (red, lies under the chapters sheet)

`position:relative; height:100lvh; width:100%; overflow:hidden; background:var(--atelier-background);
--col-bg:var(--atelier-background)`; laptop `height:678`, tablet `1032`, mobile `1255`.

**Print texture** (all `aria-hidden`): a sheen `position:absolute; inset:0; background:var(--atelier-sheen)`
(`data-bleed`); the **uneven tone** (atelier radii) `position:absolute; inset:0; opacity:.4` (`data-bleed`);
the scratches + dust SVG per range, `position:absolute; top:0; left:0; width:100%; max-width:none` —
desktop `atelier/atelier-scratches-dust.svg` `height:860`, laptop `atelier/atelier-scratches-dust-1024.svg`
`height:676`, tablet `atelier/atelier-scratches-dust-768.svg` `height:1003`, mobile
`atelier/atelier-scratches-dust-390.svg` `height:1238` (each shown only on its range).

**Flower** (`aria-hidden`) — a grey gerbera screened over the red: `position:absolute; top:10; left:390;
height:1459; width:1060; opacity:.5; mix-blend-mode:screen`; laptop `top:7; left:277; height:1038;
width:754`; tablet `top:154; left:242; height:1167; width:848`; mobile `top:718; left:61; height:730;
width:530`; holding `atelier/atelier-flower.webp` filling it, `object-fit:cover`.

**The frame**: `position:absolute; left:0; right:0; top:50%; height:872; translate:0 -50%;
transform-origin:center; scale:var(--atelier-fit)`. **Desktop**: it fills the screen, never smaller than
Figma — `top:0; translate:none; transform-origin:top; --atelier-s: min(1, var(--atelier-ratio, 1));
--atelier-extra: max(0rem, calc(100lvh / var(--atelier-s) - 54.5rem)); height:calc(100lvh /
var(--atelier-s)); scale:var(--atelier-s)` — the headline stays 48 from the top, and a taller screen's
extra height (`--atelier-extra`) goes between the rule and the photo / copy / stats row (every element
marked "+extra" below gets `margin-top:var(--atelier-extra)` on desktop). Laptop `top:-6; height:676;
translate:none`; tablet `top:-3; height:1003; translate:none`; mobile `top:14; height:1238; translate:none`.
Inside, one `position:absolute; inset:0` layer holds:

- **Headline** `<h2 id="atelier-title">` — `position:absolute; top:48; left:48; width:1344`, **serif** 88,
  `line-height:98; letter-spacing:-0.02em; color:var(--atelier-ink)`. Laptop `top:54; left:40; width:944`,
  64 / lh 71, `letter-spacing:-1.76px` (design px, i.e. `-0.11rem`); tablet `top:43; left:32; width:704`,
  48 / lh 53, `-1.76px`; mobile `top:26; left:20; width:350`, 38 / lh 43 (`-0.02em`).
  - Desktop / laptop / tablet: line 1, a plain block (it stands still): "We release a perfume when it is"
    + a space + the script word; line 2, **blur lines** `["So far that is six in ten years."]` with a word
    gap of `0.14em` (the face's own space), on the block's arrival.
  - Mobile: line 1 "We release a perfume", line 2 "when it is" + space + script word (plain blocks); then
    blur lines `["So far that is six", "in ten years."]`, gap 0.14em.
  - The **script word** "finished." — `<span>`: **script**, `margin-inline-start:0.2em` (mobile 0.1em),
    size 150 (laptop 109, tablet 82, mobile 65), `line-height` = the headline's (98 / 71 / 53 / 43).
- **Underline** — a **pen** (no bleed), delay **900** (the blur line's last letter: 25 letters × 36),
  duration 900, box `position:absolute; top:238; left:395; height:20; width:411` (laptop `192 / 292 /
  15 / 299`, tablet `147 / 225 / 11 / 228`, mobile `195 / 20 / 7 / 147` — top / left / height / width),
  holding `atelier/atelier-hand-underline.svg` at the same height × width (`display:block; max-width:none;
  overflow:visible`). It is drawn under "six in ten years".
- **Rule** — `position:absolute; top:298; left:48; height:1; width:1344; background:var(--atelier-rule)`;
  laptop `top:238; left:40; width:944`; tablet `top:198; left:32; width:704`; mobile `top:234; left:20;
  width:350`.
- **Photo** (+extra) — `position:absolute; top:339; left:48; height:485; width:432; overflow:hidden`;
  laptop `top:278; left:40; height:358; width:270`; tablet `top:239; left:32; height:377; width:336`; mobile
  `top:262; left:20; height:393; width:350`. Inside a **parallax** (`range: 8`, and on desktop `range: 16`
  with the layer overscanned to `top:-25%; bottom:-25%` — the picture plainly lags the page): desktop
  `<img src="atelier/atelier-photo.webp" alt="Hands arranging six small perfume vials on a pale table">`
  filling it, `object-fit:cover`; compact instead the uncropped `atelier/atelier-photo-full.webp` in a box
  at laptop `top:12; left:-67; height:441; width:353`, tablet `top:-32; left:-99; height:549; width:441`,
  mobile `top:-33; left:-102; height:571; width:458`. Over it a wash `position:absolute; inset:0;
  background:var(--atelier-photo-wash); mix-blend-mode:soft-light`.
- **Paragraphs** (+extra) — `position:absolute; top:339; left:504; width:440; display:flex;
  flex-direction:column; gap:32`, **serif** 22, `line-height:1.45; color:var(--atelier-ink-muted)`; laptop
  `top:278; left:334; width:270; gap:29`, 20; tablet `top:239; left:400; width:336; gap:29`, 20; mobile
  `top:977; left:20; width:350; gap:26`, 18. Two `<p>`: "Three of us work in a former silk workshop on the
  Croix-Rousse hill in Lyon. We buy by the harvest, straight from the growers, and print the year on every
  box." · "When a material runs out, the batch ends. The next one will smell a little different, and we
  think that is the point."
- **Button** (+extra) `<a href="#atelier">Visit the atelier</a>` — `position:absolute; top:780; left:504;
  display:flex; height:44; align-items:center; justify-content:center; border:1px solid
  var(--atelier-action-border); padding:0 26`, **caps** 13, `line-height:20; white-space:nowrap;
  color:var(--atelier-ink)`; hover `border-color:var(--atelier-ink); background:var(--atelier-ink);
  color:var(--atelier-background)` (250 ms). Laptop `top:592; left:334`; tablet `top:572; left:400`;
  mobile `top:1157; left:20`.
- **Stats** (+extra) `<ul>` — `position:absolute; top:334; left:1050; width:342`; laptop `top:272; left:648;
  width:336`; tablet `top:656; left:32; width:704`; mobile `top:679; left:20; width:350`. Three
  `<li style="position:relative">` of height 197 (laptop 145.5, tablet 113; mobile 89, the second 96, the
  last 65) — on desktop / laptop the last rule lines up with the photo's bottom edge. Each:
  - the figure `<p>` — **serif** 84 (laptop 64, tablet 72, mobile 56), `line-height:1; white-space:nowrap;
    color:var(--atelier-ink)`, an **odometer** of "2016", "6", "400" (on the block's arrival);
  - the label `<p>` — `position:absolute; top:52; right:0; width:200; text-align:right`, **caps** 12,
    `line-height:1.5; color:var(--atelier-ink-muted)` (laptop `top:34`, tablet `41`, mobile `28`): "Founded in
    Lyon", "Perfumes in the range", "Bottles per batch";
  - a rule `position:absolute; top:96; left:0; height:1; width:100%; background:var(--atelier-rule)` (laptop
    `73`, tablet `88`, mobile `64`);
  - on the second stat only, the **aside**: a hand-drawn arrow — a **pen** (no bleed, `from: "right"`,
    delay 2300, duration 600) box `position:absolute; top:24; left:44; height:26; width:44` (laptop `18 /
    34 / 20 / 34`, tablet `21 / 38 / 22 / 38`, mobile `16 / 29 / 17 / 29`) holding
    `atelier/atelier-hand-arrow.svg` at that size; and the note — a box `position:absolute; top:-10;
    left:91; height:52; width:236; display:flex; align-items:center; justify-content:center` (laptop `-8 /
    69 / 42 / 188`, tablet `-9 / 78 / 47 / 212`, mobile `-7 / 61 / 34 / 153`) holding a span `rotate:-3deg`,
    **script** 40 (laptop 32, tablet 36, mobile 26), `line-height:1; white-space:nowrap;
    color:var(--atelier-ink)`, whose text "we know them by heart" is a **pen** (delay 900, duration 1300).

### 05 Shelf — `<section aria-labelledby="shelf-title">` (light sheet)

`position:relative; height:100lvh; width:100%; overflow:hidden; background:var(--shelf-paper);
--col-bg:var(--shelf-paper)`; laptop `height:734`, tablet `799`, mobile `763`.

**The frame** — one flex column: `position:absolute; left:0; right:0; top:50%; height:885;
translate:0 -50%; transform-origin:center; scale:var(--shelf-fit); display:flex; flex-direction:column;
align-items:flex-start; gap:36; padding:48`. Desktop: `top:0; translate:none; transform-origin:top left;
height:calc(100lvh / var(--shelf-fit, 1)); width:calc(100% / var(--shelf-fit, 1));
justify-content:space-between` (header at the top, cards at the bottom; scaled from the left edge at a
widened width so it still spans a short screen). Laptop `height:734; gap:32; padding:51 40 48`; tablet
`height:799; gap:32; padding:43 0 40 32`; mobile `height:763; gap:24; padding:43 0 32 20`.

1. **Header row** — `display:flex; width:100%; align-items:center; justify-content:space-between;
   white-space:nowrap`; tablet `flex-direction:column; align-items:flex-start; gap:24`; mobile the same with
   `gap:31`.
   - `<h2 id="shelf-title">` — **serif** 50, `line-height:53; letter-spacing:-0.01em; color:var(--shelf-ink)`;
     laptop / tablet 44 / 47; mobile 36 / 40; compact `margin-top:-3`. Its content is one **blur-in**
     (inline, nowrap): "Three from" + space + `<span>` "the" + space + `<span>` "shelf", each span **script**
     92 (laptop / tablet 81, mobile 66), `color:var(--shelf-accent)`; the first span also has
     `margin-inline-start:0.18em` (mobile 0) — the wider gap Figma has before the script words.
   - Filters `<ul>` — `display:flex; align-items:center; gap:26` (handheld `height:17; gap:20`), four
     `<button aria-pressed>`: **caps** 12, `line-height:1.5`, colour transition 250 ms: "[ All six ]"
     (pressed, `var(--shelf-ink)`), "Floral", "Woody", "Green" (`var(--shelf-ink-muted)`, hover → ink).
     They do not filter.
2. **Cards** `<ul id="shelf-cards" data-bleed-right>` — `display:flex; width:100%; border-top:1px solid
   var(--shelf-rule); border-bottom:1px solid var(--shelf-rule)`; handheld a swipe row: `overflow-x:auto;
   scroll-snap-type:x mandatory; scrollbar-width:none` (and `::-webkit-scrollbar { display:none }`). Each
   `<li>`: `box-shadow: inset -1px 0 0 var(--shelf-rule)` (none on the last); desktop `flex:1`; laptop
   `width:314; flex-shrink:0` (the second `316`); tablet `width:320; flex-shrink:0;
   scroll-snap-align:start`; mobile `width:300` likewise.
3. **Swipe progress** (handheld only, `aria-hidden`) — `position:relative; height:2; overflow:hidden`,
   tablet `width:704`, mobile `width:350`: a track `position:absolute; top:1; left:0; height:1; width:100%;
   background:var(--shelf-progress-track)` and a thumb `position:absolute; top:0; left:0; height:2;
   width:33.333%; background:var(--shelf-progress-thumb)`, moved on the row's `scroll` (and `resize`) by
   `thumb.style.translate = `${(p * 200).toFixed(2)}% 0`` with `p = scrollLeft / (scrollWidth −
   clientWidth)` (0 when nothing scrolls).

**A card** (`<article>`, card index *i* = 0, 1, 2) — `display:flex; flex-direction:column; width:100%`:

- **Photo box** — `position:relative; height:470; width:100%; overflow:hidden;
  background:var(--shelf-photo-frame)`; laptop `330`, tablet `336`, mobile `315`.
  - **Blurred backdrop** (`aria-hidden`) — `position:absolute; top:-20; left:-20; height:510;
    width:calc(100% + 40); filter:blur(4.5px)`; laptop `top:-14; left:-14; height:359; width:343`, tablet
    `-14 / -14 / 364 / 349`, mobile `-13 / -13 / 342 / 327`, all three `filter:blur(3.16px)` (design px).
    Inside, a **parallax** with `range` = `[-6, -8, -7][i]` holding the card photo (`alt=""`,
    `crossorigin="anonymous"`, filling, `object-fit:cover`) and a **ripple** host.
  - **Wash** (card 0 only) — `position:absolute; inset:0; background:var(--shelf-photo-wash)`.
  - **Bottle** — box `position:absolute; top:58; left:calc(50% - 97); height:340; width:195;
    filter:blur(0.25px)`; laptop `top:41; left:89; height:239; width:137`; tablet `41 / 91 / 243 / 139`;
    mobile `39 / 85 / 228 / 131`. `transition: translate 250ms var(--ease-entrance)`; on hover of the card
    `translate: 0 -8`. Inside, a **drift** (desktop only) with `range` = `[2.5, 7, 3.25][i]` — each bottle
    at its own pace, so the three never move as one — holding the bottle `<img>` filling it,
    `object-fit:cover`.
  - **Badge** `<p>` — `position:absolute; top:16; left:18` (compact `16`), **caps** 12, `line-height:1.5;
    white-space:nowrap; color:var(--shelf-on-photo)`.
  - **Script name** (`aria-hidden`) — `position:absolute; top:372; left:0; width:100%; text-align:center`,
    **script** 72, `line-height:1; color:var(--shelf-on-photo)`; laptop `top:262`, 50; tablet `266`, 51;
    mobile `249`, 48. Text a **pen**, delay `900 + i × 350`, duration 1300.
- **Info** — `display:flex; flex-direction:column; align-items:center; gap:10; padding:26 24 24;
  text-align:center` (compact side padding 20):
  - `<h3>` — **caps** 12, `line-height:1.5; white-space:nowrap; color:var(--shelf-ink)`; a **blur-in**
    (inline, nowrap) of the name, delay `i × 120`.
  - description `<p>` — **serif** 18, `line-height:1.26; letter-spacing:-0.01em;
    color:var(--shelf-ink-description)`; its lines are `display:block` spans (on mobile `inline`, flowing
    freely); where a compact split is given, laptop / tablet show that one instead.
  - price `<p>` — Jost 400 13, `line-height:1.5; letter-spacing:0.04em; white-space:pre;
    color:var(--shelf-ink)`: "50 ml  ·  €140".
  - `<button aria-label="Quick add: <name>">QUICK ADD</button>` — `margin-top:18; display:flex; height:44;
    width:400` (compact `100%`)`; align-items:center; justify-content:center; border:1px solid
    var(--shelf-button-border)`, **caps** 13, `line-height:20; color:var(--shelf-ink)`; hover
    `border-color:var(--shelf-ink); background:var(--shelf-ink); color:var(--shelf-paper)` (250 ms).

The three cards (copy verbatim):

| i | badge | name | description lines (1440 / 390) | compact lines (1024 / 768) | photo | bottle (alt) | wash |
|---|---|---|---|---|---|---|---|
| 0 | New | Papier Buvard | "Dry vetiver, cedar shavings, the inside" / "of a new notebook." | "Dry vetiver, cedar shavings, the inside of" / "a new notebook." | `shelf/shelf-photo-papier-buvard.webp` | `shelf/shelf-bottle-papier-buvard-2x.webp` ("Papier Buvard bottle") | yes |
| 1 | Best seller | Marguerite Fauve | "A daisy field at four in the afternoon," / "still warm." | — | `shelf/shelf-photo-marguerite-fauve.webp` | `shelf/shelf-bottle-marguerite-fauve-2x.webp` ("Marguerite Fauve bottle") | no |
| 2 | Batch 03, last bottles | Thé au Sel | "Green tea gone cold by an open window," / "a little salt on the glass." | — | `shelf/shelf-photo-the-au-sel.webp` | `shelf/shelf-bottle-the-au-sel-2x.webp` ("Thé au Sel bottle") | no |

### Interlude — Note `<section aria-labelledby="note-title">` (red paper, lies under the shelf sheet)

`position:relative; height:100lvh; min-height:560; width:100%; overflow:hidden;
background:var(--note-paper); --col-bg:var(--note-paper)`; laptop / tablet `height:665`, mobile
`height:585; min-height:0`.

**Paper** (all `aria-hidden`, `position:absolute; inset:0`): a gradient `linear-gradient(to bottom,
var(--note-paper-from), var(--note-paper-to))` (`data-bleed`); a sheen `var(--note-paper-sheen)`
(`data-bleed`); the **uneven tone** (note radii) at `opacity:.4` (`data-bleed`); the scratches SVG per range,
`width:100%; height:100%; max-width:none` — desktop `note/note-scratches.svg`, laptop
`note/note-scratches-1024.svg`, tablet `note/note-scratches-768.svg`, mobile `note/note-scratches-390.svg`
(`data-bleed`).

**Corner labels** — each `position:absolute; white-space:nowrap`, **caps** 12, `line-height:1.5;
color:var(--note-eyebrow)`:
- `<h2 id="note-title">` "A note from the perfumer" — `left:48; top:48` (laptop `40 / 48`, tablet `32 / 40`,
  mobile `20 / 40`); a **blur-in** (inline, nowrap).
- "Formula No. 04" — `right:48; top:48` (laptop `right:40`, tablet `right:32; top:40`, mobile `right:auto;
  left:20; top:65`).
- In the `<figcaption>`: "Lyon, October 2026" — `left:48; bottom:48` (laptop `40 / 48`, tablet `32 / 40`,
  mobile `20 / 40`); "Camille Aubert" — `right:48; bottom:48` (same steps on the right).

**The quote** — `<figure style="position:absolute; inset:0">` holding a layer `position:absolute;
inset:0` that on desktop is `scale: min(1, calc(var(--note-ratio, 1) + 0.1))` (on 21:9 screens the quote
scales to fit between the corner lines), and in it:

- a box `position:absolute; left:50%; top:50%; height:462; width:863; translate:-50% -50%; display:flex;
  align-items:center; justify-content:center` (laptop `margin-left:-10; height:370; width:691`; tablet
  `margin-left:-10; height:305; width:570`; mobile `height:185; width:345`) holding
  `<blockquote style="rotate:-4deg; white-space:nowrap; text-align:center">` — **script** 150 (laptop 120,
  tablet 99, mobile 60), `line-height:0.9; color:var(--note-ink)` — with one `<p>` of three block lines:
  1. "a daisy has almost no " (printed);
  2. "scent of its own, " printed, then **"so we"** written by a **pen** (delay 0, duration **550**), then a
     space;
  3. **"asked its cousin."** written by a **pen** (delay **350**, duration **1100**).
- the hand-drawn double underline under "its cousin." — `aria-hidden` box `position:absolute; left:671;
  top:50%; margin-top:157; height:49; width:331; display:flex; align-items:center;
  justify-content:center` (laptop `left:463; margin-top:125; height:39; width:265`; tablet `342 / 104 / 32 /
  218`; mobile `175 / 62 / 19 / 132` — left / margin-top / height / width) holding a **pen** (no bleed,
  `flex:none; rotate:-4deg`, delay `350 + 1100 × 0.8 = 1230`, duration 800) around
  `note/note-underline.svg` at `height:26; width:330` (laptop `21 × 264`, tablet `17 × 218`, mobile
  `10 × 131`).

**Its trigger is not the block's arrival** but the quote being **plainly in sight** (latched, a
stack-frame job): `visible = min(rect.bottom, innerHeight) − max(rect.top, over, 0)` for the
`<blockquote>`, where `over` is the bottom of the previous block when that block lies *over* this one
(its computed `z-index` is higher), else 0; it fires once `visible / rect.height ≥ 0.25`. (On a short
window the block shows long before the quote does.)

### 06 Discovery set — `<section aria-labelledby="discovery-title">` (light sheet, last)

`position:relative; height:100lvh; width:100%; overflow:hidden; background:var(--discovery-surface);
--col-bg:var(--discovery-surface)`; laptop `height:683`, tablet `1203`, mobile `height:1524;
overflow:visible`.

**The frame**: `position:absolute; left:0; right:0; top:50%; height:759; translate:0 -50%;
transform-origin:center; scale:var(--discovery-fit)`. **Desktop**: `top:0; translate:none;
transform-origin:top; --discovery-s: min(1, calc(var(--discovery-ratio, 1) + 0.0844)); --discovery-extra:
max(-4rem, calc(100lvh / var(--discovery-s) - 47.4375rem)); height:calc(100lvh / var(--discovery-s));
scale:var(--discovery-s)` — a taller (or up to 64 shorter) screen makes the vial cells taller (shorter)
by `--discovery-extra`; shorter still (21:9) scales the frame down about its top centre. Laptop
`margin-top:-4; height:683`; tablet `margin-top:-4; height:1203`; mobile `top:0; margin-top:-1;
height:100%; translate:none; scale:1`.

- **Title** `<h2 id="discovery-title">` — `position:absolute; top:48; left:48; width:820`, **serif** 50,
  `line-height:53; letter-spacing:-0.01em; color:var(--discovery-ink)`; laptop `top:52; left:40; width:620`,
  44 / 47; tablet `top:44; left:32; width:704`, 44 / 47; mobile `top:41; left:20; width:350`, 36 / 40.
  Script spans: **script** 92 (laptop / tablet 81, mobile 66), `color:var(--discovery-accent)`.
  - Non-mobile: one **blur-in** (block, wrapping): "Wear it for" + space + `<span>`"a" + space +
    `<span style="margin-inline-end:0.28em">`"week" + space + "before you decide."
  - Mobile: **blur lines** `["Wear it for" + " " + <span>"a week", "before you decide."]`.
- **Lede** `<p>` — `position:absolute; top:125; left:48; width:468`, **serif** 18, `line-height:1.45;
  color:var(--discovery-ink-muted)`; laptop `top:119; left:40; width:400`; tablet `top:111; left:32;
  width:440`; mobile `top:139; left:20; width:350`, 16. Two block lines: "Six 2 ml vials, one of each
  perfume." / "About a week of wear per scent."
- **Button** `<button>Order the set  ·  €24</button>` (two spaces each side of the dot) —
  `position:absolute; top:49; left:1149; display:flex; height:52; align-items:center; padding:0 26;
  background:var(--discovery-action); color:var(--discovery-action-foreground)`, **caps** 13,
  `line-height:20; white-space:pre`; hover `background:var(--discovery-accent)` (250 ms). Laptop `top:53;
  left:741`; tablet `top:191; left:32`; mobile `top:209; left:20; width:350; justify-content:center`.
- **Arrow** — a **pen** (no bleed, delay 2300, duration 700), box `position:absolute; top:93; left:1094;
  height:34; width:44` (laptop `95 / 695 / 31 / 40`; tablet `top:209; left:287`, mirrored `scale:-1 1`;
  mobile `267 / 114 / 29 / 37`, mirrored) holding `discovery/discovery-hand-arrow.svg` at that size.
- **Aside** — box `position:absolute; top:143; left:843; height:55; width:282; display:flex;
  align-items:center; justify-content:center` (laptop `130 / 476 / 46 / 240`; tablet `212 / 339 / 49 /
  254`; mobile `280 / 155 / 41 / 211`) holding `<p style="rotate:-3deg">` **script** 40 (laptop 34, tablet 36,
  mobile 30), `line-height:1; white-space:nowrap; color:var(--discovery-accent)`, whose text "it comes off
  your first bottle" is a **pen** (delay 900, duration 1400).
- **Rule** — `position:absolute; top:212; left:48; height:1; width:1344;
  background:var(--discovery-rule-strong)`; laptop `top:209; left:40; width:944`; tablet `top:297; left:32;
  width:704`; mobile `top:345; left:20; width:350`.
- **Vials** `<ul>` — same top / left / width as the rule, `display:grid;
  grid-template-columns:repeat(6, 1fr)` (laptop 6, tablet 3, mobile 2).

**A vial column** (`<li class="group">`, index *i* = 0…5; "ruled" = `i > 0`; on tablet "ruled" =
`i % 3 ≠ 0`, on mobile `i % 2 ≠ 0`) — `position:relative; height:calc(490 + var(--discovery-extra, 0px))`
on desktop; laptop `429`, tablet `435`, mobile `380`. A ruled column has `padding-left:20` (laptop 18,
tablet 18, mobile 14), an unruled one 0.

- **Left rule** (ruled only) — `position:absolute; top:0; bottom:0; left:0; width:1;
  background:var(--discovery-rule)`; on compact `var(--discovery-rule-strong)`, and on tablet it stops 20
  short of the bottom (`bottom:20`).
- **Top rule** of each wrapped row — `position:absolute; left:0; right:0; top:0; height:1;
  background:var(--discovery-rule-strong)`: tablet for `i ≥ 3`, mobile for `i ≥ 2`; hidden otherwise.
- **Number** — `position:absolute; top:22` (compact `20`), **caps** 12, `line-height:normal;
  color:var(--discovery-accent)`.
- **Name** `<h3>` — `position:absolute; top:51` (compact `46`), **script** 40 (laptop 26, tablet 34, mobile
  28), `line-height:1; white-space:nowrap; color:var(--discovery-ink)`, **trim**; a **blur-in** (inline,
  nowrap), delay `1200 + i × 220`.
- **Notes** `<p>` — `position:absolute; top:99; width:180`, **serif** 16, `line-height:1.35;
  color:var(--discovery-ink-soft)`; laptop `top:76`, width 121 (ruled) / 139; tablet `top:82`, width 199 /
  217; mobile `top:78`, 14, width 147 / 161.
- **Vial** — inside a **fade** (delay `i × 80`, duration **550**, blur **12** px) whose box is
  `pointer-events:none; position:absolute; inset:0` (the vials come in left to right out of a blur, a beat
  apart, before their names are written): the `<img>` `position:absolute; top:156; left:93;
  height:300; width:auto; max-width:none; transform-origin:bottom`, desktop
  `margin-top:calc(var(--discovery-extra, 0px) / 2)`; laptop `top:150; height:240; left:63` (ruled) / `55`;
  tablet `top:156; height:220; left:103 / 94`; mobile `top:151; height:170; left:77 / 70`.
  `transition: transform 250ms var(--ease-entrance)`; on hover of the column `transform:
  translateY(-8) rotate(-2deg)` (−8 design px).
- **Volume** — `position:absolute; top:482` (desktop `+ margin-top:var(--discovery-extra)`; laptop `412`,
  tablet `398`, mobile `343`), **caps** 12, `line-height:normal; color:var(--discovery-ink-faint)`: "2 ML".

| i | number | name | notes | image (intrinsic) | alt |
|---|---|---|---|---|---|
| 0 | 01 | Papier Buvard | vetiver, cedar, paper | `discovery/discovery-vial-01.webp` (76 × 600) | "Papier Buvard 2 ml sample vial" |
| 1 | 02 | Figue Verte | fig leaf, milk, moss | `discovery/discovery-vial-02.webp` (78 × 600) | "Figue Verte 2 ml sample vial" |
| 2 | 03 | Iris Lacté | orris, rice, musk | `discovery/discovery-vial-03.webp` (78 × 600) | "Iris Lacté 2 ml sample vial" |
| 3 | 04 | Marguerite Fauve | chamomile, honey, hay | `discovery/discovery-vial-04.webp` (76 × 600) | "Marguerite Fauve 2 ml sample vial" |
| 4 | 05 | Encre Douce | ink, plum, suede | `discovery/discovery-vial-05.webp` (78 × 600) | "Encre Douce 2 ml sample vial" |
| 5 | 06 | Thé au Sel | green tea, salt, mint | `discovery/discovery-vial-06.webp` (76 × 600) | "Thé au Sel 2 ml sample vial" |

### 07 Footer — uncovered last

**The reveal wrapper** (after `<main>`): `<div data-block data-footer-reveal data-header-tone="dark"
style="position:sticky; bottom:0; z-index:0; opacity:0">` holding the footer and a shade layer
`<div aria-hidden style="pointer-events:none; position:absolute; inset:0; background:var(--sheet-shade);
opacity:.45">`, plus `<noscript><style>[data-footer-reveal]{opacity:1}</style></noscript>`. It lies under
`<main>` (z-index 10), pinned to the bottom of the screen; the last block lifts off it like a curtain. A
stack-frame job:

```js
const MAX_SHADE = 0.45, ARRIVED = 0.5, NEAR = 1.5;
read()  { uncovered = innerHeight - main.getBoundingClientRect().bottom;
          shown = clamp01(uncovered / wrapper.offsetHeight);
          near ||= uncovered > -NEAR * innerHeight; }
write() { if (near && !painted) { painted = true; wrapper.style.opacity = "1"; }   // shown once the end is 1.5 screens away, never hidden again
          if (shown >= ARRIVED) markArrived(wrapper);                              // its reveals wait until it is half uncovered
          shade.style.opacity = Math.round((1 - shown) * MAX_SHADE * 1000) / 1000;  // it brightens as it is uncovered
          flower.style.transform = `translate3d(0, ${((1 - shown) * 14).toFixed(3)}%, 0)`; }  // the gerbera drifts up into place
```

**`<footer>`** — `position:relative; height:676; width:100%; overflow:hidden;
background:var(--footer-background); --col-bg:var(--footer-background)`; laptop `619`, tablet `659`, mobile
`625`.

- **Flower** `data-footer-flower` — `pointer-events:none; position:absolute; top:-361; left:186; height:1469;
  width:1067; opacity:.7; mix-blend-mode:screen`; laptop `-254 / 112 / 1102 / 800`; tablet `-147 / 53 / 911 /
  662`; mobile `-8 / -29 / 617 / 448` (top / left / height / width). It holds
  `<img src="footer/footer-flower.webp" alt="" crossorigin="anonymous" width="736" height="1013">` filling
  it (`object-fit:cover`) and a **ripple** host (the blend and the drift carry the ripple with it).
- **Content** — `position:absolute; left:0; right:0; top:16; height:100%` (laptop / tablet `top:8`, mobile
  `top:5`) holding:
  - **Wordmark** `<p>` "Herbier Blanc" — `position:absolute; top:205; left:50%; margin-left:-475`,
    **script** 230, `line-height:1; white-space:nowrap; color:var(--footer-ink)`, **trim**; laptop `top:204;
    margin-left:-351`, 170; tablet `top:236; margin-left:-264`, 128; mobile `top:35; margin-left:-132`, 64. Its
    text is a **pen** (bleed), duration **2200**, on the footer's arrival.
  - **Atelier column** — `position:absolute; top:32; left:48; display:flex; flex-direction:column; gap:12;
    width:300`; laptop `top:40; left:40; width:260`; tablet `top:32; left:32; width:240`; mobile `top:249;
    left:20; width:165`. An `<h2>` eyebrow (**caps** 12, `line-height:1.5; color:var(--footer-ink-muted)`)
    whose text "Atelier" is a **blur-in** (inline, nowrap), then the body (Jost 400 13, `line-height:1.9;
    letter-spacing:0.04em; color:var(--footer-ink)`): an `<address>` of block lines "14 montée de la
    Grande-Côte" / "69001 Lyon, France" and `<p>`"Thursday to Saturday, 11:00–19:00"; on mobile instead
    "14 montée" / "de la Grande-Côte" / "69001 Lyon, France" and "Thu–Sat, 11:00–19:00".
  - **Link groups** — each `<nav aria-label>` `position:absolute; display:flex; flex-direction:column;
    gap:12; width:300` (laptop 260, tablet 240, mobile 165) with the same eyebrow `<h2>` (blur-in) and a
    `<ul>` in the body style of **pen links** (all `href="#"`). Each link gets `min-height:24px` — in **px**,
    not rem: the 1.9 line height scales with the rem grid and falls under the 24px touch-target minimum
    between frames (23.2px at 1350 wide, 21.7px at 900).
    - **Contact** — `top:32; right:48; text-align:right` (laptop `top:40; right:40`; tablet `right:32`; mobile
      `top:249; right:auto; left:205; text-align:left`): "bonjour@herbierblanc.fr", "Instagram",
      "Stockists".
    - **Shop** — `top:507; left:48` (laptop `456 / 40`; tablet `504 / 32`; mobile `410 / 20`): "Perfumes",
      "Discovery set", "Gift cards".
    - **Help** — `top:507; right:48; text-align:right` (laptop `456`, `right:40`; tablet `504`, `right:32`;
      mobile `top:410; right:auto; left:205; text-align:left`): "Shipping and returns", "Ingredients and
      allergens", "Legal notice".
  - **Tagline** `<p>` "One letter per batch. Nothing in between." — `position:absolute; top:468; left:50%;
    width:420; translate:-50% 0; text-align:center`, **serif** 22, `line-height:1.5; color:var(--footer-ink)`;
    laptop `top:419; width:340`, 20; tablet `top:467; width:340`, 20; mobile `top:108; width:350`, 18.
  - **Subscribe** `<button>` — `position:absolute; top:514; left:50%; translate:-50% 0; display:flex;
    height:44; align-items:center; justify-content:center; border:1px solid var(--footer-border); padding:0
    26`, **caps** 13, `line-height:20; color:var(--footer-ink)`; hover `border-color:var(--footer-ink);
    background:var(--footer-ink); color:var(--footer-background)` (250 ms). Laptop `top:465`, tablet `513`,
    mobile `149`. Text "SUBSCRIBE".
  - **Legal** `<p>` "© 2026 Herbier Blanc SAS  ·  FR / EN" — `position:absolute; top:594; left:50%; width:500;
    translate:-50% 0; text-align:center; white-space:pre-wrap`, eyebrow style (**caps** 12, `line-height:1.5;
    color:var(--footer-ink-muted)`); laptop `top:545`; tablet `593`; mobile `top:562; width:350`.

## Reduced motion

`prefers-reduced-motion: reduce` makes every spring and tween immediate: the curtains still cover the page
until loading is done and then vanish; every reveal shows at rest; the header still hides and shows (at
once). The sheets keep their shade and blur but get no depth shift; the parallax / drift layers rest at their
centred place; the bottle simply fades in with the scroll (`opacity = mist progress`, no WebGL); the cursor
ripple is off. The scroll-scrubbed frames, the zoom, the envelope and the letter still follow the scroll.

## Fixed parameters (bake these in)

Every constant is given in its section above; the ones that recur:

- **Ranges**: desktop `≥ 1200` (frame 1440), laptop `896–1199` (1024), tablet `700–895` (768), mobile
  `< 700` (390); compact `< 1200`, handheld `< 896`. Root font-size `1.111111vw` / `1.5625vw` / `2.083333vw` /
  `min(4.102564vw, 19.2px)`; phone column 390 × 1.2 centred from 469 px.
- **Palette**: crimson `#9a2219` (paper `#a3261c → #8a1c15`), paper `#f5f5f5` / shelf `#f3f3f3`, ink `#141414`
  / `#131313`, light ink `#f9f9f9`, stage backdrop `#331f14`, stationery ink `#2b1a12`, rule `#5c331f`, warm
  light `#d99e4d`, shadow umber `#290f05`, photo frame `#664d40`, shade `#0a0a0a`, curtains `#000000`.
- **Faces**: Instrument Serif 400 · Mrs Saint Delafield 400 · Jost 400 / 500; caps tracking `0.2em`.
- **Intro**: hairline 325 ms (easeInOutCubic) · cap 1500 ms · font share 0.15 · first 40 frames · counter
  needle `{90, 26}`, in 150 / finish 250 (easeOutCubic) / out 200 ms · hold 60 ms · parting 650 ms
  (easeInOutQuart) · hero starts at 12 % of the parting · push-in 1.4× over 1600 ms (easeInOutQuart) · hero
  copy delay 350, rise 450 ms (easeOutCubic) from 0.75rem.
- **Blur-in**: opacity 0 / blur 8px → 1 / 0, `{98, 17}`, 36 ms per letter.
- **Pen**: feather 18 %, easeInOutSine, default 1400 ms; links' underline 700 ms `cubic-bezier(0.2, 0, 0, 1)`.
- **Fade**: 900 ms easeOutCubic by default. **Odometer**: 1500 ms easeOutCubic, 140 ms per digit.
- **Sheets**: shade 0.45, blur 10 px, depth 0.22 screen; arrival at 35 %, entry at 90 % of the screen.
- **Stage**: 400 lvh; arrival = first screen; copy reveals at 0.6 of the arrival; envelope opens from 0.35;
  mist done at 0.7 of the block (`{90, 26}`); zoom 1 → 4/3; flap 0.04–0.11 / 0.11–0.18; letter waits to 0.15;
  pens 0.25–0.42 / 0.42–0.6 / 0.6–0.78; 124 frames, batches of 4, ±10 decoded.
- **Header**: `{260, 32}`; links trail `{170, 24}` from blur 6 / 0.6em; probe 2.25rem every 100 ms; hover zone
  7.5rem; top zone 24 px; direction slop 6 px. **Menu**: sheet `{210, 32}`; ink 1700 ms linear after 280 ms,
  step 0.095, stroke 0.4.
- **Ripple**: 14 rings, spawn every 26 px, life 1.5 s, speed 340, width 90, amplitude 24, freq 0.055,
  intensity `{120, 26}`, DPR ≤ 2.
- **Hover**: colour swaps 250 ms `cubic-bezier(0.2, 0, 0, 1)`; bottles and vials lift 8 (vials also −2°).

## Assets

Every runtime asset lives under `ASSET_BASE_URL = /assets`
at the same path it has under the project's `public/assets/`. Load each from that URL exactly as the sections
reference it (e.g. `/assets/product/product-bottle-clear-2x.webp`).
Fonts are not assets — they come from the Google Fonts `<link>` above.

**The frame sequence** (248 files — give the pattern, never hard-code a list):

- `/assets/product/frames/product-frame-{NNN}.webp` —
  `NNN` = `001` … `124` (zero-padded to 3 digits), 124 frames, 2304 × 1440 (≥ 896 px).
- `/assets/product/frames/product-frame-sm-{NNN}.webp` —
  `001` … `124`, 124 frames, 1152 × 720 (< 896 px).

**Everything else** (45 files):

- /assets/texture/texture-grain.webp
- /assets/product/product-bottle-clear-2x.webp
- /assets/product/product-envelope-back.svg
- /assets/product/product-envelope-flap-open.svg
- /assets/product/product-envelope-flap.svg
- /assets/product/product-envelope-paper.svg
- /assets/product/product-envelope-fibres.svg
- /assets/product/product-envelope-edge.svg
- /assets/product/product-envelope-pocket-mask.svg
- /assets/product/product-letter-paper.svg
- /assets/product/product-letter-fibres.svg
- /assets/chapters/chapters-harvest-photo.webp
- /assets/chapters/chapters-bottle-2x.webp
- /assets/chapters/chapters-bottle-shadow-768.svg
- /assets/chapters/chapters-bottle-shadow-390.svg
- /assets/chapters/chapters-bottle-shadow-contact-768.svg
- /assets/chapters/chapters-bottle-shadow-contact-390.svg
- /assets/atelier/atelier-photo.webp
- /assets/atelier/atelier-photo-full.webp
- /assets/atelier/atelier-flower.webp
- /assets/atelier/atelier-scratches-dust.svg
- /assets/atelier/atelier-scratches-dust-1024.svg
- /assets/atelier/atelier-scratches-dust-768.svg
- /assets/atelier/atelier-scratches-dust-390.svg
- /assets/atelier/atelier-hand-underline.svg
- /assets/atelier/atelier-hand-arrow.svg
- /assets/shelf/shelf-photo-papier-buvard.webp
- /assets/shelf/shelf-photo-marguerite-fauve.webp
- /assets/shelf/shelf-photo-the-au-sel.webp
- /assets/shelf/shelf-bottle-papier-buvard-2x.webp
- /assets/shelf/shelf-bottle-marguerite-fauve-2x.webp
- /assets/shelf/shelf-bottle-the-au-sel-2x.webp
- /assets/note/note-scratches.svg
- /assets/note/note-scratches-1024.svg
- /assets/note/note-scratches-768.svg
- /assets/note/note-scratches-390.svg
- /assets/note/note-underline.svg
- /assets/discovery/discovery-hand-arrow.svg
- /assets/discovery/discovery-vial-01.webp
- /assets/discovery/discovery-vial-02.webp
- /assets/discovery/discovery-vial-03.webp
- /assets/discovery/discovery-vial-04.webp
- /assets/discovery/discovery-vial-05.webp
- /assets/discovery/discovery-vial-06.webp
- /assets/footer/footer-flower.webp

Used where: the grain and `atelier/atelier-scratches-dust.svg` also texture Block 02's photo; the footer flower
is also the menu sheet's gerbera; frame 001 is also the hero's picture and the `<link rel="preload">`.
[/hidecontent]
← 返回首页