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

Seed

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

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

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. Use an ES module script
with an importmap for three.js **r143** (`three@0.143.0` from unpkg — the scene uses
`THREE.WebGL1Renderer`, GLSL ES 1.0 shaders and the r143 `EffectComposer`, so do not upgrade it)
and Lenis 1.3 for smooth scrolling. Hardcode every value given here as a fixed constant.

## What it is

SEED. is a one-screen landing page for a fictional meadow brand, laid over a live macro of a
**dandelion clock at golden hour**: one clock in focus, backlit by a low sun at the upper right,
its pappus hairs glowing by forward scattering against an out-of-focus meadow of soft grass,
bokeh, other blurred clocks, drifting seeds and a midge swarm. The cursor is a **breath** — seeds
near it lean, let go and tumble off on the air, and the stem bends; a **click** (or the "Make a
Wish" pill) blows a gust that strips the clock in a wave; rest the cursor and it regrows.

Over the scene sits a white editorial frame: a giant serif wordmark "SEED.", a centred nav, hairline
rules that cross in tiny dots, a serif heading, a Mulish paragraph, cream glass pills, and a right
column with a looping video card and a forest-glass stat card. The page opens with a **preloader**:
a 2D line-drawn dandelion blooms on deep forest green with a 0→100 counter in its centre, then a
round, feathered hole opens from the centre onto the live scene while the frame draws itself in.

Palette: forest `#14200f`, cream `#fffbe6`, sage `#70745f`, umber `#77603c`, white type; the scene
carries its own golden light. Type: **Instrument Serif 400** (display) + **Mulish** (body), both
from Google Fonts.

## Page shell & libraries

```html
<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=Mulish:wght@400&display=swap" rel="stylesheet">
<script type="importmap">
{ "imports": {
  "three": "https://unpkg.com/three@0.143.0/build/three.module.js",
  "three/examples/jsm/": "https://unpkg.com/three@0.143.0/examples/jsm/",
  "lenis": "https://unpkg.com/lenis@1.3.26/dist/lenis.mjs"
} }
</script>
```

- `html, body { margin: 0; }` and `body { background: #000; color: #fff; }` — the black ground goes on
  `body` **only** (it propagates to the canvas, under the `z-index: -10` scene). A background on
  `html` as well makes `body` paint its own black box over the scene. The ground shows before the
  first scene frame.
- **Adaptive rem grid** — the whole layout is in rem and the root font-size scales with the
  viewport so the 1400×800 design stays proportional:
  ```css
  html { font-size: 16px; }
  @media (max-width: 1400px) { html { font-size: 1.142857vw; } }   /* 16·100/1400 — desktop, laid out at 1400 */
  @media (max-width: 1023px) { html { font-size: 2.083333vw; } }   /* 16·100/768  — tablet, laid out at 768 */
  @media (max-width: 639px)  { html { font-size: 4.444444vw; } }   /* 16·100/360  — phone, laid out at 360 */
  ```
  Above 1400px, scale the root font-size up in JS, **damped** (coef 0.6666, not 1:1):
  `html.style.fontSize = 16 * (1 + 0.6666 * (innerWidth - 1400) / 1400) + 'px'` on resize; at or
  below 1400 remove the inline size so the media queries drive. **Every px value below is a design px: write it as `px/16` rem.**
- Three layouts: **desktop ≥ 1024px**, **tablet 640–1023px**, **phone ≤ 639px**.
- Lenis: `new Lenis({ smoothWheel: true })`, driven from a rAF loop (`lenis.raf(time)`); stop it
  (`lenis.stop()`) and keep `window.scrollTo(0,0)` while the preloader is up, `lenis.start()` after.
  Set `history.scrollRestoration = 'manual'`.
- `prefers-reduced-motion: reduce` → no preloader, no WebGL (show the still, see Assets), every
  entrance at rest at once.

### Design tokens (CSS custom properties)

```css
:root {
  --forest-950: #14200f; --forest-900: #13210e; --sage-600: #70745f; --cream: #fffbe6; --umber-600: #77603c;
  --on-scene: #ffffff;                 /* text, rules and dots over the scene */
  --on-glass: #14200f;                 /* text and icons on the cream pills */
  --glass-pill-border: rgb(255 255 255 / .5);   --glass-pill-border-hover: rgb(255 255 255 / .9);
  --glass-card-border: rgb(255 255 255 / .2);   --glass-card-border-hover: rgb(255 255 255 / .45);
  --glass-pill-fill:       linear-gradient(174.47deg, rgb(255 251 230 / .3) 11.947%, #fffbe6 104.77%); /* 212×50 pill */
  --glass-pill-fill-large: linear-gradient(173.76deg, rgb(255 251 230 / .3) 11.947%, #fffbe6 104.77%); /* 60px-high pill */
  --glass-card-fill: linear-gradient(to bottom, rgb(19 33 14 / .2), rgb(112 116 95 / .8));
  --media-card-ground: rgb(19 33 14 / .8);
  --scene-vignette: radial-gradient(77.45% 116.06% at 50% 50%, rgb(0 0 0 / 0) 0%, #000 100%);
  --glass-glow-fill: radial-gradient(60% 110% at 50% 50%, rgb(255 255 255 / .5) 0%, rgb(255 255 255 / 0) 100%);
  --glass-glow-halo: 0 0 1.5rem rgb(255 251 230 / .45);
  --pill-radius: 2.5625rem;            /* 41px */
  --blur-pill: .25rem; --blur-pill-header: .75rem; --blur-card: 1rem;
  --duration-normal: 250ms; --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
  --preloader-size: min(72vmin, 30rem);
  --preloader-hole: 72vmax; --preloader-feather: 14vmax;
}
```

Type scale: wordmark **196.899px** desktop / **160px** tablet / **72px** phone; display (h1 and
"200+") **64px** (h1 **40px** on phone); label (nav, pill labels) **20px**; body **16px**, Mulish,
`line-height: normal`; caption **14px** Mulish. Instrument Serif everything display, `line-height: 1`.

## The scene layer

A `<div id="scene">` fixed full-bleed **behind** everything: `position: fixed; left: 0; right: 0;
top: 0; height: 100lvh; z-index: -10; background: #000`. On touch devices, measure its px height
once and only re-measure on a **width** change (so the iOS toolbar does not resize the canvas
mid-scroll). The canvas is `display:block; width:100%; height:100%`, `aria-hidden="true"`, sized by
this box (ResizeObserver), never by `window.inner*`. The full scene is in **The WebGL scene** below.

## Layout & sections (one `<section aria-labelledby="hero-heading">`)

The hero is one markup with three layouts (one h1, no duplicated copy). Text colour white.

**Copy (verbatim):**
- wordmark (a link to `/`, `aria-label="SEED."`): `SEED.`
- nav (`<nav aria-label="Main">`, a `<ul>`): `Wind` (#wind) · `Contact` (#contact) · `Meadow` (#meadow) · `Seeds` (#seeds)
- button: `Make a Wish`
- h1 (`id="hero-heading"`): `Delicate Structures Carried by the Wind.` (breaks as "Delicate Structures / Carried by the Wind.")
- paragraph: `Where a single breath meets the open sky. Each seed carries a tiny parachute of silk, drifting for miles to plant new life wherever the wind decides.`
- CTA link (#meadow): `Explore the Meadow`
- stat card: `200+` and, on two lines, `Seeds on a Single` / `Dandelion Head`
- media card video alt (on its poster image): `A round archway of flowers, ferns and moss-covered stones opening onto a sunlit forest`
- scene still alt: `A dandelion clock backlit by a low golden sun in a meadow, its seeds glowing against soft out-of-focus grass`

**Vignette:** an `aria-hidden` layer `position:absolute; inset:0; pointer-events:none; background: var(--scene-vignette)` over the scene, under the content.

### Desktop (≥ 1024px) — the 1400×800 frame, `height: 100lvh`, every box absolute

| element | placement (design px) | spec |
|---|---|---|
| rule A (vertical) | `left 472; top 0; height 50%; width 1` | white |
| rule B (vertical) | `right 473; top 0; bottom 0; width 1` | white |
| rule C (horizontal) | `left 0; right 473; top 50%; margin-top -1; height 1` | white |
| rule D (horizontal) | `right 0; top 50%; margin-top -152; width 474; height 1` | white |
| dot 1 | `left 470; top 50%; margin-top -3; 5×5` | white, round |
| dot 2 | `right 471; top 50%; margin-top -3; 5×5` | white, round |
| dot 3 | `right 471; top 50%; margin-top -154; 5×5` | white, round |
| wordmark | `left 40; top 20` | Instrument Serif 196.899px, line-height 1; hover/focus `opacity .8` (CSS transition 250ms `--ease-entrance`) |
| Make a Wish | `right 40; top 40` | see **Glass pills** |
| nav | `left 50%; top 55; translateX(-50%)` | `<ul>` flex, gap 32 |
| h1 | `left 40; top 50%; margin-top 40; width 462` | Instrument Serif 64px, line-height 1, left aligned |
| paragraph | `left 483; top 100%; margin-top -100; width 403` | Mulish 16px, line-height normal |
| CTA | `left 40; bottom 40` | see **Glass pills** |
| card column | `right 40; bottom 40; top 50%; margin-top -111; width 394` | flex column, gap 4 |
| ↳ media card | `flex: 1` | radius 24, `overflow:hidden`, background `--media-card-ground` |
| ↳ stat card | height 112, `flex-shrink:0` | radius 24, 1px border `--glass-card-border` (hover `--glass-card-border-hover`), background `--glass-card-fill`, `backdrop-filter: blur(1rem)`; "200+" Instrument Serif 64px line-height 1 at `left 22; top 22`; the label Mulish 14px at `left 253; top 36`; `white-space: nowrap` |

### Tablet (640–1023px, laid out at 768)

The section is a CSS grid, `min-height: 100lvh`, `grid-template-columns: 1fr auto`,
`grid-template-rows: auto 1fr auto`. No desktop rules/dots.
- **Header band** (spans both columns): an inner grid `grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap 32`, padding `24 32 32`, `border-bottom: 1px solid #fff`. The wordmark (160px) spans both rows on the left; the Make a Wish pill is top-right (`justify-self:end; margin-top 8`); the nav sits bottom-right (`align-self:end; justify-self:end`). A 1px vertical rule at `left 388px`, full band height, separates wordmark and nav; a 5px dot sits where it meets the bottom rule (`left 386; bottom -3`).
- **Middle row** (spans both columns, `align-self:end`): the open meadow where the clock sits, with the h1 (64px) at its foot: padding `64 32 32`, `max-width 526`.
- **Bottom-left cell:** paragraph then CTA, `display:flex; flex-direction:column; justify-content:space-between; gap 32; padding 32; border-top: 1px solid #fff`.
- **Bottom-right cell:** the cards column, `width 384` (`24rem`), padding 32, `border-left` and `border-top` 1px white, a 5px dot at its top-left corner (`left -3; top -3`). The media card here has `aspect-ratio: 394 / 355`; the stat card is a flex row (`align-items:center; justify-content:space-between; gap 16; padding 0 22`) with the two texts in normal flow.

### Phone (≤ 639px, laid out at 360)

- **First screen** (`min-height: 100lvh; display:flex; flex-direction:column; padding: 16 16 24`):
  - header: `display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; row-gap 20; margin: 0 -16px; padding: 0 16px 16px; border-bottom: 1px solid #fff`. Wordmark 72px and the Make a Wish pill (height 44, `width:auto`, padding-x 20) on one row; the nav full width under them, `justify-content: space-between; gap 16`.
  - an empty `flex: 1; min-height: 40` spacer — the open band where the clock sits.
  - h1 at 40px, then the paragraph (`margin-top 16`), then the CTA (`margin-top 24`).
- **Below it:** the cards column — `border-top: 1px solid #fff; padding: 24 16 24`, the media card `aspect-ratio 394/355`, the stat card as on tablet.

### Glass pills, nav links and their hovers

All hovers are physical springs (`tension 320, friction 26`) triggered by the whole control,
plus CSS colour transitions (`transition: border-color, color 250ms var(--ease-entrance)`).

- **Make a Wish** — a `<button type="button">`, `height 50; width 212; padding 0 40; gap 8;
  display:flex; align-items:center; justify-content:center; border-radius: var(--pill-radius);
  border: 1px solid var(--glass-pill-border); background: var(--glass-pill-fill);
  backdrop-filter: blur(.75rem)`. Label Instrument Serif 20px colour `--on-glass`, then the small
  ring icon 8×8 (inline SVG below). Click → `blow(-0.6, -0.05)` (see the scene: a gust from just
  left of the clock).
- **Explore the Meadow** — one `<a>` (`display:flex; align-items:center; gap 4`) holding a pill
  (`height 60; padding 0 32; gap 12; border-radius var(--pill-radius); 1px border
  --glass-pill-border; background --glass-pill-fill-large; backdrop-filter: blur(.25rem)`; the 9×9
  ring icon then the label Instrument Serif 20px `--on-glass`) and the 60×60 round arrow button SVG.
- **Hover glow** (both pills, and the arrow button): an `aria-hidden` layer inside the control,
  `position:absolute; inset:0; border-radius:inherit; background: var(--glass-glow-fill);
  box-shadow: var(--glass-glow-halo); opacity:0; transition: opacity 250ms var(--ease-entrance)`,
  `opacity:1` on hover/focus-visible; the content after it is `position:relative`. The border goes
  to `--glass-pill-border-hover`.
- **Hover motion:** Make a Wish — label `x 0→-2px`, ring `x 0→2px, scale 1→1.6`. CTA — ring
  `scale 1→1.5`, arrow button `rotate 0→45deg, scale 1→1.06`.
- **Nav link** — `display:flex; align-items:center; gap 5; white-space:nowrap`, Instrument Serif
  20px, line-height 1: a 4×4 white dot (inline SVG) then the word, and a 1px white hairline
  `position:absolute; bottom -4; left 9; right 0; transform-origin:left`. Hover: hairline
  `scaleX 0→1`, dot `x 0→2px`.
- **Media card hover:** the video layer `scale 1→1.05` on a slow spring (`tension 90, friction 24`).
- **Stat card hover:** its border goes to `--glass-card-border-hover`.

Icons (inline these SVGs verbatim):

```html
<!-- nav dot, 4×4 -->
<svg width="4" height="4" viewBox="0 0 4 4" fill="none"><circle cx="2" cy="2" r="2" fill="white"/></svg>
<!-- ring, 8×8 (Make a Wish) -->
<svg width="8" height="8" viewBox="0 0 8 8" fill="none"><path d="M7.5 4C7.5 5.933 5.933 7.5 4 7.5C2.067 7.5 0.5 5.933 0.5 4C0.5 2.067 2.067 0.5 4 0.5C5.933 0.5 7.5 2.067 7.5 4Z" stroke="#14200F"/></svg>
<!-- ring, 9×9 (CTA) -->
<svg width="9" height="9" viewBox="0 0 9 9" fill="none"><path d="M8.5 4.5C8.5 6.70914 6.70914 8.5 4.5 8.5C2.29086 8.5 0.5 6.70914 0.5 4.5C0.5 2.29086 2.29086 0.5 4.5 0.5C6.70914 0.5 8.5 2.29086 8.5 4.5Z" stroke="#14200F"/></svg>
<!-- arrow button, 60×60 -->
<svg width="60" height="60" viewBox="0 0 60 60" fill="none">
  <circle cx="30" cy="30" r="30" fill="url(#seedArrowGlow)"/>
  <circle cx="30" cy="30" r="29.5" stroke="white" stroke-opacity="0.5"/>
  <path d="M27 24.6503C27 24.6503 33.9383 24.1076 34.9154 25.0846C35.8924 26.0617 35.3496 33 35.3496 33M34.5 25.5L24.5 35.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke="white"/>
  <defs><radialGradient id="seedArrowGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(50 5) rotate(119.539) scale(51.7228)"><stop stop-color="#FFFE8F"/><stop offset="1" stop-color="#13210E"/></radialGradient></defs>
</svg>
```

### The media card video

A muted, looping, `playsinline` video (`preload="none"`, `aria-hidden`) over its poster image
(`<img>` with the alt above, `loading="lazy"`), both `position:absolute; inset:0; width:100%;
height:100%; object-fit:cover`. Sources: the 1080 file for `(min-width: 1024px)`, else the 720
file (see Assets). Set `video.muted = true` in JS; an IntersectionObserver (`threshold 0.01`, no
margin) sets `preload='auto'` and plays it when on screen, pauses it off screen; a refused
`play()` just leaves the poster. Under reduced motion show only the poster.

## The springs

Every entrance and hover is a damped spring (mass 1), integrated per frame with `dt` in seconds —
write one small helper and use it everywhere:

```js
// react-spring semantics: tension k, friction c, mass 1. Step with dt in seconds (sub-step at ≤ 1/240 s).
function spring({ tension = 170, friction = 26 } = {}) {
  let x = 0, v = 0, target = 0;
  return {
    set(t) { target = t; }, jump(t) { x = target = t; v = 0; }, get value() { return x; },
    step(dt) { const n = Math.ceil(dt / (1 / 240)), h = dt / n;
      for (let i = 0; i < n; i++) { const a = -tension * (x - target) - friction * v; v += a * h; x += v * h; }
      return x; },
    get resting() { return Math.abs(x - target) < 1e-4 && Math.abs(v) < 1e-4; },
  };
}
```

## The loader / reveal

A fixed overlay (`inset:0; z-index:60; display:flex; align-items:center; justify-content:center;
background: #14200f; color: #fffbe6`), in the HTML from the first paint (the hero renders at rest
under it), `aria-hidden`. It locks the scroll and returns the page to the top. Hidden by CSS under
`prefers-reduced-motion` and in a `<noscript>`.

### The 2D dandelion

A box `width = height = var(--preloader-size)` holding an SVG `viewBox="-150 -150 300 300"`,
`overflow:visible; fill:none; stroke:currentColor; stroke-linecap:round`, plus a centre disc.
Generate the seeds with a seeded PRNG so every load draws the same head:

```js
const mulberry = (seed) => { let a = seed; return () => { a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };
const SEED_COUNT = 96, RIM_REACH = 100, HAIR = 26, CORE = 7;
const BLOOM_MS = 2600, GROW_MS = 980, OPEN_DELAY_MS = 340, OPEN_MS = 860;
const LAST_START_MS = BLOOM_MS - OPEN_DELAY_MS - OPEN_MS, FIRST_START_MS = 40;
const rnd = mulberry(29), golden = Math.PI * (3 - Math.sqrt(5));

function makeSeeds() {
  const seeds = Array.from({ length: SEED_COUNT }, (_, i) => {
    const y = 1 - ((i + 0.5) * 2) / SEED_COUNT, ring = Math.sqrt(1 - y * y), theta = golden * i;
    const x = Math.cos(theta) * ring, z = Math.sin(theta) * ring;      // z + toward the viewer
    const rho = Math.min(1, Math.hypot(x, y));                         // 1 on the rim, 0 face-on
    const spread = 62 + (1 - rho) * 118;                               // a fan side-on, a star face-on
    const count = Math.round(11 + (1 - rho) * 7);
    const hair = HAIR * (0.88 + rnd() * 0.18);
    let hairs = ""; const fullStar = spread >= 179;
    for (let k = 0; k < count; k++) {
      const deg = fullStar ? -180 + (360 * k) / count : -spread + (2 * spread * k) / (count - 1);
      const b = ((deg + (rnd() - 0.5) * 5) * Math.PI) / 180, len = hair * (0.92 + rnd() * 0.12);
      const cx = Math.sin(b * 0.7) * len * 0.55, cy = -Math.cos(b * 0.7) * len * 0.55;
      const ex = Math.sin(b) * len, ey = -Math.cos(b) * len;
      hairs += `M0 0Q${cx.toFixed(2)} ${cy.toFixed(2)} ${ex.toFixed(2)} ${ey.toFixed(2)}`;
    }
    return { angle: (Math.atan2(x, -y) * 180) / Math.PI, reach: Math.max(CORE + 6, RIM_REACH * rho),
      delay: rho * 200 + rnd() * 760, opacity: z >= 0 ? 0.62 + z * 0.33 : 0.3 + (1 + z) * 0.3,
      achene: 4 + rho * 7, hairs, z };
  });
  const lo = Math.min(...seeds.map((s) => s.delay)), hi = Math.max(...seeds.map((s) => s.delay));
  for (const s of seeds) s.delay = FIRST_START_MS + ((s.delay - lo) / (hi - lo)) * (LAST_START_MS - FIRST_START_MS);
  return seeds.sort((a, b) => a.z - b.z);                              // back to front
}

// an under-damped spring's step response (ζ 0.55, ω 11) over x ∈ [0,1]: ~12 % overshoot, settled by 1
function springStep(x) { if (x <= 0) return 0; if (x >= 1) return 1;
  const zeta = 0.55, omega = 11, wd = omega * Math.sqrt(1 - zeta * zeta), d = Math.exp(-zeta * omega * x);
  return 1 - d * (Math.cos(wd * x) + ((zeta * omega) / wd) * Math.sin(wd * x)); }
const seedTransform = (s, t) => { const g = springStep((t - s.delay) / GROW_MS);
  return `rotate(${(s.angle - (1 - Math.min(g, 1)) * 24).toFixed(2)}) scale(${g.toFixed(4)})`; };
const pappusTransform = (s, t) => { const o = springStep((t - s.delay - OPEN_DELAY_MS) / OPEN_MS);
  return `translate(0 ${(-s.reach).toFixed(2)}) scale(${o.toFixed(4)})`; };
```

Each seed is a `<g transform=seedTransform stroke-opacity=opacity>` holding: the beak
`<line x1=0 y1=-7 x2=0 y2=-reach stroke-width=0.32>`; the achene `<line x1=0 y1=-8 x2=0
y2=-(8+achene) stroke-width=1.3 stroke="#77603c">`; and a `<g transform=pappusTransform><path
d=hairs stroke-width=0.24></g>`. One linear clock `t` runs 0 → 2600 ms and every seed reads it.
The SVG sits in a layer that **spins** clockwise at a steady 30°/s for the preloader's whole life
(`transform: rotate()` on the wrapping div, `will-change: transform`).

**Centre disc + counter:** a disc centred in the box, `width = height = 30%`, `border-radius:50%`,
`background: radial-gradient(circle closest-side, #14200f 55%, transparent 100%)`, holding a
`<canvas>` that fills it. The counter is drawn on that canvas in Instrument Serif at
`round(h · 0.4)` px, colour cream, centred (`textAlign center; textBaseline middle; y = h/2 + px·0.04`,
snapped to device pixels), DPR-scaled, redrawn only when the number changes and not before
`document.fonts.load()` resolves for that face. Value: `round(100 · s²(3 − 2s))` with `s = t/2600`.

### The lift

The overlay lifts when the bloom has finished **and** the scene has drawn its first frame (cap: 4 s
after the bloom if no frame arrives). Then: draw `100`, mark the hero **revealed** (starts the
entrances below), set `pointer-events:none` on the overlay, and run a soft over-damped spring
`reveal: 0 → 1` (`tension 15, friction 11.5`), writing `--reveal` on the overlay each frame. The
mask:

```css
.preloader-mask {
  --hole: calc(var(--reveal, 0) * (var(--preloader-hole) + var(--preloader-feather)) - var(--preloader-feather));
  -webkit-mask-image: radial-gradient(circle at 50% 50%, transparent var(--hole), black calc(var(--hole) + var(--preloader-feather)));
          mask-image: radial-gradient(circle at 50% 50%, transparent var(--hole), black calc(var(--hole) + var(--preloader-feather)));
}
```

At the same time the dandelion box scales `1 + 0.25·reveal` and fades `opacity 1 − reveal`.
Remove the overlay once the feather has passed every corner: `reveal ≥ coverReveal() + 0.02`, where
`coverReveal() = (hypot(innerWidth, innerHeight)/2 + 0.14·vmax) / (0.86·vmax)` and
`vmax = max(innerWidth, innerHeight)`. The radius the hole shows, in px from the viewport centre, is
`seenRadius(r) = (r · 0.86 − 0.07) · vmax` — the headings below wait for it.

## Hero entrances (after the reveal starts)

Everything is drawn at rest in the HTML; when the reveal starts each element jumps to its `from`
state (still under the curtain) and springs to rest after its delay. Default spring `tension 120,
friction 28` (soft, no overshoot). Rules use `tension 60, friction 22`; dots `tension 260,
friction 16`. "Mask" means the element is `overflow:hidden` and an inner block moves inside it at
full opacity.

| element | delay (ms) | from → to |
|---|---|---|
| rule A | 100 | `scaleY 0→1`, origin top |
| rule B | 200 | `scaleY 0→1`, origin top |
| rule C | 250 | `scaleX 0→1`, origin left |
| rule D | 400 | `scaleX 0→1`, origin right |
| dots 1 / 2 / 3 | 800 / 880 / 960 | `scale 0→1` |
| tablet band rule / dot | 150 / 700 | `scaleY 0→1` / `scale 0→1` |
| tablet cards dot | 850 | `scale 0→1` |
| nav item *i* | 250 + 70·i | mask, inner `translateY 130% → 0` (the mask has `padding-bottom 6; margin-bottom -6` so the hairline fits) |
| Make a Wish | 300 | `clip-path: inset(0 50% 0 50% round 2.5625rem) → inset(0 0 0 0 round 2.5625rem)`; once settled (delay + 1600 ms) **remove the clip-path** — an ancestor clip-path kills `backdrop-filter` |
| media card | 350 | `clip-path: inset(100% 0 0 0 round 1.5rem) → inset(0 0 0 0 round 1.5rem)` |
| stat card | 500 | same as the media card |
| paragraph | 550 | mask, inner `translateY 105% → 0` |
| CTA | 650 | `clip-path: inset(0 100% 0 0 round 2.5625rem) → inset(0 0 0 0 round 2.5625rem)`, then removed at rest |

### Blur In — the wordmark and the h1

Both are held `visibility:hidden` (laid out) until the preloader's hole uncovers their centre
(`seenRadius(reveal) ≥` the distance from the viewport centre to the element's centre). Then
"Blur In": split the text into words (`display:inline-flex` per word, words separated by a flex
`column-gap` of **0.17em** — Instrument Serif's own space width, so the line breaks hold) and each
word into letter spans. After **150 ms**, letter *n* fades `opacity 0 → 1` on a spring
`tension 25, friction 12` starting at `n · 50 ms`. The blur is **one filter per word**, not per
letter: word *w* whose letters are `first … first+count−1` gets
`filter: blur(8 · (1 − r(elapsed − middle·50)) px)` with `middle = first + (count−1)/2`, no filter
before its first letter starts (`elapsed < first·50`) and none once under 0.05px:

```js
const R = Math.sqrt(12 * 12 - 4 * 25), R1 = (-12 + R) / 2, R2 = (-12 - R) / 2;    // over-damped, ζ 1.2
const r = (ms) => ms <= 0 ? 0 : 1 - (R2 * Math.exp(R1 * ms / 1000) - R1 * Math.exp(R2 * ms / 1000)) / (R2 - R1);
// run `elapsed` linearly from 0 to (letters − 1)·50 + 2100 ms
```

## The WebGL scene

The dandelion is a three.js **r143** scene on a `WebGL1Renderer` (`antialias: false` — every frame
is drawn into the composer's targets, MSAA would smooth nothing). Its layers, back to front:

1. **Backdrop** — a full-screen shader at **⅓ resolution** into a render target: the warm haze sky,
   an fbm tree line, the mottled meadow, the sun, 34 bokeh glints, 9 soft mid-distance grass blades
   (some with backlit seed heads) and four soft out-of-focus clocks; blitted at the far plane.
2. **The plant** — a hand-built tube stem (48×18) that bends as a cantilever every frame, a pitted
   receptacle and 16 reflexed bracts, 7 in-focus grass blades with veins, folded midribs and cuticle
   sheen, translucent in the backlight.
3. **Fine hairs** on the stem and along the blade edges, dew drops that act as lenses.
4. **The seeds** — 210 hero seeds on a Fibonacci sphere + 14 drifting air seeds: instanced
   ribbed achenes, and every beak and 44 pappus hairs per seed as anti-aliased screen-space fibres
   with per-vertex **depth of field**; off the focal plane a seed becomes a soft backlit disc sprite.
5. **Motes** (70 out-of-focus fluff discs) and a **midge swarm** (12), additive.
6. **Foreground** — a premultiplied ⅓-resolution layer: a near out-of-focus clock, the meadow's dark
   near edge, four soft foreground blades, out-of-focus seeds before the lens, the hero clock's limb
   glow and lens veil/ghosts.
7. **Final pass** — a golden-hour grade (teal shadows, gold highlights, gentle S-curve), vignette,
   grain.

**Interaction:** the pointer (on `window`) is smoothed and its *speed* is the breath — seeds within
`breathRadius` lean away and let go past a per-seed threshold, the stem bends away, nearby free seeds
are pushed. A `pointerdown` anywhere that is **not** on a link, button, input, label or dialog blows
a gust from the click point; the Make a Wish button blows from NDC `(-0.6, -0.05)`. A still cursor
near the clock regrows it faster. The camera sways slowly and parallaxes with the pointer.

**Framing:** the clock's screen position depends on the box width — phone (≤ 639) centred at
`x 0.5, y 0.41`; tablet (≤ 1023) `x 0.58, y 0.29`; desktop `x 0.471, y 0.556` (0 = left/top).

**Loop rules:** one clock in seconds for every timed motion (`dt` clamped to 0.1 s, the simulation
step to 0.05 s); on desktop (`(hover: hover) and (pointer: fine)`) gate the **draw** to every
≥ 12.5 ms (≈ 60 fps on a 120 Hz panel) while accumulating the simulated time; on phones draw every
frame (no cap). DPR capped at **1.5**. Resize only when the box's CSS size or DPR really changed,
and draw at once after a real resize. `preventDefault()` on `webglcontextlost` and rebuild the scene
on a fresh canvas (no fade-in on a rebuild). When the first frame has been drawn, tell the preloader.
If WebGL fails to start, show the still image instead.

Build it exactly from the code below — the shaders, the tuning, and the simulation are the effect.
Paste the three blocks, in order, into one `<script type="module">`.

### 1 · Shaders (verbatim GLSL ES 1.0)

```js
/**
 * GLSL for the dandelion scene — ported verbatim from the MOTDES
 * `dandelion.html` scene (GLSL ES 1.0, run on three.js r143's WebGL1Renderer).
 * Only the final composite changed: the source's torus/bloom composers never
 * had anything on their layers, so their (always black) textures are gone.
 */
// seed proportions (1 unit ≈ 18 mm): receptacle radius, achene top, beak top (pappus apex), hair length
const R0 = 0.12;
const A0 = 0.216;
const AP = 0.756;
const HL = 0.32;
const SEED_MID = 0.48;
const FULLSCREEN_VERT = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
/* ---------- final composite: a grade, vignette, grain, fade ---------- */
const FINAL_VERT = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }`;
const FINAL_FRAG = `
  uniform sampler2D tDiffuse;
  uniform float uFade, iTime, uGrain, uVignette, uWarm;
  varying vec2 vUv;
  float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
  void main() {
    vec3 col = texture2D(tDiffuse, vUv).xyz;
    // a warm golden-hour grade: green-teal in the shadows, gold in the highs, a gentle S, colour kept alive
    col = max(col, 0.0);
    float lum = dot(col, vec3(0.299, 0.587, 0.114));
    col = mix(col, col * vec3(0.9, 1.03, 0.97), (1.0 - smoothstep(0.0, 0.45, lum)) * 0.55 * uWarm);
    col = mix(col, col * vec3(1.06, 1.0, 0.86), smoothstep(0.4, 1.0, lum) * 0.6 * uWarm);
    col = mix(vec3(lum), col, 1.0 + 0.1 * uWarm);
    col = mix(col, col * col * (3.0 - 2.0 * col), 0.22);
    col = col * 0.97 + vec3(0.012, 0.014, 0.006);
    col *= mix(1.0 - 0.3 * uVignette, 1.0, smoothstep(1.25, 0.4, length((vUv - 0.5) * vec2(1.15, 1.0))));
    col += (hash(vUv * 1000.0 + fract(iTime * 2.0)) - 0.5) * uGrain;
    gl_FragColor = vec4(col * uFade, 1.);
  }`;
const GLSL_COMMON = `
  float h12(vec2 p){ vec3 p3 = fract(p.xyx * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
  float h11(float n){ return h12(vec2(n, n * 0.371 + 11.3)); }
  float vnoise(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
    return mix(mix(h12(i), h12(i + vec2(1.0, 0.0)), f.x), mix(h12(i + vec2(0.0, 1.0)), h12(i + vec2(1.0, 1.0)), f.x), f.y); }
  // forward scattering (Henyey-Greenstein, g 0.7, ×4π): 18.9 looking into the sun, 0.28 side-on
  float fwdPh(float c){ float d = 1.0 + 0.49 - 1.4 * c; return 0.51 / (d * sqrt(d)); }
  // 1 inside, soft edge of width b (never a reversed smoothstep)
  float soft(float d, float r, float b){ return 1.0 - smoothstep(r - b, r + b, d); }
`;
const QROT = `vec3 qrot(vec4 q, vec3 v){ return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); }`;
// An anti-aliased fibre. Each segment is a screen-space quad at least one pixel wide plus a one-pixel soft ramp; a fibre
// thinner than a pixel keeps its true width as coverage (alpha), so the hairs read the same at any DPR and do not
// shimmer as they move. The ramp also carries the circle of confusion: a fibre off the focal plane spreads into a soft
// smear with the same energy (depth of field per vertex, so one seed can be sharp at its root and soft at its far hairs).
const GLSL_FIBER = `
  uniform vec2 uVp; uniform float uFocus, uAper, uFDepth;
  float cocPx(float d){ return min(48.0, uAper * max(0.0, abs(d - uFocus) - uFDepth) / max(d, 0.1) * uVp.y); }
  vec4 fiber(vec3 w0, vec3 w1, float end, float side, float wd, out float across, out float halfW, out float cov, out float coc){
    vec4 c0 = projectionMatrix * viewMatrix * vec4(w0, 1.0), c1 = projectionMatrix * viewMatrix * vec4(w1, 1.0);
    vec4 c = end < 0.5 ? c0 : c1;
    float cw = max(c.w, 0.05);
    vec2 s0 = c0.xy / max(c0.w, 0.05) * 0.5 * uVp, s1 = c1.xy / max(c1.w, 0.05) * 0.5 * uVp;
    vec2 dir = s1 - s0; float L = length(dir); dir = L > 1e-3 ? dir / L : vec2(1.0, 0.0);
    vec2 nrm = vec2(-dir.y, dir.x);
    float wpx = wd * projectionMatrix[1][1] * 0.5 * uVp.y / cw;
    coc = cocPx(cw);
    float ramp = 1.0 + coc, core = 0.5 * max(wpx, 1.0);
    halfW = core + 0.5 * ramp; cov = min(wpx, 1.0); across = side * halfW;
    vec2 off = nrm * across + dir * (end < 0.5 ? -0.5 : 0.5) * ramp;
    c.xy += off / (0.5 * uVp) * c.w;
    return c;
  }
`;
const GLSL_FIBER_FRAG = `
  float fiberA(float across, float halfW, float coc){ float ramp = 1.0 + coc, core = halfW - 0.5 * ramp;
    return 1.0 - smoothstep(core - 0.5 * ramp, core + 0.5 * ramp, abs(across)); }
`;
/* ---------- the out-of-focus meadow (third resolution) ---------- */
const BACKDROP_VERT = FULLSCREEN_VERT;
const BACKDROP_FRAG = GLSL_COMMON +
    `
  uniform vec2 uRes, uSunP, uPar; uniform float uTime, uSunGlow, uBokeh, uClocks, uHaze, uGust, uSway;
  uniform vec3 uSky, uTree, uMeadow, uShadeC, uSunC, uGlint, uFil;
  varying vec2 vUv;
  // an out-of-focus clock: a soft disc with a brighter limb, a hint of radial texture, a darker seed core and a soft stem
  vec4 softClock(vec2 p, vec2 c, float r, float b, vec2 sp, float bare){
    float t = uTime;
    vec2 cc = c + vec2(sin(t * 0.55 + c.x * 9.0) + 0.6 * sin(t * 1.3 + c.y * 7.0), 0.0) * 0.004 * uSway * (1.0 + uGust * 4.0);
    vec2 d = p - cc; float l = length(d);
    float body = soft(l, r, b);
    float ang = atan(d.y, d.x);
    float streak = 0.82 + 0.18 * vnoise(vec2(ang * 7.0, 1.0)) * (1.0 - bare * 0.5);
    float limb = exp(-pow((l - r * 0.88) / (b + r * 0.14), 2.0));
    float core = 1.0 - smoothstep(0.0, r * 0.4, l);
    float lit = 0.8 + 1.2 * exp(-length(cc - sp) * 2.4);
    float side = 0.8 + 0.2 * dot(d / max(l, 1e-4), normalize(sp - cc));
    float a = body * (0.5 + 0.35 * limb) * (1.0 - core * 0.3) * (1.0 - bare * 0.55) * uClocks;
    vec3 cl = mix(uFil, uSunC, 0.45) * lit * side * streak;
    // the stem
    float yy = cc.y - p.y;
    float sx = cc.x + 0.08 * yy * yy - 0.02 * yy;
    float sw = r * 0.07 + b * 0.5;
    float st = soft(abs(p.x - sx), sw, b * 0.6) * smoothstep(0.0, r * 0.3, yy) * uClocks;
    vec3 sc = mix(uShadeC * 1.4, uMeadow, 0.5) + uSunC * 0.35 * lit * smoothstep(-sw, sw, (p.x - sx) * sign(sp.x - sx));
    vec3 rgb = sc * st * 0.8;
    float A = st * 0.8;
    rgb = rgb * (1.0 - a) + cl * a; A = A + a * (1.0 - A);
    return vec4(rgb, A);
  }
  void main(){
    float asp = uRes.x / uRes.y, t = uTime;
    vec2 p = (vUv - 0.5) * vec2(asp, 1.0);
    vec2 sp = (uSunP - 0.5) * vec2(asp, 1.0);
    vec2 q = p + uPar * 0.25;      // the far layer barely moves
    vec2 m = p + uPar * 0.12;      // the middle distance
    float ds = length(p - sp);
    float sunW = exp(-ds * 2.4);
    // sky: warm haze, paler near the sun
    vec3 sky = mix(uSky * 0.78, mix(uSky, vec3(1.0, 0.96, 0.85), 0.35), sunW);
    // the tree line: a soft fbm crown, never a line
    float hz = 0.13 + (vnoise(vec2(q.x * 3.2, 1.7)) - 0.5) * 0.14 + (vnoise(vec2(q.x * 8.0 + 3.1, 4.2)) - 0.5) * 0.05;
    float tree = 1.0 - smoothstep(hz - 0.09, hz + 0.09, q.y);
    vec3 treeC = mix(uTree, uSky, 0.12 + 0.7 * sunW) * (0.85 + 0.3 * vnoise(q * vec2(6.0, 9.0)));
    // the meadow below it, darker and greener toward the camera, mottled with light and shade
    float md = clamp((0.04 - q.y) / 0.5, 0.0, 1.0);
    vec3 meadow = mix(mix(uTree, uSky, 0.45), uMeadow, smoothstep(0.0, 0.4, md));
    meadow = mix(meadow, uShadeC, smoothstep(0.35, 1.0, md) * 0.8);
    float mot = vnoise(q * vec2(2.4, 5.0) + vec2(0.0, 3.0)) * 0.65 + vnoise(q * vec2(5.0, 11.0)) * 0.35;
    meadow *= 0.62 + 0.55 * mot;
    meadow += uSunC * 0.25 * exp(-length((q - sp) * vec2(0.8, 2.2)) * 2.5) * (1.0 - md);
    float mm = 1.0 - smoothstep(-0.06, 0.08, q.y);
    vec3 col = mix(sky, treeC, tree);
    col = mix(col, meadow, mm);
    // aerial perspective: the horizon band and everything near the sun lift toward the haze colour (soft horizon)
    float band = exp(-pow((q.y - 0.07) / 0.17, 2.0));
    col = mix(col, uSky * 1.02, clamp((band * 0.3 + sunW * 0.55) * uHaze, 0.0, 0.92));
    // the sun
    col += uSunC * (exp(-ds * ds * 260.0) * 0.9 + exp(-ds * ds * 22.0) * 0.32 + sunW * 0.22) * uSunGlow;
    // bokeh: glints off wet grass and leaves, round, brighter rims, cat's-eye toward the frame edge
    for (int i = 0; i < 34; i++) {
      float fi = float(i);
      vec2 c = vec2((h11(fi * 1.31) - 0.5) * asp * 1.1, -0.48 + h11(fi * 2.17) * 0.78);
      c += vec2(sin(t * 0.21 + fi * 1.7), cos(t * 0.17 + fi * 2.3)) * 0.006;
      float r = (0.018 + 0.05 * h11(fi * 3.1)) * (1.0 + clamp(0.1 - c.y, 0.0, 1.0) * 1.3);
      vec2 d = m - c;
      vec2 off = -c / max(length(c), 0.2) * r * 0.35 * smoothstep(0.2, 0.8, length(c));
      float disc = soft(length(d), r, r * 0.1) * soft(length(d - off), r * 1.05, r * 0.1);
      disc *= 0.7 + 0.3 * smoothstep(r * 0.5, r * 0.98, length(d));
      float lit = exp(-length(c - sp) * 2.0);
      vec3 bc = mix(mix(uMeadow * 1.6, uGlint, 0.35), uGlint, lit);
      float tw = 0.75 + 0.25 * sin(t * 0.6 + fi * 3.7);
      col += bc * disc * uBokeh * (0.05 + 0.1 * h11(fi * 5.9)) * (0.4 + 2.2 * lit) * tw;
    }
    // soft grass blades in the middle distance, rim-lit on the sun side
    for (int i = 0; i < 9; i++) {
      float fi = float(i);
      float x0 = (h11(fi * 3.7 + 1.0) - 0.5) * asp * 1.05, y0 = -0.56;
      float hg = 0.3 + 0.42 * h11(fi * 5.3), lean = (h11(fi * 7.1) - 0.5) * 0.6;
      float bl = 0.008 + 0.014 * h11(fi * 9.7);
      float yy = clamp((m.y - y0) / hg, 0.0, 1.0);
      float cx = x0 + lean * hg * yy * yy + sin(t * 0.7 + fi * 1.7) * 0.008 * yy * yy * uSway * (1.0 + uGust * 4.0);
      float w = (0.006 + 0.008 * h11(fi * 2.3)) * (1.0 - yy) + 0.001;
      float dx = m.x - cx;
      float a = soft(abs(dx), w, bl) * soft(m.y, y0 + hg, bl * 1.5) * 0.75;
      float lit = exp(-length(vec2(cx, y0 + hg) - sp) * 1.8);
      vec3 bcol = mix(uShadeC * 1.6, uMeadow * 0.8, 0.4 * yy) + uSunC * 0.5 * lit * smoothstep(0.0, w + bl, dx * sign(sp.x - cx));
      col = mix(col, bcol, a);
      // a backlit seed head on some
      if (h11(fi * 4.4) > 0.55) {
        vec2 tp = vec2(x0 + lean * hg, y0 + hg + 0.03);
        vec2 dd = (m - tp) * vec2(1.0, 0.45);
        float ph = soft(length(dd), 0.012, 0.012);
        col = mix(col, mix(uGlint, uSunC, 0.5) * (0.6 + 1.4 * lit), ph * 0.55);
      }
    }
    // four soft clocks at different depths: two behind, one far, one half-blown
    vec4 k;
    k = softClock(m, vec2(-0.68, 0.06), 0.085, 0.028, sp, 0.0); col = col * (1.0 - k.a) + k.rgb;
    k = softClock(q, vec2(0.30, 0.19), 0.032, 0.012, sp, 0.0); col = col * (1.0 - k.a) + k.rgb;
    k = softClock(m, vec2(0.50, 0.0), 0.07, 0.024, sp, 0.0); col = col * (1.0 - k.a) + k.rgb;
    k = softClock(m, vec2(0.74, -0.24), 0.055, 0.02, sp, 0.7); col = col * (1.0 - k.a) + k.rgb;
    col += (h12(gl_FragCoord.xy) - 0.5) / 255.0;
    gl_FragColor = vec4(col, 1.0);
  }`;
/** Draws the backdrop render target behind everything (at the far plane). */
const BACKDROP_BLIT_VERT = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.99999, 1.0); }`;
const BACKDROP_BLIT_FRAG = GLSL_COMMON +
    `uniform sampler2D tBg; varying vec2 vUv;
  void main(){ gl_FragColor = vec4(texture2D(tBg, vUv).rgb + (h12(gl_FragCoord.xy) - 0.5) / 200.0, 1.0); }`;
/* ---------- the plant: stem, receptacle, bracts, grass blades ---------- */
const PLANT_VERT = `attribute vec3 aSide; attribute float aId; varying vec3 vN, vW, vL, vS; varying vec2 vUv; varying float vId;
  void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; vL = position; vN = normalize(mat3(modelMatrix) * normal); vUv = uv;
    vS = mat3(modelMatrix) * aSide; vId = aId;
    gl_Position = projectionMatrix * viewMatrix * w; }`;
const PLANT_FRAG = GLSL_COMMON +
    `
  uniform vec3 uBase, uTrans, uSun, uSunDir, uSky, uShade; uniform float uRim, uTransAmt, uEdge, uPit, uBack, uKind;
  varying vec3 vN, vW, vL, vS; varying vec2 vUv; varying float vId;
  // an anti-aliased stripe: 1 on lines at integer x, half-width w (in x units), filtered by the pixel footprint
  float lines(float x, float w){ float d = abs(fract(x + 0.5) - 0.5), fw = fwidth(x), aa = fw * 0.75 + 1e-4;
    return mix(1.0 - smoothstep(w - aa, w + aa, d), 2.0 * w, smoothstep(0.25, 0.5, fw)); }   // fades to its mean where too dense to draw
  void main(){
    vec3 V = normalize(cameraPosition - vW);
    vec3 Ng = normalize(vN); float ndv = dot(Ng, V); if (ndv < 0.0) Ng = -Ng;
    vec3 N = Ng;
    float fwd = fwdPh(dot(-V, uSunDir));
    vec3 base = uBase; float trM = 1.0, alpha = 1.0, sheen = 0.0, edgeL = 0.0;
    if (uPit > 0.5) { // the bare receptacle: a pale dome pitted where the seeds sat
      vec3 n = normalize(vL); float c = vnoise(vec2(atan(n.z, n.x) * 9.0, n.y * 14.0));
      base *= 0.7 + 0.45 * smoothstep(0.25, 0.6, c);
    }
    if (uKind > 1.5) {
      // ---- a grass blade: parallel veins converging to the tip, a folded midrib, cuticle sheen, a drying tip ----
      float x = vUv.y * 2.0 - 1.0, u = vUv.x, id = vId;
      vec3 S = normalize(vS);
      N = normalize(Ng + S * x * 0.55);                                   // the V of the fold
      float vx = (x * 0.5 + 0.5) * 8.0;
      float vein = lines(vx, 0.1), mid = 1.0 - smoothstep(0.03, 0.03 + fwidth(x) * 1.5 + 0.02, abs(x));
      float fib = vnoise(vec2(x * 22.0, u * 4.0 + id * 9.0));             // fine fibres between the veins
      float blot = vnoise(vec2(x * 2.5 + id * 3.0, u * 18.0 + id * 7.0));
      float h = h11(id * 1.7 + 0.3);
      base *= mix(vec3(0.9, 1.04, 0.88), vec3(1.1, 1.0, 0.8), h);         // each blade its own green
      base *= 0.78 + 0.3 * smoothstep(0.0, 0.45, u);                      // darker toward the sheath
      base = mix(base, base * vec3(1.3, 1.2, 0.62), smoothstep(0.62, 1.0, u) * 0.55);   // yellowing toward the tip
      base = mix(base, vec3(0.42, 0.32, 0.16), smoothstep(0.93, 0.985, u) * step(0.45, h11(id * 3.1)));   // a dry tip on some
      base *= (0.86 + 0.24 * blot) * (0.93 + 0.12 * fib) * (1.0 - vein * 0.2) * (1.0 + mid * 0.22);
      trM = (1.0 - vein * 0.5 - mid * 0.5) * (0.85 + 0.3 * fib) * (1.0 + 0.35 * abs(x));   // veins are thicker: darker in transmitted light
      sheen = 0.22;
      float e = min(vUv.y, 1.0 - vUv.y), ea = fwidth(vUv.y);
      alpha = smoothstep(0.0, ea * 1.6 + 1e-4, e);                        // an anti-aliased silhouette
      edgeL = pow(abs(x), 10.0) * uEdge;
    } else if (uKind > 0.5) {
      // ---- the stem: a hollow scape, fine longitudinal ridges, a red-brown flush low down, paler under the head ----
      float u = vUv.x, ar = vUv.y * 22.0;
      vec3 T = normalize(vS), A = normalize(cross(T, Ng));
      float rdg = sin(ar * 6.2832);
      float aa = clamp(1.0 - fwidth(ar) * 1.4, 0.0, 1.0);                  // ridges fade out where they would alias
      N = normalize(Ng + A * rdg * 0.16 * aa);
      float streak = vnoise(vec2(ar * 0.5, u * 30.0)), mott = vnoise(vec2(vUv.y * 5.0, u * 9.0));
      base = mix(base * vec3(1.32, 0.78, 0.72), base, smoothstep(0.55, 0.8, u) * 0.6 + 0.4);
      base = mix(base, base * vec3(1.18, 1.14, 0.8), smoothstep(0.93, 1.0, u));
      base *= (0.9 + 0.16 * streak) * (0.92 + 0.14 * mott) * (1.0 - 0.08 * aa * (0.5 - 0.5 * rdg));
      trM = 0.85 + 0.35 * streak;
      sheen = 0.3;
      float nd = abs(ndv);
      alpha = smoothstep(0.0, fwidth(nd) * 1.5 + 1e-4, nd);
    } else if (uEdge > 0.0) base *= 0.88 + 0.12 * sin(vUv.y * 60.0) * (1.0 - abs(vUv.y * 2.0 - 1.0));   // bract veins
    vec3 amb = mix(uShade * 1.2, uSky, N.y * 0.5 + 0.5) * 0.5;
    vec3 col = base * (amb + uSun * max(dot(N, uSunDir), 0.0) * 0.7);
    // light through the tissue: the sun is behind, the face we see is lit from the back
    float tr = max(0.0, dot(-N, uSunDir)) * 0.8 + 0.2 * max(0.0, dot(-Ng, uSunDir));
    col += uTrans * uSun * base * 2.2 * tr * trM * (0.25 + 0.12 * min(fwd, 8.0)) * uTransAmt * uBack;
    // the waxy cuticle mirrors the bright haze at grazing angles
    float fr = pow(1.0 - max(dot(N, V), 0.0), 4.0);
    col += uSky * fr * sheen * (0.5 + 0.5 * N.y);
    // the silhouette catches the light (fine hairs on the stem, the blade's edge)
    float rim = pow(1.0 - max(dot(Ng, V), 0.0), 3.0);
    col += uSun * rim * min(fwd, 10.0) * 0.12 * uRim * uBack;
    col += uSun * edgeL * min(fwd, 10.0) * 0.1 * uBack;
    if (uKind < 0.5 && uEdge > 0.0) col += uSun * pow(abs(vUv.y * 2.0 - 1.0), 10.0) * min(fwd, 10.0) * 0.1 * uEdge * uBack;
    gl_FragColor = vec4(col, alpha);
  }`;
/* ---------- fine hairs on the stem and the blade edges ---------- */
const HAIR_VERT = GLSL_FIBER +
    `attribute vec3 aP1, aC; attribute float aId; varying float vAcr, vHalf, vCov, vCoc, vT; varying vec3 vW, vTan;
  void main(){
    float wd = mix(0.0016, 0.0007, aC.z * 0.5 + aC.x * 0.5);
    gl_Position = fiber(position, aP1, aC.x, aC.y, wd, vAcr, vHalf, vCov, vCoc);
    vW = aC.x < 0.5 ? position : aP1; vTan = normalize(aP1 - position + 1e-6); vT = aC.z * 0.5 + aC.x * 0.5;
  }`;
const HAIR_FRAG = GLSL_COMMON +
    GLSL_FIBER_FRAG +
    `uniform vec3 uFil, uSun, uSunDir, uSky; uniform float uBack;
  varying float vAcr, vHalf, vCov, vCoc, vT; varying vec3 vW, vTan;
  void main(){
    float fwd = fwdPh(dot(normalize(vW - cameraPosition), uSunDir));
    float tl = dot(vTan, uSunDir), tf = sqrt(max(0.0, 1.0 - tl * tl));
    vec3 col = uFil * 0.3 + uSky * 0.1 + uSun * min(fwd, 14.0) * 0.1 * uBack * (0.35 + 0.65 * tf);
    float a = min(1.0, 1.2 * vCov * fiberA(vAcr, vHalf, vCoc)) * (0.7 + 0.3 * (1.0 - vT));
    if (a < 0.003) discard;
    gl_FragColor = vec4(col, a);
  }`;
/* ---------- the seeds: beaks and pappus hairs as fibres ---------- */
const SEED_VERT = QROT +
    GLSL_FIBER +
    `
  attribute vec4 hA; attribute vec4 hB; attribute float hS; attribute vec3 aPos; attribute vec4 aQuat; attribute vec4 aInfo;
  uniform float uTime, uSize, uHairW;
  varying float vA, vPart, vT, vAcr, vHalf, vCov, vCoc; varying vec3 vW, vTan;
  vec3 seedPt(float t, out vec3 tg){
    if (hB.z < 1.0) { tg = vec3(0.0, 1.0, 0.0); return vec3(0.0, mix(${A0}, ${AP}, t), 0.0); }
    float open = aInfo.z;
    float e = mix(0.1, hA.y, open) - hB.y * t * open;
    vec3 dir = vec3(sin(e) * cos(hA.x), cos(e), sin(e) * sin(hA.x));
    dir.y += sin(uTime * 5.0 + aInfo.w * 40.0 + hA.x * 5.0) * 0.035 * t;
    tg = dir;
    return vec3(0.0, ${AP}, 0.0) + dir * (${HL} * hB.x * t);
  }
  void main(){
    float s = aInfo.y * uSize; vec3 tg0, tg1;
    vec3 w0 = aPos + qrot(aQuat, seedPt(hA.z, tg0) * s), w1 = aPos + qrot(aQuat, seedPt(hA.w, tg1) * s);
    float t = mix(hA.z, hA.w, hB.w);
    // true widths: the beak about 70 microns, a pappus hair a few microns, thinning to its tip
    float wd = (hB.z < 1.0 ? 0.0042 : 0.0017 * mix(1.0, 0.45, t) * uHairW) * s;
    gl_Position = fiber(w0, w1, hB.w, hS, wd, vAcr, vHalf, vCov, vCoc);
    vW = hB.w < 0.5 ? w0 : w1; vTan = qrot(aQuat, tg1); vA = aInfo.x; vPart = hB.z; vT = t;
  }`;
const SEED_FRAG = GLSL_COMMON +
    GLSL_FIBER_FRAG +
    `
  uniform vec3 uFil, uBeak, uSun, uSunDir, uSky; uniform float uBack;
  varying float vA, vPart, vT, vAcr, vHalf, vCov, vCoc; varying vec3 vW, vTan;
  void main(){
    vec3 ray = normalize(vW - cameraPosition);
    float fwd = fwdPh(dot(ray, uSunDir));
    // a thin fibre scatters most when it lies across the light
    float tl = dot(normalize(vTan), uSunDir); float tf = sqrt(max(0.0, 1.0 - tl * tl));
    vec3 col; float a;
    if (vPart < 1.0) { col = uBeak * 0.55 + uSun * min(fwd, 14.0) * 0.06 * uBack * (0.5 + 0.5 * tf); a = 0.8; }
    else {
      col = uFil * 0.42 + uSky * 0.12 + uSun * min(fwd, 14.0) * 0.085 * uBack * (0.4 + 0.6 * tf);
      a = mix(1.0, 0.55, vT);
    }
    // far off the focal plane the fibres give way to the seed's soft disc (drawn by the bokeh sprite)
    a *= vCov * fiberA(vAcr, vHalf, vCoc) * (1.0 - smoothstep(8.0, 30.0, vCoc));
    a *= 1.0 + 0.012 * min(fwd, 14.0);   // toward the sun the haze is near white: the hairs hold out by glowing denser
    a = min(a, 1.0) * vA;
    if (a < 0.003) discard;
    gl_FragColor = vec4(col, a);
  }`;
/* ---------- the achenes: a ribbed spindle ---------- */
const ACHENE_VERT = QROT +
    GLSL_FIBER +
    `attribute vec3 aPos; attribute vec4 aQuat; attribute vec4 aInfo; uniform float uSize;
  varying vec3 vN, vW, vL; varying float vA;
  void main(){ vec3 wp = aPos + qrot(aQuat, position * aInfo.y * uSize); vN = qrot(aQuat, normal); vW = wp; vL = position;
    vec4 mv = viewMatrix * vec4(wp, 1.0);
    vA = aInfo.x * (1.0 - smoothstep(3.0, 14.0, cocPx(-mv.z)));   // a defocused seed body melts into its disc
    gl_Position = projectionMatrix * mv; }`;
const ACHENE_FRAG = GLSL_COMMON +
    `uniform vec3 uAch, uSun, uSunDir, uSky, uShade; uniform float uBack;
  varying vec3 vN, vW, vL; varying float vA;
  void main(){
    if (vA < 0.02) discard;
    vec3 V = normalize(cameraPosition - vW), N = normalize(vN);
    float fwd = fwdPh(dot(-V, uSunDir));
    float ridge = 0.78 + 0.22 * sin(atan(vL.z, vL.x) * 10.0);
    float barb = 0.85 + 0.15 * step(0.6, fract(vL.y * 70.0)) * smoothstep(0.1, 0.17, vL.y);
    vec3 amb = mix(uShade * 1.5, uSky, N.y * 0.5 + 0.5) * 0.8;
    vec3 col = uAch * (amb + uSun * max(dot(N, uSunDir), 0.0) * 0.8) * ridge * barb;
    col += uAch * uSun * min(fwd, 12.0) * 0.06 * uBack;   // a little light through the thin seed coat
    col += uSun * pow(1.0 - max(dot(N, V), 0.0), 2.0) * min(fwd, 10.0) * 0.1 * uBack;
    gl_FragColor = vec4(col, vA);
  }`;
/* ---------- the seed's soft disc: what a pappus becomes off the focal plane ---------- */
const SEED_BLUR_VERT = GLSL_COMMON +
    `attribute vec2 aB; uniform vec3 uFil, uSun, uSunDir, uSky; uniform float uBack;
  varying float vI; varying vec3 vC;
  void main(){ gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0); gl_PointSize = aB.y > 0.002 ? aB.x : 0.0;
    float f = fwdPh(dot(normalize(position - cameraPosition), uSunDir));
    vC = uFil * 0.4 + uSky * 0.15 + uSun * min(f, 14.0) * 0.08 * uBack; vI = aB.y; }`;
const SEED_BLUR_FRAG = `varying float vI; varying vec3 vC;
  void main(){ vec2 c = gl_PointCoord * 2.0 - 1.0; float r = length(c); if (r > 1.0) discard;
    // a soft, slightly limb-brightened disc with a denser core where the beak and achene sit
    float d = (1.0 - smoothstep(0.62, 1.0, r)) * (0.75 + 0.25 * smoothstep(0.3, 0.85, r)) + 0.25 * exp(-r * r * 9.0);
    gl_FragColor = vec4(vC, d * vI); }`;
/* ---------- dew: drops sitting on the blades and the stem ---------- */
const DEW_VERT = `attribute float aR; uniform float uPx; uniform vec2 uSunNdc; varying vec2 vSd;
  void main(){ vec4 mv = viewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv;
    gl_PointSize = max(1.0, 2.0 * aR * uPx / -mv.z);
    vec2 nd = gl_Position.xy / gl_Position.w; vSd = normalize(uSunNdc - nd + 1e-4); }`;
const DEW_FRAG = GLSL_COMMON +
    `uniform vec3 uSun, uSky, uShade, uMeadowC; uniform float uDew, uBack; varying vec2 vSd;
  void main(){
    vec2 c = gl_PointCoord * 2.0 - 1.0; c.y = -c.y; float r2 = dot(c, c); if (r2 > 1.0) discard;
    // a drop is a lens: it shows the world upside down — the bright haze at its bottom, the dark meadow at its top
    vec3 col = mix(uShade * 1.3, uSky * 1.05, 1.0 - smoothstep(-0.6, 0.5, c.y + dot(c, vSd) * 0.3));
    col = mix(col, uMeadowC * 0.6, smoothstep(0.35, 0.0, abs(c.y + 0.1)) * 0.25);
    col *= 1.0 - smoothstep(0.55, 1.0, r2) * 0.75;                                    // the dark refracting rim
    col += uSun * exp(-dot(c + vSd * 0.5, c + vSd * 0.5) * 14.0) * 1.3 * uBack;      // the sun focused on the far side
    col += vec3(1.0, 0.97, 0.9) * exp(-dot(c - vSd * 0.55, c - vSd * 0.55) * 140.0) * 1.5; // the glint toward the sun
    gl_FragColor = vec4(col, (1.0 - smoothstep(0.8, 1.0, r2)) * uDew);
  }`;
/* ---------- out-of-focus fluff: discs sized by their circle of confusion ---------- */
const MOTE_VERT = GLSL_COMMON +
    `attribute float aB; uniform float uPx, uFocus, uDrift, uTime, uBack; uniform vec3 uSunDir;
  varying float vI;
  void main(){
    vec3 p = position;
    p.x = mod(p.x + uDrift * (0.6 + aB * 0.6) + 9.0, 18.0) - 9.0;
    p.y += sin(uTime * 0.25 + aB * 20.0) * 0.25; p.z += cos(uTime * 0.2 + aB * 13.0) * 0.2;
    vec4 mv = viewMatrix * vec4(p, 1.0); gl_Position = projectionMatrix * mv;
    float d = -mv.z;
    float coc = 0.11 * abs(d - uFocus) / max(d, 0.1) * uPx;
    float px = clamp(coc, 3.0, 110.0); gl_PointSize = px;
    vec3 ray = normalize(p - cameraPosition);
    vI = aB * clamp(7.0 / px, 0.05, 1.0) * (0.15 + 0.12 * min(fwdPh(dot(ray, uSunDir)), 12.0)) * uBack;
    if (d < 0.3) vI = 0.0;
  }`;
const MOTE_FRAG = `uniform vec3 uGlint; uniform float uBokeh; varying float vI;
  void main(){ vec2 c = gl_PointCoord * 2.0 - 1.0; float r = length(c); if (r > 1.0) discard;
    float disc = (1.0 - smoothstep(0.82, 1.0, r)) * (0.7 + 0.3 * smoothstep(0.4, 0.95, r));
    gl_FragColor = vec4(uGlint * disc * vI * uBokeh, 1.0); }`;
/* ---------- midges: a loose swarm dancing in the backlight ---------- */
const MIDGE_VERT = GLSL_COMMON +
    GLSL_FIBER +
    `attribute vec4 aPh, aFq; uniform float uTime, uBack; uniform vec3 uCtr, uSunDir, uPush;
  varying float vI;
  void main(){
    float t = uTime;
    // a midge darts round the swarm's centre: two slow loops, a quick bob, never leaving the swarm
    vec3 o = vec3(sin(t * aFq.x + aPh.x) * 0.42 + sin(t * aFq.y * 2.3 + aPh.y) * 0.1,
                  sin(t * aFq.z * 1.3 + aPh.z) * 0.26 + sin(t * aFq.w + aPh.x) * 0.035,
                  cos(t * aFq.x * 0.8 + aPh.y) * 0.4);
    vec3 p = uCtr + o * (0.7 + 0.6 * aPh.w) + uPush * (0.6 + 0.8 * aPh.w);
    vec4 mv = viewMatrix * vec4(p, 1.0); gl_Position = projectionMatrix * mv;
    float d = -mv.z, coc = cocPx(d);
    float core = max(2.0, 0.035 * projectionMatrix[1][1] * 0.5 * uVp.y / d);
    float px = core + coc; gl_PointSize = px;
    float wing = 0.55 + 0.45 * sin(t * aFq.w * 1.7 + aPh.z * 3.0);   // wings catch the light as it turns
    vI = (core * core) / (px * px) * wing * (0.2 + 0.07 * min(fwdPh(dot(normalize(p - cameraPosition), uSunDir)), 14.0)) * uBack;
  }`;
const MIDGE_FRAG = `uniform vec3 uGlint; varying float vI;
  void main(){ vec2 c = gl_PointCoord * 2.0 - 1.0; float r = length(c); if (r > 1.0) discard;
    gl_FragColor = vec4(uGlint * vI * (1.0 - smoothstep(0.55, 1.0, r)), 1.0); }`;
/* ---------- the foreground layer (third resolution, premultiplied) ---------- */
const FOREGROUND_VERT = FULLSCREEN_VERT;
const FOREGROUND_FRAG = GLSL_COMMON +
    `
  uniform vec2 uRes, uSunP, uPar, uHero; uniform float uTime, uGust, uSway, uHeroR, uHeroFill, uFg, uLens, uClocks, uBokeh, uBack;
  uniform vec3 uShadeC, uMeadow, uSunC, uGlint, uFil;
  varying vec2 vUv;
  void over(inout vec3 rgb, inout float A, vec3 c, float a){ rgb = rgb * (1.0 - a) + c * a; A = A + a * (1.0 - A); }
  void blade(inout vec3 rgb, inout float A, vec2 p, vec2 sp, float x0, float hg, float lean, float w0, float bl, float ph){
    float y0 = -0.62;
    float yy = clamp((p.y - y0) / hg, 0.0, 1.0);
    float cx = x0 + lean * hg * yy * yy + sin(uTime * 0.6 + ph) * 0.012 * yy * yy * uSway * (1.0 + uGust * 3.0);
    float w = w0 * pow(1.0 - yy, 0.7) + 0.002;
    float dx = p.x - cx;
    float a = soft(abs(dx), w, bl) * soft(p.y, y0 + hg, bl) * 0.93 * uFg;
    float lit = exp(-length(vec2(cx, y0 + hg * 0.7) - sp) * 1.6);
    vec3 c = mix(uShadeC * 0.75, uMeadow * 0.55, yy * 0.5) + uSunC * (0.08 + 0.5 * lit) * smoothstep(-w * 0.2, w + bl, dx * sign(sp.x - cx)) * uBack;
    over(rgb, A, c, a);
  }
  void main(){
    float asp = uRes.x / uRes.y, t = uTime;
    vec2 p = (vUv - 0.5) * vec2(asp, 1.0);
    vec2 sp = (uSunP - 0.5) * vec2(asp, 1.0);
    vec2 q = p - uPar * 0.45;   // nearer than the focus: moves the other way
    vec3 rgb = vec3(0.0); float A = 0.0;
    // a near clock, bottom right, far out of focus: a big soft glow
    { vec2 c = vec2(0.80, -0.44) + vec2(sin(t * 0.5), 0.0) * 0.006 * (1.0 + uGust * 3.0); float r = 0.2, b = 0.09;
      float l = length(q - c); float a = soft(l, r, b) * 0.32 * uClocks;
      float limb = exp(-pow((l - r * 0.85) / 0.08, 2.0));
      over(rgb, A, mix(uFil, uSunC, 0.5) * (0.75 + 0.5 * limb), a); }
    // the meadow's near edge: a dark, very soft mass of grass along the bottom
    { float edge = -0.36 + (vnoise(vec2(q.x * 5.0, 2.0)) - 0.5) * 0.12 + (vnoise(vec2(q.x * 13.0, 5.0)) - 0.5) * 0.05;
      float a = (1.0 - smoothstep(edge - 0.1, edge + 0.08, q.y)) * 0.72 * uFg;
      float lit = exp(-length(vec2(q.x, edge) - sp) * 1.4);
      over(rgb, A, mix(uShadeC * 0.8, uMeadow * 0.5, 0.3) + uSunC * 0.12 * lit * smoothstep(edge - 0.12, edge, q.y), a); }
    // soft foreground grass
    blade(rgb, A, q, sp, -0.74, 0.78, 0.22, 0.04, 0.045, 0.0);
    blade(rgb, A, q, sp, -0.58, 0.40, -0.25, 0.032, 0.035, 2.0);
    blade(rgb, A, q, sp, 0.70, 0.62, -0.38, 0.045, 0.055, 4.0);
    blade(rgb, A, q, sp, 0.16, 0.16, 0.12, 0.05, 0.06, 5.5);
    // out-of-focus seeds in front of the lens: big faint discs
    for (int i = 0; i < 4; i++) { float fi = float(i);
      vec2 c = vec2((h11(fi * 7.7) - 0.5) * asp, (h11(fi * 3.3) - 0.5) * 0.9) + vec2(sin(t * 0.07 + fi * 2.0) * 0.06, cos(t * 0.05 + fi) * 0.03);
      float r = 0.05 + 0.05 * h11(fi * 9.1); float l = length(q - c);
      float d = soft(l, r, r * 0.12) * (0.6 + 0.4 * smoothstep(r * 0.5, r, l));
      rgb += uGlint * d * 0.035 * uBokeh * (0.5 + exp(-length(c - sp) * 2.0)); }
    // the hero's limb glow: light scattered by the fringe of hairs, strongest on the sun side
    { vec2 d = p - uHero; float l = length(d);
      float ring = exp(-pow((l - uHeroR * 0.93) / (uHeroR * 0.13), 2.0));
      float fill = exp(-pow(l / uHeroR, 4.0));
      float side = 0.4 + 0.6 * max(0.0, dot(d / max(l, 1e-4), normalize(sp - uHero)));
      float prox = 0.6 + 0.8 * exp(-length(uHero - sp) * 1.5);
      rgb += uSunC * (ring * 0.09 + fill * 0.035) * side * prox * uHeroFill * uBack; }
    // the lens: a veil toward the sun and a few faint ghosts along the axis through the centre
    { float ds = length(p - sp);
      rgb += uSunC * (exp(-ds * 1.8) * 0.06 + exp(-ds * ds * 30.0) * 0.08) * uLens;
      for (int i = 0; i < 4; i++) { float fi = float(i);
        float k = -0.35 - fi * 0.42; vec2 gc = sp * k; float r = 0.025 + 0.03 * h11(fi * 5.5 + 2.0);
        vec3 tint = mix(vec3(1.0, 0.75, 0.4), vec3(0.55, 0.85, 0.8), h11(fi * 1.9));
        rgb += tint * soft(length(p - gc), r, r * 0.35) * 0.03 * uLens; } }
    rgb += (h12(gl_FragCoord.xy) - 0.5) / 255.0;
    gl_FragColor = vec4(max(rgb, 0.0), A);
  }`;
/** Composites the premultiplied foreground render target over the frame. */
const FOREGROUND_BLIT_VERT = FULLSCREEN_VERT;
const FOREGROUND_BLIT_FRAG = `uniform sampler2D tFg; varying vec2 vUv; void main(){ gl_FragColor = texture2D(tFg, vUv); }`;
```

### 2 · Tuning (bake these in)

```js
/**
 * The dandelion scene's tuning — ported from the MOTDES `dandelion.html`
 * scene (its CONFIG block, values unchanged). These feed shader uniforms, not
 * CSS: the palette is the scene's own light, so it lives here and not in the
 * design tokens. Colours are 0xRRGGBB.
 *
 * DANDELION: a macro of a dandelion clock in a meadow at golden hour, backlit
 * by a low sun. One hero clock is in focus (pitted receptacle, dark achenes,
 * long beaks, an umbrella of fine pappus hairs that glow by forward
 * scattering); other clocks, grass and seed heads sit in front of and behind
 * it as soft bokeh. The cursor is a BREATH — seeds near it lean, let go and
 * tumble off on the air, and the stem bends. A CLICK blows a gust that strips
 * the clock in a wave. Rest the cursor and the clock regrows.
 */
const DANDELION_CONFIG = {
    // palette
    filamentColor: 0xfff3df, // the pappus hairs
    beakColor: 0xefe4c6, // the long seed beak
    acheneColor: 0x77603c, // the seed body
    stemColor: 0x56742a,
    grassColor: 0x3e5a19,
    transColor: 0xd8e86a, // light through leaf tissue
    sunColor: 0xffd98f,
    skyColor: 0xf0c879, // the warm haze
    treeColor: 0x4a5428, // the far tree line (soft)
    meadowColor: 0x4f6a22,
    shadeColor: 0x17260f, // deep meadow shade
    glintColor: 0xffe2a6, // bokeh glints
    receptacleColor: 0xcdb98a, // the bare, pitted receptacle
    // the clock
    seeds: 210, // hero seed count
    filaments: 44, // pappus hairs per seed
    seedSize: 1.0,
    sway: 1.0, // the idle breeze
    shed: 0.15, // seeds that let go on their own, per second
    // the breath + the blow
    breathRadius: 0.9,
    breathPower: 1.0,
    blowStrength: 1.0,
    blowRadius: 4.0,
    wind: 0.32, // the drift (+x, toward the sun)
    lift: 0.05,
    turbulence: 1.0,
    tumble: 1.0, // how hard a released seed tumbles before it rights itself
    flightTime: 14,
    regrow: 6,
    airSeeds: 14, // seeds from other clocks drifting through, at every depth
    midges: 12, // a small swarm dancing in the backlight, right of the clock
    stemHairs: 420, // fine hairs on the stem and along the blade edges
    // the meadow
    blades: 7, // in-focus grass blades
    dew: 1.0,
    motes: 70, // out-of-focus fluff and pollen
    softClocks: 1.0, // the out-of-focus clocks behind and in front
    foreground: 1.0, // the soft foreground grass
    bokeh: 1.0,
    // light
    sunX: 0.62,
    sunY: 0.7,
    sunGlow: 1.0,
    backlight: 1.0,
    lensGlow: 1.0,
    haze: 1.0,
    // the lens: depth of field for everything that leaves the focal plane
    aperture: 0.12, // circle-of-confusion gain
    focusDepth: 1.1, // half-depth of the sharp zone round the clock
    hairWidth: 1.0, // pappus hair thickness (sub-pixel, anti-aliased)
    // camera
    camDist: 7.4,
    camHeight: 0.25,
    orbit: 0.06,
    parallax: 1.0,
    fov: 30,
    // grade
    warmth: 1.0,
    grain: 0.022,
    mainAlpha: 1.0,
    /** Device-pixel-ratio ceiling — the source scene's own cap. */
    maxDpr: 1.5,
    /** The intro: a fade up from black, after a short hold (seconds). */
    introDelay: 0.2,
    introDuration: 1.6,
};
/**
 * Where the clock sits on screen, per layout (`x` 0 left … 1 right, `y` 0 top …
 * 1 bottom), keyed by the box's CSS width — the same breakpoints as the hero
 * (`sm` 640, `lg` 1024). The source aimed a fixed offset from the clock, so it
 * wandered with the aspect ratio (cropped at a phone's left edge).
 * - desktop: the Figma hero (node 7438:6957) — 47% across; 55.6% down is the
 *   source's own height;
 * - phone: centred, in the open band between the header and the copy;
 * - tablet: up and right, so the heading under it reads on darker meadow.
 */
const DANDELION_FRAMING = [
    { maxWidth: 639, x: 0.5, y: 0.41 },
    // up and right of centre: the heading sits under the clock, left
    { maxWidth: 1023, x: 0.58, y: 0.29 },
    { maxWidth: Infinity, x: 0.471, y: 0.556 },
];
```

### 3 · The simulation and renderer

The small helpers it relies on come first; then the scene, verbatim.

```js
import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";

const S = { R0, A0, AP, HL, SEED_MID, FINAL_VERT, FINAL_FRAG, BACKDROP_VERT, BACKDROP_FRAG, BACKDROP_BLIT_VERT,
  BACKDROP_BLIT_FRAG, PLANT_VERT, PLANT_FRAG, HAIR_VERT, HAIR_FRAG, SEED_VERT, SEED_FRAG, ACHENE_VERT, ACHENE_FRAG,
  SEED_BLUR_VERT, SEED_BLUR_FRAG, DEW_VERT, DEW_FRAG, MOTE_VERT, MOTE_FRAG, MIDGE_VERT, MIDGE_FRAG, FOREGROUND_VERT,
  FOREGROUND_FRAG, FOREGROUND_BLIT_VERT, FOREGROUND_BLIT_FRAG };
const CONFIG = DANDELION_CONFIG;

const DANDELION_WISH_EVENT = "dandelion:wish";        // the Make a Wish button: window.dispatchEvent(new Event(DANDELION_WISH_EVENT))
let sceneReady = false; const readyListeners = new Set();
const markSceneReady = () => { if (sceneReady) return; sceneReady = true; readyListeners.forEach((l) => l()); readyListeners.clear(); };
const onSceneReady = (l) => { if (sceneReady) l(); else readyListeners.add(l); };   // the preloader subscribes

const createFrameClock = () => { let first = -1, last = -1; return {
  tick(now) { if (first < 0) first = now; const dt = last < 0 ? 0 : Math.min(0.1, (now - last) / 1000); last = now;
    return { dt: Math.max(0, dt), t: (now - first) / 1000 }; },
  reset() { last = -1; } }; };
const createDrawGate = (enabled, intervalMs = 12.5) => { let since = Infinity; return {
  shouldDraw(dt) { if (!enabled) return true; since += dt * 1000; if (since < intervalMs) return false; since = 0; return true; },
  force() { since = Infinity; } }; };
const watchContext = (canvas, onChange) => {
  const lost = (e) => { e.preventDefault(); onChange("lost"); }, restored = () => onChange("restored");
  canvas.addEventListener("webglcontextlost", lost); canvas.addEventListener("webglcontextrestored", restored);
  return () => { canvas.removeEventListener("webglcontextlost", lost); canvas.removeEventListener("webglcontextrestored", restored); }; };

const UP = new THREE.Vector3(0, 1, 0);
const color = (hex) => new THREE.Vector3(((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255);
const quadIndex = (n) => {
    const idx = [];
    for (let i = 0; i < n; i++) {
        const b = i * 4;
        idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
    }
    return idx;
};
/** A seeded PRNG — the scene's layout is the same on every load. */
const mulberry = (seed) => {
    let a = seed;
    return () => {
        a |= 0;
        a = (a + 0x6d2b79f5) | 0;
        let t = Math.imul(a ^ (a >>> 15), 1 | a);
        t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
};
const smooth01 = (a, b, x) => {
    const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
    return t * t * (3 - 2 * t);
};
class Dandelion {
    scene;
    camera;
    renderer;
    finalU;
    /** CSS size of the box. */
    width = 1;
    height = 1;
    time = 0;
    skipIntro;
    pSm = new THREE.Vector2();
    pPrev = new THREE.Vector2();
    speed = 0;
    breathPt = new THREE.Vector3(1e4, 0, 0);
    breathPrev = new THREE.Vector3(1e4, 0, 0);
    breathVel = new THREE.Vector3();
    breath = 0;
    head0 = new THREE.Vector3(0, 0, 0);
    head = this.head0.clone();
    bend = new THREE.Vector3();
    bendV = new THREE.Vector3();
    headQ = new THREE.Quaternion();
    sunDir = new THREE.Vector3(0.3, 0.3, -1).normalize();
    gustT = -1e9;
    gustDir = new THREE.Vector3(1, 0, 0);
    gustStr = 0;
    gustEnv = 0;
    still = 0;
    drift = 0;
    _v = new THREE.Vector3();
    _v2 = new THREE.Vector3();
    _v3 = new THREE.Vector3();
    _v4 = new THREE.Vector3();
    _q = new THREE.Quaternion();
    common = {
        uSun: { value: color(CONFIG.sunColor) },
        uSunDir: { value: this.sunDir },
        uSky: { value: color(CONFIG.skyColor) },
        uShade: { value: color(CONFIG.shadeColor) },
        uBack: { value: CONFIG.backlight },
        uTime: { value: 0 },
    };
    lens = {
        uVp: { value: new THREE.Vector2(1, 1) },
        uFocus: { value: CONFIG.camDist },
        uAper: { value: CONFIG.aperture },
        uFDepth: { value: CONFIG.focusDepth },
    };
    // backdrop + foreground (third resolution)
    bgU = {
        uRes: { value: new THREE.Vector2(1, 1) },
        uTime: { value: 0 },
        uSunP: { value: new THREE.Vector2(CONFIG.sunX, CONFIG.sunY) },
        uPar: { value: new THREE.Vector2() },
        uSky: { value: color(CONFIG.skyColor) },
        uTree: { value: color(CONFIG.treeColor) },
        uMeadow: { value: color(CONFIG.meadowColor) },
        uShadeC: { value: color(CONFIG.shadeColor) },
        uSunC: { value: color(CONFIG.sunColor) },
        uGlint: { value: color(CONFIG.glintColor) },
        uFil: { value: color(CONFIG.filamentColor) },
        uSunGlow: { value: CONFIG.sunGlow },
        uBokeh: { value: CONFIG.bokeh },
        uClocks: { value: CONFIG.softClocks },
        uHaze: { value: CONFIG.haze },
        uGust: { value: 0 },
        uSway: { value: CONFIG.sway },
    };
    bgRT;
    bgScene = new THREE.Scene();
    bgCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
    fgU = {
        uRes: this.bgU.uRes, uTime: this.bgU.uTime, uSunP: this.bgU.uSunP, uPar: this.bgU.uPar, uGust: this.bgU.uGust, uSway: this.bgU.uSway,
        uShadeC: this.bgU.uShadeC, uMeadow: this.bgU.uMeadow, uSunC: this.bgU.uSunC, uGlint: this.bgU.uGlint, uFil: this.bgU.uFil,
        uHero: { value: new THREE.Vector2() },
        uHeroR: { value: 0.3 },
        uHeroFill: { value: 1 },
        uFg: { value: CONFIG.foreground },
        uLens: { value: CONFIG.lensGlow },
        uClocks: this.bgU.uClocks, uBokeh: this.bgU.uBokeh, uBack: this.common.uBack,
    };
    fgRT;
    fgScene = new THREE.Scene();
    // the plant
    NT = 48;
    NR = 18;
    stemPts = [];
    stemGeo = new THREE.BufferGeometry();
    stemPos;
    stemNor;
    stemSide;
    ringC = [];
    ringT = [];
    ringS = [];
    ringF = [];
    headGroup = new THREE.Group();
    // blades + dew
    NS = 30;
    bladeDefs = [];
    bladeGeo = new THREE.BufferGeometry();
    bladePos = new Float32Array(0);
    bladeNor = new Float32Array(0);
    bladeSide = new Float32Array(0);
    drops = [];
    dewPos = new Float32Array(0);
    dewMesh = null;
    dewU = null;
    // stem hairs
    hairs = [];
    hP0 = null;
    hP1 = null;
    hP0a = new Float32Array(0);
    hP1a = new Float32Array(0);
    // seeds
    seeds = [];
    NH = 0;
    shedAcc = 0;
    aPos = null;
    aQuat = null;
    aInfo = null;
    blurPos = null;
    blurInfo = null;
    blurPosA = new Float32Array(0);
    blurInfoA = new Float32Array(0);
    // motes + midges
    moteU = null;
    midgeU = null;
    midgePush = new THREE.Vector3();
    midgePushV = new THREE.Vector3();
    constructor(scene, camera, renderer, finalU, skipIntro) {
        this.scene = scene;
        this.camera = camera;
        this.renderer = renderer;
        this.finalU = finalU;
        this.skipIntro = skipIntro;
        const rtOpts = { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthBuffer: false };
        this.bgRT = new THREE.WebGLRenderTarget(2, 2, rtOpts);
        this.fgRT = new THREE.WebGLRenderTarget(2, 2, rtOpts);
        const vtx = (this.NT + 1) * (this.NR + 1) * 3;
        this.stemPos = new Float32Array(vtx);
        this.stemNor = new Float32Array(vtx);
        this.stemSide = new Float32Array(vtx);
        this.buildBackdrop();
        this.buildPlant();
        this.buildSeeds();
        this.buildBlades();
        this.buildStemHairs();
        this.buildMotes();
        this.buildMidges();
        this.buildForeground();
    }
    /* ---------- the out-of-focus meadow (third resolution) ---------- */
    buildBackdrop() {
        const q = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: this.bgU, depthWrite: false, depthTest: false, vertexShader: S.BACKDROP_VERT, fragmentShader: S.BACKDROP_FRAG }));
        q.frustumCulled = false;
        this.bgScene.add(q);
        const bg = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
            uniforms: { tBg: { value: this.bgRT.texture } }, depthWrite: false, depthTest: false,
            vertexShader: S.BACKDROP_BLIT_VERT, fragmentShader: S.BACKDROP_BLIT_FRAG,
        }));
        bg.frustumCulled = false;
        bg.renderOrder = -10;
        this.scene.add(bg);
    }
    /* ---------- the plant: stem, receptacle, bracts ---------- */
    plantMat(hex, opts) {
        const uniforms = Object.assign({}, this.common, {
            uBase: { value: color(hex) },
            uTrans: { value: color(CONFIG.transColor) },
            uRim: { value: opts.rim },
            uTransAmt: { value: opts.trans },
            uEdge: { value: opts.edge ?? 0 },
            uPit: { value: opts.pit ?? 0 },
            uKind: { value: opts.kind ?? 0 },
        });
        return new THREE.ShaderMaterial({
            uniforms, side: THREE.DoubleSide, transparent: Boolean(opts.kind), extensions: { derivatives: true, fragDepth: false, drawBuffers: false, shaderTextureLOD: false },
            vertexShader: S.PLANT_VERT, fragmentShader: S.PLANT_FRAG,
        });
    }
    buildPlant() {
        const { NT, NR } = this;
        const stemMat = this.plantMat(CONFIG.stemColor, { rim: 1.0, trans: 0.3, kind: 1 });
        // the stem: a hand-made tube so it can bend every frame; base well below the frame
        const curve = new THREE.CatmullRomCurve3([
            new THREE.Vector3(0.75, -7.5, -0.9), new THREE.Vector3(0.35, -4.5, -0.45), new THREE.Vector3(0.06, -1.8, -0.08), new THREE.Vector3(0, -0.1, 0),
        ]);
        for (let i = 0; i <= NT; i++)
            this.stemPts.push(curve.getPoint(i / NT));
        const uv = new Float32Array((NT + 1) * (NR + 1) * 2);
        const idx = [];
        for (let i = 0; i <= NT; i++) {
            for (let j = 0; j <= NR; j++) {
                const k = i * (NR + 1) + j;
                uv[k * 2] = i / NT;
                uv[k * 2 + 1] = j / NR;
                if (i < NT && j < NR) {
                    const a = k, b = k + NR + 1;
                    idx.push(a, b, a + 1, b, b + 1, a + 1);
                }
            }
        }
        const g = this.stemGeo;
        g.setAttribute("position", new THREE.BufferAttribute(this.stemPos, 3));
        g.setAttribute("normal", new THREE.BufferAttribute(this.stemNor, 3));
        g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
        g.setAttribute("aSide", new THREE.BufferAttribute(this.stemSide, 3));
        g.setIndex(idx);
        const stem = new THREE.Mesh(g, stemMat);
        stem.frustumCulled = false;
        stem.renderOrder = 1;
        this.scene.add(stem);
        for (let i = 0; i <= NT; i++) {
            this.ringC.push(new THREE.Vector3());
            this.ringT.push(new THREE.Vector3());
            this.ringS.push(new THREE.Vector3());
            this.ringF.push(new THREE.Vector3());
        }
        // the head: receptacle + reflexed bracts, carried by the stem's tip
        this.scene.add(this.headGroup);
        const rec = new THREE.Mesh(new THREE.SphereGeometry(S.R0, 28, 18), this.plantMat(CONFIG.receptacleColor, { rim: 0.5, trans: 0.2, pit: 1 }));
        rec.scale.set(1, 0.82, 1);
        this.headGroup.add(rec);
        const rnd = mulberry(7);
        const bp = [], bn = [], bu = [], bi = [];
        const NB = 16, NSB = 8;
        for (let j = 0; j < NB; j++) {
            const ph = (j / NB) * Math.PI * 2 + rnd() * 0.2, len = 0.3 + rnd() * 0.18, w0 = 0.022 + rnd() * 0.01, out = 0.05 + rnd() * 0.05;
            const rad = new THREE.Vector3(Math.cos(ph), 0, Math.sin(ph)), tan = new THREE.Vector3(-Math.sin(ph), 0, Math.cos(ph));
            const base0 = bp.length / 3;
            for (let k = 0; k <= NSB; k++) {
                const s = k / NSB;
                const c = new THREE.Vector3().addScaledVector(rad, 0.085 + out * Math.sin(s * 2.2)).add(new THREE.Vector3(0, -0.06 - len * s, 0));
                const w = w0 * (1 - s * 0.85);
                for (const sg of [-1, 1]) {
                    const p = c.clone().addScaledVector(tan, w * sg);
                    bp.push(p.x, p.y, p.z);
                    bn.push(rad.x, 0.3, rad.z);
                    bu.push(s, sg < 0 ? 0 : 1);
                }
                if (k < NSB) {
                    const a = base0 + k * 2;
                    bi.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
                }
            }
        }
        const bg = new THREE.BufferGeometry();
        bg.setAttribute("position", new THREE.Float32BufferAttribute(bp, 3));
        bg.setAttribute("normal", new THREE.Float32BufferAttribute(bn, 3));
        bg.setAttribute("uv", new THREE.Float32BufferAttribute(bu, 2));
        bg.setIndex(bi);
        this.headGroup.add(new THREE.Mesh(bg, this.plantMat(CONFIG.stemColor, { rim: 0.6, trans: 0.25, edge: 0.3 })));
        this.updateStem();
    }
    /* ---------- fine hairs on the stem: sparse, appressed, densest under the head; lit only when the sun is behind ---------- */
    buildStemHairs() {
        const M = Math.max(0, Math.round(CONFIG.stemHairs)), rnd = mulberry(41);
        this.hairs = [];
        for (let i = 0; i < M; i++) {
            this.hairs.push({ host: -1, u: 1 - 0.36 * Math.pow(rnd(), 1.5), a: rnd() * Math.PI * 2, edge: 0, tilt: 0.35 + rnd() * 0.75, len: 0.018 + rnd() * rnd() * 0.06, kink: (rnd() - 0.5) * 0.6, lift: 0 });
        }
        // the grass blades carry fine marginal hairs (cilia) along their edges, leaning toward the tip
        const nb = Math.round((M / 420) * 34);
        if (M > 0) {
            this.bladeDefs.forEach((_d, b) => {
                for (let i = 0; i < nb; i++) {
                    this.hairs.push({ host: b, u: 0.12 + rnd() * 0.78, a: 0, edge: rnd() < 0.5 ? -1 : 1, tilt: 0.5 + rnd() * 0.6, len: 0.012 + rnd() * 0.03, kink: (rnd() - 0.5) * 0.5, lift: (rnd() - 0.5) * 0.8 });
                }
            });
        }
        const n = Math.max(1, this.hairs.length) * 2; // two segments per hair (a slight kink)
        const corner = [], ids = [];
        for (let i = 0; i < n; i++) {
            for (const end of [0, 1]) {
                for (const sd of [-1, 1]) {
                    corner.push(end, sd, i % 2);
                    ids.push(Math.floor(i / 2));
                }
            }
        }
        const g = new THREE.BufferGeometry();
        this.hP0a = new Float32Array(n * 4 * 3);
        this.hP1a = new Float32Array(n * 4 * 3);
        this.hP0 = new THREE.BufferAttribute(this.hP0a, 3).setUsage(THREE.DynamicDrawUsage);
        this.hP1 = new THREE.BufferAttribute(this.hP1a, 3).setUsage(THREE.DynamicDrawUsage);
        g.setAttribute("position", this.hP0);
        g.setAttribute("aP1", this.hP1);
        g.setAttribute("aC", new THREE.Float32BufferAttribute(corner, 3));
        g.setAttribute("aId", new THREE.Float32BufferAttribute(ids, 1));
        g.setIndex(quadIndex(n));
        const uniforms = Object.assign({}, this.common, this.lens, { uFil: { value: color(CONFIG.filamentColor) } });
        const m = new THREE.ShaderMaterial({ uniforms, transparent: true, depthWrite: false, side: THREE.DoubleSide, vertexShader: S.HAIR_VERT, fragmentShader: S.HAIR_FRAG });
        const mesh = new THREE.Mesh(g, m);
        mesh.frustumCulled = false;
        mesh.renderOrder = 2.5;
        mesh.visible = M > 0;
        this.scene.add(mesh);
    }
    updateStemHairs(t) {
        if (!this.hairs.length || !this.hP0 || !this.hP1)
            return;
        const P0 = this.hP0a, P1 = this.hP1a, NT = this.NT;
        const c = this._v4, rad = new THREE.Vector3(), d = new THREE.Vector3(), m = new THREE.Vector3(), e = new THREE.Vector3();
        const tn = new THREE.Vector3(), sd = new THREE.Vector3(), nn = new THREE.Vector3();
        this.hairs.forEach((h, i) => {
            if (h.host >= 0) {
                // a marginal hair on a blade
                const bd = this.bladeDefs[h.host];
                this.bladePoint(bd, h.u, t, c, tn, sd);
                nn.crossVectors(sd, tn).normalize();
                const w = bd.w * 1.7 * Math.pow(1 - h.u, 0.75) + 0.003;
                c.addScaledVector(sd, w * h.edge * 0.98);
                d.copy(tn).multiplyScalar(Math.cos(h.tilt)).addScaledVector(sd, h.edge * Math.sin(h.tilt)).addScaledVector(nn, h.lift * 0.4).normalize();
                m.copy(c).addScaledVector(d, h.len * 0.5);
                d.lerp(tn, 0.35 + h.kink * 0.3).normalize();
                e.copy(m).addScaledVector(d, h.len * 0.5);
            }
            else {
                const f = h.u * NT, i0 = Math.min(NT - 1, Math.floor(f)), k = f - i0;
                const T = this.ringT[i0], Sd = this.ringS[i0], F = this.ringF[i0];
                const r = 0.07 - 0.012 * h.u;
                rad.copy(Sd).multiplyScalar(Math.cos(h.a)).addScaledVector(F, Math.sin(h.a));
                c.copy(this.ringC[i0]).lerp(this.ringC[i0 + 1], k).addScaledVector(rad, r * 0.97);
                d.copy(T).multiplyScalar(Math.cos(h.tilt)).addScaledVector(rad, Math.sin(h.tilt));
                m.copy(c).addScaledVector(d, h.len * 0.5);
                d.copy(T).multiplyScalar(Math.cos(h.tilt * (0.6 + h.kink))).addScaledVector(rad, Math.sin(h.tilt * (0.6 + h.kink)));
                e.copy(m).addScaledVector(d, h.len * 0.5);
            }
            for (let s = 0; s < 2; s++) {
                const a = s ? m : c, b = s ? e : m, base = (i * 2 + s) * 4 * 3;
                for (let v = 0; v < 4; v++) {
                    const o = base + v * 3;
                    P0[o] = a.x;
                    P0[o + 1] = a.y;
                    P0[o + 2] = a.z;
                    P1[o] = b.x;
                    P1[o + 1] = b.y;
                    P1[o + 2] = b.z;
                }
            }
        });
        this.hP0.needsUpdate = true;
        this.hP1.needsUpdate = true;
    }
    // the stem bends as a cantilever: the head carries the full offset, the base none
    updateStem() {
        const { NT, NR } = this, P = this.stemPos, N = this.stemNor, T = this.stemSide;
        const tn = this._v2, side = this._v3, fw = new THREE.Vector3();
        const ctr = [];
        for (let i = 0; i <= NT; i++) {
            const u = i / NT, w = (u * u * (3 - u)) / 2;
            ctr.push(this.stemPts[i].clone().addScaledVector(this.bend, w));
        }
        for (let i = 0; i <= NT; i++) {
            tn.subVectors(ctr[Math.min(NT, i + 1)], ctr[Math.max(0, i - 1)]).normalize();
            side.set(0, 0, 1).cross(tn).normalize();
            fw.crossVectors(side, tn);
            const r = 0.07 - 0.012 * (i / NT);
            this.ringC[i].copy(ctr[i]);
            this.ringT[i].copy(tn);
            this.ringS[i].copy(side);
            this.ringF[i].copy(fw);
            for (let j = 0; j <= NR; j++) {
                const a = (j / NR) * Math.PI * 2, k = (i * (NR + 1) + j) * 3;
                const ca = Math.cos(a), sa = Math.sin(a);
                const nx = side.x * ca + fw.x * sa, ny = side.y * ca + fw.y * sa, nz = side.z * ca + fw.z * sa;
                P[k] = ctr[i].x + nx * r;
                P[k + 1] = ctr[i].y + ny * r;
                P[k + 2] = ctr[i].z + nz * r;
                N[k] = nx;
                N[k + 1] = ny;
                N[k + 2] = nz;
                T[k] = tn.x;
                T[k + 1] = tn.y;
                T[k + 2] = tn.z;
            }
        }
        this.stemGeo.attributes.position.needsUpdate = true;
        this.stemGeo.attributes.normal.needsUpdate = true;
        this.stemGeo.attributes.aSide.needsUpdate = true;
        // the head rides the tip, tilted with the stem's last tangent
        tn.subVectors(ctr[NT], ctr[NT - 2]).normalize();
        this.headQ.setFromUnitVectors(UP, tn);
        this.head.copy(ctr[NT]).addScaledVector(tn, 0.1);
        this.headGroup.position.copy(this.head);
        this.headGroup.quaternion.copy(this.headQ);
    }
    /* ---------- the seeds: instanced achenes + one batch of beak and pappus fibres ---------- */
    buildSeeds() {
        const NH = Math.max(20, Math.round(CONFIG.seeds)), NA = Math.max(0, Math.round(CONFIG.airSeeds)), N = NH + NA, F = Math.max(8, Math.round(CONFIG.filaments));
        const rnd = mulberry(11);
        // one quad per fibre segment: hA = (azimuth, elevation, t0, t1), hB = (length factor, curl, part, end), hS = side
        const pos = [], hA = [], hB = [], hS = [];
        const seg = (a, e, t0, t1, part, lf, curl) => {
            for (const end of [0, 1]) {
                for (const sd of [-1, 1]) {
                    pos.push(0, 0, 0);
                    hA.push(a, e, t0, t1);
                    hB.push(lf, curl, part, end);
                    hS.push(sd);
                }
            }
        };
        seg(0, 0, 0, 1, 0, 1, 0);
        for (let i = 0; i < F; i++) {
            const a = (i / F) * Math.PI * 2 + (rnd() - 0.5) * 0.2, e = 1.05 + rnd() * 0.32, lf = 0.82 + rnd() * 0.3, curl = 0.12 + rnd() * 0.25;
            seg(a, e, 0, 0.5, 2, lf, curl);
            seg(a, e, 0.5, 1, 2, lf, curl);
        }
        const aPos = new THREE.InstancedBufferAttribute(new Float32Array(N * 3), 3).setUsage(THREE.DynamicDrawUsage);
        const aQuat = new THREE.InstancedBufferAttribute(new Float32Array(N * 4), 4).setUsage(THREE.DynamicDrawUsage);
        const aInfo = new THREE.InstancedBufferAttribute(new Float32Array(N * 4), 4).setUsage(THREE.DynamicDrawUsage);
        this.aPos = aPos;
        this.aQuat = aQuat;
        this.aInfo = aInfo;
        const geo = new THREE.InstancedBufferGeometry();
        geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
        geo.setAttribute("hA", new THREE.Float32BufferAttribute(hA, 4));
        geo.setAttribute("hB", new THREE.Float32BufferAttribute(hB, 4));
        geo.setAttribute("hS", new THREE.Float32BufferAttribute(hS, 1));
        geo.setIndex(quadIndex(pos.length / 12));
        geo.setAttribute("aPos", aPos);
        geo.setAttribute("aQuat", aQuat);
        geo.setAttribute("aInfo", aInfo);
        geo.instanceCount = N;
        const sU = Object.assign({}, this.common, this.lens, {
            uSize: { value: CONFIG.seedSize },
            uFil: { value: color(CONFIG.filamentColor) },
            uBeak: { value: color(CONFIG.beakColor) },
            uAch: { value: color(CONFIG.acheneColor) },
            uHairW: { value: CONFIG.hairWidth },
        });
        const mat = new THREE.ShaderMaterial({ uniforms: sU, transparent: true, depthWrite: false, side: THREE.DoubleSide, vertexShader: S.SEED_VERT, fragmentShader: S.SEED_FRAG });
        const seedMesh = new THREE.Mesh(geo, mat);
        seedMesh.frustumCulled = false;
        seedMesh.renderOrder = 3;
        this.scene.add(seedMesh);
        // the achenes: a ribbed spindle, shared instance attributes
        const lathe = new THREE.LatheGeometry([[0, 0], [0.008, 0.015], [0.015, 0.06], [0.017, 0.12], [0.013, 0.175], [0.005, 0.205], [0.002, S.A0]].map(([x, y]) => new THREE.Vector2(x, y)), 8);
        const ag = new THREE.InstancedBufferGeometry();
        ag.index = lathe.index;
        ag.setAttribute("position", lathe.attributes.position);
        ag.setAttribute("normal", lathe.attributes.normal);
        ag.setAttribute("aPos", aPos);
        ag.setAttribute("aQuat", aQuat);
        ag.setAttribute("aInfo", aInfo);
        ag.instanceCount = N;
        const am = new THREE.ShaderMaterial({ uniforms: sU, transparent: true, vertexShader: S.ACHENE_VERT, fragmentShader: S.ACHENE_FRAG });
        const acheneMesh = new THREE.Mesh(ag, am);
        acheneMesh.frustumCulled = false;
        acheneMesh.renderOrder = 2;
        this.scene.add(acheneMesh);
        // the seed's soft disc: what a pappus becomes off the focal plane — a round backlit blur sized by its circle of confusion
        this.blurPosA = new Float32Array(N * 3);
        this.blurInfoA = new Float32Array(N * 2);
        this.blurPos = new THREE.BufferAttribute(this.blurPosA, 3).setUsage(THREE.DynamicDrawUsage);
        this.blurInfo = new THREE.BufferAttribute(this.blurInfoA, 2).setUsage(THREE.DynamicDrawUsage);
        const bgeo = new THREE.BufferGeometry();
        bgeo.setAttribute("position", this.blurPos);
        bgeo.setAttribute("aB", this.blurInfo);
        const bmat = new THREE.ShaderMaterial({ uniforms: sU, transparent: true, depthWrite: false, vertexShader: S.SEED_BLUR_VERT, fragmentShader: S.SEED_BLUR_FRAG });
        const blurMesh = new THREE.Points(bgeo, bmat);
        blurMesh.frustumCulled = false;
        blurMesh.renderOrder = 3.5;
        this.scene.add(blurMesh);
        // state
        this.seeds = [];
        const golden = Math.PI * (3 - Math.sqrt(5));
        for (let i = 0; i < N; i++) {
            const s = {
                hero: i < NH, state: 0, grow: 1, open: 1, rand: rnd(), phase: rnd() * 6.28, pos: new THREE.Vector3(), vel: new THREE.Vector3(), quat: new THREE.Quaternion(),
                ang: new THREE.Vector3(), spin: 0, zHome: 0, updraft: 0, thresh: 0.3 + rnd() * 0.5, scale: 0.88 + rnd() * 0.24, pending: -1, age: 0, wdir: new THREE.Vector3(0, 1, 0), dir: UP.clone(),
            };
            if (s.hero) {
                const y = 1 - (i / (NH - 1)) * 1.8, r = Math.sqrt(Math.max(0, 1 - y * y)), th = golden * i;
                s.dir = new THREE.Vector3(Math.cos(th) * r + (rnd() - 0.5) * 0.08, y, Math.sin(th) * r + (rnd() - 0.5) * 0.08).normalize();
                if (rnd() < 0.05) {
                    s.state = 2;
                    s.grow = rnd();
                }
            }
            else
                this.spawnAir(s, true);
            this.seeds.push(s);
        }
        this.NH = NH;
        this.shedAcc = 0;
    }
    /** The clock's place on screen for this box width (`DANDELION_FRAMING`). */
    framing() {
        return DANDELION_FRAMING.find((f) => this.width <= f.maxWidth) ?? DANDELION_FRAMING[DANDELION_FRAMING.length - 1];
    }
    /** How far right of the clock the camera aims, per unit of depth — puts the clock at `framing().x` at any aspect. */
    aimK() {
        return -(2 * this.framing().x - 1) * Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2) * (this.width / this.height);
    }
    /** How far above the clock the camera aims, per unit of depth — puts it at `framing().y`. */
    aimUpK() {
        return (2 * this.framing().y - 1) * Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2);
    }
    // the visible half-width / half-height of the frame at world depth z (the camera sits near z = camDist, aimed right of the clock)
    viewBox(z) {
        const D = Math.max(0.6, CONFIG.camDist - z), th = Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2);
        return { cx: this.aimK() * D, hw: th * D * (this.width / this.height), hh: th * D };
    }
    // a seed from another clock: at any depth — some far behind, some right in front of the lens — so the lens has
    // something to blur; most come in on the wind from the left, some rise on a warm updraft from the meadow on the right
    spawnAir(s, anywhere) {
        s.state = 1;
        s.age = 0;
        s.open = 1;
        s.grow = 1;
        const r = Math.random();
        s.zHome = r < 0.3 ? -0.8 + Math.random() * 1.4 : r < 0.68 ? -4.2 + Math.random() * 3.0 : 1.2 + Math.random() * 2.6;
        const B = this.viewBox(s.zHome);
        s.updraft = 0;
        if (anywhere)
            s.pos.set(B.cx + (Math.random() * 2 - 1) * B.hw * 0.95, (Math.random() * 1.6 - 0.8) * B.hh, s.zHome);
        else if (Math.random() < 0.35) {
            s.pos.set(B.cx + (Math.random() * 1.1 - 0.05) * B.hw, -B.hh - 0.4 - Math.random() * 0.4, s.zHome);
            s.updraft = 0.32 + Math.random() * 0.2;
        }
        else
            s.pos.set(B.cx - B.hw - 0.5 - Math.random() * 1.2, (Math.random() * 1.5 - 0.65) * B.hh, s.zHome);
        s.vel.set(CONFIG.wind, s.updraft, 0);
        this._v.set((Math.random() - 0.5) * 0.5, 1, (Math.random() - 0.5) * 0.5).normalize();
        s.quat.setFromUnitVectors(UP, this._v);
        s.ang.set(0, 0, 0);
        s.spin = (Math.random() - 0.5) * 0.8;
    }
    /* ---------- in-focus grass blades + dew ---------- */
    buildBlades() {
        const all = [
            [-2.3, -0.25, 0.35, 0.18, 0.3, 0.05, 0.075], [-1.45, 0.35, -0.95, -0.3, 0.4, -0.5, 0.06], [1.35, -0.6, 1.05, -0.35, 0.45, 0.9, 0.075],
            [2.25, 0.25, -0.35, 0.2, 0.5, -0.3, 0.065], [3.4, -0.35, 0.95, -0.6, 0.9, 0.12, 0.075], [-0.75, -0.75, -0.55, -0.2, 0.3, 1.3, 0.055], [0.65, 0.5, -1.15, 0.25, 0.3, 0.6, 0.055],
            [-3.1, 0.1, 1.3, 0.5, 0.8, -0.8, 0.08], [2.9, 0.45, -1.2, 0.1, 0.2, 0.2, 0.06],
        ];
        const NB = Math.max(0, Math.min(all.length, Math.round(CONFIG.blades))), NS = this.NS;
        this.bladeDefs = all.slice(0, NB).map((d) => ({ x: d[0], z: d[1], tip: d[2], lean: d[3], arc: d[4], tw: d[5], w: d[6] * 1.4, ph: d[0] * 1.7 }));
        const nv = NB * (NS + 1) * 2;
        const g = this.bladeGeo, idx = [], uv = new Float32Array(nv * 2);
        for (let b = 0; b < NB; b++) {
            for (let k = 0; k <= NS; k++) {
                const v0 = (b * (NS + 1) + k) * 2;
                uv[v0 * 2] = k / NS;
                uv[v0 * 2 + 1] = 0;
                uv[v0 * 2 + 2] = k / NS;
                uv[v0 * 2 + 3] = 1;
                if (k < NS)
                    idx.push(v0, v0 + 2, v0 + 1, v0 + 1, v0 + 2, v0 + 3);
            }
        }
        this.bladePos = new Float32Array(nv * 3);
        this.bladeNor = new Float32Array(nv * 3);
        this.bladeSide = new Float32Array(nv * 3);
        g.setAttribute("position", new THREE.BufferAttribute(this.bladePos, 3));
        g.setAttribute("normal", new THREE.BufferAttribute(this.bladeNor, 3));
        g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
        g.setAttribute("aSide", new THREE.BufferAttribute(this.bladeSide, 3));
        g.setIndex(idx);
        const ids = new Float32Array(nv);
        for (let b = 0; b < NB; b++)
            ids.fill(b + 1, b * (NS + 1) * 2, (b + 1) * (NS + 1) * 2);
        g.setAttribute("aId", new THREE.BufferAttribute(ids, 1));
        const m = new THREE.Mesh(g, this.plantMat(CONFIG.grassColor, { rim: 0.7, trans: 0.5, edge: 1.0, kind: 2 }));
        m.frustumCulled = false;
        m.renderOrder = 1;
        this.scene.add(m);
        // dew: drops sitting on the blades and the stem
        const rnd = mulberry(23), drops = [];
        for (let b = 0; b < NB; b++) {
            const n = 1 + Math.floor(rnd() * 2.6);
            for (let i = 0; i < n; i++)
                drops.push({ b, s: 0.3 + rnd() * 0.55, o: (rnd() - 0.5) * 0.9, r: 0.022 + rnd() * 0.03 });
        }
        for (let i = 0; i < 3; i++)
            drops.push({ b: -1, s: 0.55 + i * 0.1 + rnd() * 0.05, o: rnd() - 0.5, r: 0.018 + rnd() * 0.016 });
        this.drops = drops;
        this.dewPos = new Float32Array(drops.length * 3);
        const dg = new THREE.BufferGeometry();
        dg.setAttribute("position", new THREE.BufferAttribute(this.dewPos, 3));
        dg.setAttribute("aR", new THREE.Float32BufferAttribute(drops.map((d) => d.r), 1));
        const dewU = Object.assign({}, this.common, {
            uPx: { value: 800 }, uDew: { value: CONFIG.dew }, uSunNdc: { value: new THREE.Vector2() }, uMeadowC: { value: color(CONFIG.meadowColor) },
        });
        this.dewU = dewU;
        const dm = new THREE.ShaderMaterial({ uniforms: dewU, transparent: true, depthWrite: false, vertexShader: S.DEW_VERT, fragmentShader: S.DEW_FRAG });
        this.dewMesh = new THREE.Points(dg, dm);
        this.dewMesh.frustumCulled = false;
        this.dewMesh.renderOrder = 4;
        this.scene.add(this.dewMesh);
    }
    bladePoint(d, s, t, out, tan, side) {
        const H = d.tip + 4.4, sw = CONFIG.sway;
        const bx = (Math.sin(t * 0.8 + d.ph) * 0.06 + Math.sin(t * 1.9 + d.ph * 2.0) * 0.02) * sw * H * 0.3 + this.gustDir.x * this.gustEnv * this.gustStr * 0.25 + this.bend.x * 0.25;
        const bz = Math.cos(t * 0.7 + d.ph) * 0.03 * sw * H * 0.3;
        const f = (u) => out.set(d.x + d.lean * H * u + d.arc * u * u * Math.sign(d.lean || 1) * 0.6 + bx * u * u, -4.4 + H * (u - d.arc * 0.12 * u * u * u), d.z + bz * u * u);
        if (tan) {
            f(Math.min(1, s + 0.01));
            tan.copy(out);
            f(Math.max(0, s - 0.01));
            tan.sub(out).normalize();
        }
        f(s);
        if (side)
            side.set(Math.cos(d.tw + s * 0.6), 0, Math.sin(d.tw + s * 0.6));
        return out;
    }
    updateBlades(t) {
        const P = this.bladePos, N = this.bladeNor, A = this.bladeSide, NS = this.NS;
        const c = new THREE.Vector3(), tn = new THREE.Vector3(), sd = new THREE.Vector3(), n = new THREE.Vector3();
        this.bladeDefs.forEach((d, b) => {
            for (let k = 0; k <= NS; k++) {
                const s = k / NS;
                this.bladePoint(d, s, t, c, tn, sd);
                n.crossVectors(sd, tn).normalize();
                const w = d.w * 1.7 * Math.pow(1 - s, 0.75) + 0.003;
                const v0 = (b * (NS + 1) + k) * 2 * 3;
                P[v0] = c.x - sd.x * w;
                P[v0 + 1] = c.y - sd.y * w;
                P[v0 + 2] = c.z - sd.z * w;
                P[v0 + 3] = c.x + sd.x * w;
                P[v0 + 4] = c.y + sd.y * w;
                P[v0 + 5] = c.z + sd.z * w;
                N[v0] = N[v0 + 3] = n.x;
                N[v0 + 1] = N[v0 + 4] = n.y;
                N[v0 + 2] = N[v0 + 5] = n.z;
                A[v0] = A[v0 + 3] = sd.x;
                A[v0 + 1] = A[v0 + 4] = sd.y;
                A[v0 + 2] = A[v0 + 5] = sd.z;
            }
        });
        if (this.bladeDefs.length) {
            this.bladeGeo.attributes.position.needsUpdate = true;
            this.bladeGeo.attributes.normal.needsUpdate = true;
            this.bladeGeo.attributes.aSide.needsUpdate = true;
        }
        if (!this.dewMesh)
            return;
        const D = this.dewPos, toCam = new THREE.Vector3(), cam = this.camera.position;
        this.drops.forEach((dr, i) => {
            if (dr.b < 0) {
                // on the stem
                const k = Math.round(dr.s * this.NT) * (this.NR + 1) + Math.floor((dr.o + 0.5) * this.NR * 0.5 + this.NR * 0.25);
                const SP = this.stemPos;
                c.set(SP[k * 3], SP[k * 3 + 1], SP[k * 3 + 2]);
                toCam.subVectors(cam, c).normalize();
                c.addScaledVector(toCam, 0.09);
            }
            else {
                const d = this.bladeDefs[dr.b];
                this.bladePoint(d, dr.s, t, c, tn, sd);
                n.crossVectors(sd, tn).normalize();
                toCam.subVectors(cam, c);
                if (n.dot(toCam) < 0)
                    n.negate();
                c.addScaledVector(sd, dr.o * d.w * 1.7 * Math.pow(1 - dr.s, 0.75)).addScaledVector(n, dr.r * 0.7);
            }
            D[i * 3] = c.x;
            D[i * 3 + 1] = c.y;
            D[i * 3 + 2] = c.z;
        });
        this.dewMesh.geometry.attributes.position.needsUpdate = true;
    }
    /* ---------- out-of-focus fluff: discs sized by their circle of confusion ---------- */
    buildMotes() {
        const M = Math.max(0, Math.round(CONFIG.motes)), rnd = mulberry(31), p = [], b = [];
        for (let i = 0; i < M; i++) {
            p.push(-9 + rnd() * 18, -3.5 + rnd() * 7.5, -14 + rnd() * 18.5);
            b.push(0.3 + rnd() * 0.7);
        }
        const g = new THREE.BufferGeometry();
        g.setAttribute("position", new THREE.Float32BufferAttribute(p.length ? p : [0, 0, 0], 3));
        g.setAttribute("aB", new THREE.Float32BufferAttribute(b.length ? b : [0], 1));
        const moteU = Object.assign({}, this.common, {
            uPx: { value: 800 }, uFocus: { value: CONFIG.camDist }, uDrift: { value: 0 }, uGlint: { value: color(CONFIG.glintColor) }, uBokeh: { value: CONFIG.bokeh },
        });
        this.moteU = moteU;
        const m = new THREE.ShaderMaterial({ uniforms: moteU, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: S.MOTE_VERT, fragmentShader: S.MOTE_FRAG });
        const motes = new THREE.Points(g, m);
        motes.frustumCulled = false;
        motes.renderOrder = 5;
        motes.visible = M > 0;
        this.scene.add(motes);
    }
    /* ---------- midges: a loose swarm dancing in the backlight right of the clock, just behind the focus ---------- */
    buildMidges() {
        const M = Math.max(0, Math.round(CONFIG.midges)), rnd = mulberry(53), ph = [], fq = [];
        for (let i = 0; i < M; i++) {
            ph.push(rnd() * 6.28, rnd() * 6.28, rnd() * 6.28, rnd());
            fq.push(0.5 + rnd() * 0.7, 0.8 + rnd() * 1.2, 0.4 + rnd() * 0.6, 3 + rnd() * 4);
        }
        const g = new THREE.BufferGeometry();
        g.setAttribute("position", new THREE.Float32BufferAttribute(M ? new Array(M * 3).fill(0) : [0, 0, 0], 3));
        g.setAttribute("aPh", new THREE.Float32BufferAttribute(M ? ph : [0, 0, 0, 0], 4));
        g.setAttribute("aFq", new THREE.Float32BufferAttribute(M ? fq : [1, 1, 1, 1], 4));
        const midgeU = Object.assign({}, this.common, this.lens, {
            uCtr: { value: new THREE.Vector3(2.9, 0.5, -1.6) }, uGlint: { value: color(CONFIG.glintColor) }, uPush: { value: new THREE.Vector3() },
        });
        this.midgeU = midgeU;
        const m = new THREE.ShaderMaterial({ uniforms: midgeU, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: S.MIDGE_VERT, fragmentShader: S.MIDGE_FRAG });
        const midges = new THREE.Points(g, m);
        midges.frustumCulled = false;
        midges.renderOrder = 5;
        midges.visible = M > 0;
        this.scene.add(midges);
    }
    /* ---------- the foreground layer (third resolution, premultiplied) ---------- */
    buildForeground() {
        const q = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: this.fgU, depthWrite: false, depthTest: false, blending: THREE.NoBlending, vertexShader: S.FOREGROUND_VERT, fragmentShader: S.FOREGROUND_FRAG }));
        q.frustumCulled = false;
        this.fgScene.add(q);
        const fg = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
            uniforms: { tFg: { value: this.fgRT.texture } }, depthWrite: false, depthTest: false, transparent: true,
            blending: THREE.CustomBlending, blendSrc: THREE.OneFactor, blendDst: THREE.OneMinusSrcAlphaFactor, blendSrcAlpha: THREE.ZeroFactor, blendDstAlpha: THREE.OneFactor,
            vertexShader: S.FOREGROUND_BLIT_VERT, fragmentShader: S.FOREGROUND_BLIT_FRAG,
        }));
        fg.frustumCulled = false;
        fg.renderOrder = 100;
        this.scene.add(fg);
    }
    // the breath point: the cursor on the plane through the head facing the camera
    pointerToWorld(p, out) {
        const camera = this.camera;
        const v = this._v.set(p.x, p.y, 0.5).unproject(camera).sub(camera.position).normalize();
        const fwd = this._v2;
        camera.getWorldDirection(fwd);
        const t = this._v3.copy(this.head).sub(camera.position).dot(fwd) / Math.max(1e-4, v.dot(fwd));
        return out.copy(camera.position).addScaledVector(v, t);
    }
    detach(s, push) {
        s.state = 1;
        s.age = 0;
        s.pending = -1;
        s.open = 1;
        s.grow = 1;
        const L = S.SEED_MID * s.scale * CONFIG.seedSize;
        s.pos.copy(this.head).addScaledVector(s.wdir, S.R0 * 0.9 + L);
        s.vel.copy(push);
        s.quat.setFromUnitVectors(UP, s.wdir);
        s.ang.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar((3 + Math.random() * 5) * CONFIG.tumble);
        s.spin = (Math.random() - 0.5) * 1.4;
    }
    /** A click: blow a gust from where it landed (`p` in NDC). */
    click(p) {
        const c = this.pointerToWorld(p, new THREE.Vector3());
        const v = new THREE.Vector3().subVectors(this.head, c);
        v.z = 0;
        const dist = v.length();
        // you blow from where you clicked: across the clock and a little away from the lens, lifting
        this.gustDir.copy(dist > 0.25 ? v.normalize() : new THREE.Vector3(1, 0, 0)).add(new THREE.Vector3(0.3, 0.3, -0.3)).normalize();
        this.gustDir.y = Math.max(0.22, Math.min(0.6, this.gustDir.y));
        this.gustDir.normalize();
        this.gustStr = CONFIG.blowStrength * Math.max(0.3, Math.min(1, 1.5 - dist / CONFIG.blowRadius));
        this.gustT = this.time;
        this.still = 0;
        const now = this.gustT;
        for (const s of this.seeds) {
            if (!s.hero || s.state !== 0)
                continue;
            const e = 0.5 + 0.5 * s.wdir.dot(this.gustDir); // the lee side goes first
            if (Math.random() < this.gustStr * (0.3 + 0.62 * e))
                s.pending = now + 0.04 + (1 - e) * 0.45 + Math.random() * 0.3;
        }
    }
    /** The box's CSS size changed: size the soft layers and the lens. */
    resize(width, height) {
        this.width = width;
        this.height = height;
        this.bgU.uRes.value.set(width, height);
        const v = this.renderer.getDrawingBufferSize(new THREE.Vector2());
        this.bgRT.setSize(Math.ceil(v.x / 3), Math.ceil(v.y / 3));
        this.fgRT.setSize(Math.ceil(v.x / 3), Math.ceil(v.y / 3));
        const px = v.y / (2 * Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2));
        if (this.dewU)
            this.dewU.uPx.value = px;
        if (this.moteU)
            this.moteU.uPx.value = v.y;
        this.lens.uVp.value.copy(v);
    }
    /** Step the simulation by `dt` seconds; `t` is the scene clock (seconds). */
    update(dt, t, pointer) {
        const camera = this.camera;
        this.time = t;
        const intro = this.skipIntro ? 1 : Math.max(0, Math.min(1, (t - CONFIG.introDelay) / CONFIG.introDuration));
        this.finalU.uFade.value = intro * CONFIG.mainAlpha;
        this.finalU.iTime.value = t;
        this.bgU.uTime.value = t;
        this.common.uTime.value = t;
        // the gust envelope: a fast rise, a long tail
        const gt = t - this.gustT;
        this.gustEnv = gt < 0 ? 0 : (1 - Math.exp(-gt * 9)) * Math.exp(-gt * 0.38);
        this.bgU.uGust.value = this.gustEnv * this.gustStr;
        // the pointer, smoothed; its speed is the breath
        this.pPrev.copy(this.pSm);
        this.pSm.lerp(pointer.pos, 1 - Math.exp(-dt * 3.0));
        const sp = this.pPrev.distanceTo(this.pSm) / Math.max(1e-3, dt);
        this.speed += (sp - this.speed) * (1 - Math.exp(-dt * 6));
        if (pointer.active) {
            this.breathPrev.copy(this.breathPt);
            this.pointerToWorld(this.pSm, this.breathPt);
            if (this.breathPrev.x < 1e3 && dt > 0) {
                this._v.subVectors(this.breathPt, this.breathPrev).divideScalar(Math.max(1e-3, dt));
                this.breathVel.lerp(this._v, 1 - Math.exp(-dt * 8));
            }
            this.breath = Math.min(1.2, this.speed * 0.9) * CONFIG.breathPower;
        }
        else {
            this.breath = 0;
            this.breathVel.multiplyScalar(Math.exp(-dt * 6.3)); // the source's ×0.9 per 60 Hz frame
        }
        // a still cursor near the clock lets it regrow faster
        const nearHead = this.breathPt.distanceTo(this.head) < 2.0;
        this.still = pointer.active && this.speed < 0.06 && nearHead ? this.still + dt : 0;
        const regrowBoost = 1 + Math.min(3, Math.max(0, this.still - 1.5) * 1.2); // after a moment of stillness, not straight after a blow
        // the camera: a slow sway, parallax; the hero sits left of centre, the sun upper right
        const a = Math.sin(t * 0.07) * CONFIG.orbit * 2 + this.pSm.x * 0.1 * CONFIG.parallax;
        const el = this.pSm.y * 0.06 * CONFIG.parallax;
        const D = CONFIG.camDist;
        camera.position.set(this.head0.x + Math.sin(a) * D, this.head0.y + CONFIG.camHeight + Math.sin(el) * D, this.head0.z + Math.cos(a) * D);
        camera.lookAt(this.head0);
        camera.updateMatrixWorld();
        const right = this._v.setFromMatrixColumn(camera.matrixWorld, 0), upv = this._v2.setFromMatrixColumn(camera.matrixWorld, 1);
        camera.lookAt(this._v3.copy(this.head0).addScaledVector(right, this.aimK() * D).addScaledVector(upv, this.aimUpK() * D));
        camera.updateMatrixWorld();
        this.bgU.uPar.value.set(a * 1.6, el * 1.6);
        // the sun: its screen position unprojected — one light direction for everything
        this._v.set(CONFIG.sunX * 2 - 1, CONFIG.sunY * 2 - 1, 0.5).unproject(camera).sub(camera.position).normalize();
        this.sunDir.copy(this._v);
        this.dewU?.uSunNdc.value.set(CONFIG.sunX * 2 - 1, CONFIG.sunY * 2 - 1);
        // the stem: a spring toward the breeze, the breath and the gust
        const sw = CONFIG.sway;
        const tgt = this._v3.set((Math.sin(t * 0.9) * 0.05 + Math.sin(t * 1.7 + 1.0) * 0.022 + Math.sin(t * 0.31) * 0.03) * sw + CONFIG.wind * 0.12, 0, Math.cos(t * 0.75) * 0.03 * sw);
        if (this.breath > 0.001) {
            const d = this.breathPt.distanceTo(this.head), f = Math.exp(-(d * d) / (2.2 * 2.2));
            const away = this._v.subVectors(this.head, this.breathPt);
            away.y *= 0.3;
            away.normalize();
            tgt.addScaledVector(away, this.breath * f * 0.22);
        }
        tgt.addScaledVector(this.gustDir, this.gustEnv * this.gustStr * 0.5);
        tgt.y = 0;
        this.bendV.addScaledVector(this._v.subVectors(tgt, this.bend), 22 * dt).multiplyScalar(Math.exp(-3.2 * dt));
        this.bend.addScaledVector(this.bendV, dt);
        this.updateStem();
        this.updateBlades(t);
        this.updateStemHairs(t);
        this.updateSeeds(dt, t, regrowBoost);
        this.updateLens();
        this.drift += dt * (CONFIG.wind + this.gustDir.x * this.gustEnv * this.gustStr * 3.2 * 0.4) * 0.6;
        if (this.moteU) {
            this.moteU.uDrift.value = this.drift;
            this.moteU.uFocus.value = CONFIG.camDist;
        }
        this.updateMidges(dt, t);
    }
    updateSeeds(dt, t, regrowBoost) {
        const aPos = this.aPos, aQuat = this.aQuat, aInfo = this.aInfo;
        if (!aPos || !aQuat || !aInfo)
            return;
        this.shedAcc += dt * CONFIG.shed;
        const wind = CONFIG.wind, tu = CONFIG.turbulence, gs = this.gustEnv * this.gustStr * 3.2, sw = CONFIG.sway;
        let attached = 0;
        for (let i = 0; i < this.seeds.length; i++) {
            const s = this.seeds[i];
            if (s.hero && s.state !== 1) {
                // on the clock: the breeze sways it, the breath leans it away — and may take it
                s.wdir.copy(s.dir).applyQuaternion(this.headQ);
                s.wdir.x += (Math.sin(t * 1.4 + s.phase) * 0.025 + Math.sin(t * 3.1 + s.phase * 2.0) * 0.01) * sw * (1 + this.gustEnv * 4);
                s.wdir.z += Math.cos(t * 1.1 + s.phase * 1.3) * 0.02 * sw;
                let lean = 0;
                if (this.breath > 0.001) {
                    const mid = this._v.copy(this.head).addScaledVector(s.wdir, 0.75);
                    const d = mid.distanceTo(this.breathPt), R = CONFIG.breathRadius;
                    lean = this.breath * Math.exp(-(d * d) / (R * R));
                    if (lean > 0.002)
                        s.wdir.addScaledVector(mid.sub(this.breathPt).normalize(), lean * 0.45);
                }
                if (this.gustEnv > 0.01)
                    s.wdir.addScaledVector(this.gustDir, this.gustEnv * this.gustStr * 0.25 * (0.5 + 0.5 * s.rand));
                s.wdir.normalize();
                if (s.state === 0) {
                    if (lean > s.thresh) {
                        this.detach(s, this._v.copy(s.wdir).multiplyScalar(0.4 + lean * 0.8).addScaledVector(this.breathVel, 0.35).add(this._v2.set(wind * 0.5, 0.15, 0)));
                    }
                    else if (s.pending >= 0 && t >= s.pending) {
                        this.detach(s, this._v.copy(this.gustDir).multiplyScalar((1.2 + Math.random() * 1.6) * this.gustStr).addScaledVector(s.wdir, 0.5)
                            .add(this._v2.set((Math.random() - 0.5) * 0.6, Math.random() * 0.5, (Math.random() - 0.5) * 0.4)));
                    }
                    else if (this.shedAcc >= 1 && s.rand < 0.03) {
                        this.shedAcc -= 1;
                        this.detach(s, this._v.copy(s.wdir).multiplyScalar(0.25).add(this._v2.set(wind * 0.4, 0.1, 0)));
                    }
                }
                // detach() above may have just released it (TS can't see the mutation, hence the re-read)
                const state = s.state;
                if (state !== 1) {
                    if (s.state === 2) {
                        s.grow += (dt * regrowBoost) / Math.max(0.5, CONFIG.regrow);
                        if (s.grow >= 1) {
                            s.grow = 1;
                            s.state = 0;
                        }
                    }
                    s.open = s.state === 2 ? Math.max(0, Math.min(1, (s.grow - 0.55) / 0.45)) : 1;
                    s.open = s.open * s.open * (3 - 2 * s.open);
                    this._q.setFromUnitVectors(UP, s.wdir);
                    const o = this._v.copy(this.head).addScaledVector(s.wdir, S.R0 * 0.9);
                    const g = s.state === 2 ? s.grow : 1;
                    aPos.setXYZ(i, o.x, o.y, o.z);
                    aQuat.setXYZW(i, this._q.x, this._q.y, this._q.z, this._q.w);
                    aInfo.setXYZW(i, 1, s.scale * (0.25 + 0.75 * g), s.open, s.rand);
                    attached += g;
                    continue;
                }
            }
            // in the air: the wind, the gust and the breath carry it; the pappus drags it upright, so a released seed tumbles, then hangs
            s.age += dt;
            const P = s.pos;
            const nx = (Math.sin(t * 0.9 + P.y * 1.3 + s.phase) * 0.22 + Math.sin(t * 2.1 + P.x * 0.7 + s.phase * 2.0) * 0.1) * tu;
            const ny = (Math.sin(t * 1.1 + P.x * 1.1 + s.phase * 1.7) * 0.16 + Math.sin(t * 2.7 + s.phase * 3.1) * 0.07) * tu;
            const nz = Math.cos(t * 0.8 + P.x * 0.9 + s.phase) * 0.08 * tu;
            let wx = wind + this.gustDir.x * gs + nx;
            let wy = this.gustDir.y * gs + ny + CONFIG.lift - 0.09 + s.updraft * Math.exp(-s.age * 0.12);
            const wz = this.gustDir.z * gs * 0.6 + nz - (P.z - s.zHome) * 0.05;
            if (this.breath > 0.001 || this.breathVel.lengthSq() > 1e-4) {
                const d = P.distanceTo(this.breathPt), R = CONFIG.breathRadius * 1.4, f = Math.exp(-(d * d) / (R * R));
                wx += this.breathVel.x * f * 0.6;
                wy += this.breathVel.y * f * 0.6;
                if (this.breath > 0.001) {
                    const aw = this._v.subVectors(P, this.breathPt).normalize();
                    wx += aw.x * f * this.breath * 0.8;
                    wy += aw.y * f * this.breath * 0.8;
                }
            }
            const k = 1 - Math.exp(-dt * 1.8);
            s.vel.x += (wx - s.vel.x) * k;
            s.vel.y += (wy - s.vel.y) * k;
            s.vel.z += (wz - s.vel.z) * k;
            P.addScaledVector(s.vel, dt);
            // orientation: the righting torque points the pappus into the relative air, damped tumble + a slow spin about its axis
            const axis = this._v.set(0, 1, 0).applyQuaternion(s.quat);
            const want = this._v2.set(wx - s.vel.x, wy - s.vel.y + 0.25, wz - s.vel.z).normalize();
            const tq = this._v3.crossVectors(axis, want).multiplyScalar(7);
            s.ang.addScaledVector(tq, dt).multiplyScalar(Math.exp(-dt * 2.2));
            const w = this._v2.copy(s.ang).addScaledVector(axis, s.spin), wl = w.length();
            if (wl > 1e-5) {
                this._q.setFromAxisAngle(w.divideScalar(wl), wl * dt);
                s.quat.premultiply(this._q).normalize();
            }
            const L = S.SEED_MID * s.scale * CONFIG.seedSize;
            axis.set(0, 1, 0).applyQuaternion(s.quat);
            let al;
            if (s.hero) {
                al = Math.max(0, Math.min(1, (CONFIG.flightTime - s.age) / 2.5));
                if (s.age >= CONFIG.flightTime || P.x > 6 || P.x < -6 || P.y > 4.5 || P.y < -4) {
                    s.state = 2;
                    s.grow = 0;
                    s.open = 0;
                    s.age = 0;
                    al = 0;
                }
            }
            else {
                al = Math.min(1, s.age / 1.5);
                const B = this.viewBox(P.z);
                if (P.x > B.cx + B.hw + 1.0 || P.x < B.cx - B.hw - 3.5 || P.y > B.hh + 1.0 || P.y < -B.hh - 1.2 || P.z > CONFIG.camDist - 1.0)
                    this.spawnAir(s, false);
            }
            aPos.setXYZ(i, P.x - axis.x * L, P.y - axis.y * L, P.z - axis.z * L);
            aQuat.setXYZW(i, s.quat.x, s.quat.y, s.quat.z, s.quat.w);
            aInfo.setXYZW(i, al, s.scale, 1, s.rand);
        }
        aPos.needsUpdate = true;
        aQuat.needsUpdate = true;
        aInfo.needsUpdate = true;
        this.fgU.uHeroFill.value = attached / this.NH;
    }
    // the lens: focus on the clock; every free seed gets a disc sized by its circle of confusion
    updateLens() {
        const camera = this.camera, aInfo = this.aInfo;
        if (!aInfo || !this.blurPos || !this.blurInfo)
            return;
        const fwd = camera.getWorldDirection(this._v3);
        const focus = this._v.subVectors(this.head, camera.position).dot(fwd);
        const H = this.lens.uVp.value.y, P11 = camera.projectionMatrix.elements[5] * 0.5 * H;
        this.lens.uFocus.value = focus;
        const BP = this.blurPosA, BI = this.blurInfoA;
        for (let i = 0; i < this.seeds.length; i++) {
            const s = this.seeds[i];
            if (s.state !== 1) {
                BI[i * 2 + 1] = 0;
                continue;
            }
            const sc = s.scale * CONFIG.seedSize;
            const axis = this._v2.set(0, 1, 0).applyQuaternion(s.quat);
            const c = this._v.copy(s.pos).addScaledVector(axis, (S.AP - S.SEED_MID + S.HL * 0.3) * sc);
            const d = Math.max(0.3, this._v4.subVectors(c, camera.position).dot(fwd));
            const coc = Math.min(48, (CONFIG.aperture * Math.max(0, Math.abs(d - focus) - CONFIG.focusDepth)) / d * H);
            const dp = (2 * S.HL * 0.95 * sc * P11) / d;
            const sz = dp + coc;
            BP[i * 3] = c.x;
            BP[i * 3 + 1] = c.y;
            BP[i * 3 + 2] = c.z;
            BI[i * 2] = Math.min(400, sz);
            BI[i * 2 + 1] = aInfo.getX(i) * 0.3 * Math.pow(dp / sz, 1.6) * smooth01(3, 16, coc);
        }
        this.blurPos.needsUpdate = true;
        this.blurInfo.needsUpdate = true;
        // the hero on screen, for the limb glow
        const hp = this._v.copy(this.head).project(camera);
        const asp = this.width / this.height;
        this.fgU.uHero.value.set(hp.x * 0.5 * asp, hp.y * 0.5);
        const ep = this._v2.copy(this.head).addScaledVector(this._v3.setFromMatrixColumn(camera.matrixWorld, 0), 1.08 * CONFIG.seedSize).project(camera);
        this.fgU.uHeroR.value = Math.abs(ep.x - hp.x) * 0.5 * asp;
    }
    // the swarm: a gust or a breath near it scatters it; it springs back to its dance
    updateMidges(dt, t) {
        if (!this.midgeU)
            return;
        const C = this.midgeU.uCtr.value, tg = this._v.set(0, 0, 0);
        tg.addScaledVector(this.gustDir, this.gustEnv * this.gustStr * 0.9);
        const db = this.breathPt.distanceTo(C);
        if (this.breath > 0.001)
            tg.addScaledVector(this._v2.subVectors(C, this.breathPt).normalize(), this.breath * Math.exp((-db * db) / 1.5) * 0.8);
        this.midgePushV.addScaledVector(this._v2.subVectors(tg, this.midgePush), 9 * dt).multiplyScalar(Math.exp(-2.5 * dt));
        this.midgePush.addScaledVector(this.midgePushV, dt);
        this.midgeU.uPush.value.copy(this.midgePush);
        C.set(2.9 + Math.sin(t * 0.09) * 0.35, 0.5 + Math.sin(t * 0.13 + 1.0) * 0.18, -1.6);
    }
    /** Render the two soft layers into their render targets (before the main pass). */
    renderLayers() {
        const r = this.renderer;
        r.setRenderTarget(this.bgRT);
        r.render(this.bgScene, this.bgCam);
        r.setRenderTarget(this.fgRT);
        r.render(this.fgScene, this.bgCam);
        r.setRenderTarget(null);
    }
    dispose() {
        this.bgRT.dispose();
        this.fgRT.dispose();
        for (const root of [this.scene, this.bgScene, this.fgScene]) {
            root.traverse((o) => {
                if (o instanceof THREE.Mesh || o instanceof THREE.Points) {
                    o.geometry.dispose();
                    const mats = Array.isArray(o.material) ? o.material : [o.material];
                    mats.forEach((m) => m.dispose());
                }
            });
        }
    }
}
/** Elements a pointer press on should not blow the clock (links, buttons, the consent UI). */
const INTERACTIVE = "a, button, input, select, textarea, label, summary, [role='dialog'], [role='button']";
/**
 * Build the scene into `canvas` (sized by its parent box). The scene owns its
 * render loop and listeners; `dispose()` stops and frees everything except
 * the GL context itself (`releaseContext` is the caller's).
 */
const createDandelionScene = (canvas, { skipIntro, onContextChange }) => {
    const box = canvas.parentElement ?? canvas;
    // No MSAA: every frame is drawn into the composer's (non-multisampled) targets and
    // only the final full-screen quad reaches the canvas, so a multisampled canvas
    // resolved a buffer per frame and smoothed nothing (optimization-history.md).
    const renderer = new THREE.WebGL1Renderer({ canvas, antialias: false });
    const gl = renderer.getContext();
    const unwatch = watchContext(canvas, onContextChange);
    const dprOf = () => Math.min(window.devicePixelRatio || 1, CONFIG.maxDpr);
    renderer.setPixelRatio(dprOf());
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x000000);
    const camera = new THREE.PerspectiveCamera(CONFIG.fov, 1, 0.05, 200);
    scene.add(camera);
    const finalU = {
        tDiffuse: { value: null },
        uFade: { value: 0 },
        iTime: { value: 0 },
        uGrain: { value: CONFIG.grain },
        uVignette: { value: 1 },
        uWarm: { value: CONFIG.warmth },
    };
    const composer = new EffectComposer(renderer);
    composer.addPass(new RenderPass(scene, camera));
    composer.addPass(new ShaderPass(new THREE.ShaderMaterial({ uniforms: finalU, vertexShader: S.FINAL_VERT, fragmentShader: S.FINAL_FRAG })));
    const sim = new Dandelion(scene, camera, renderer, finalU, skipIntro);
    const pointer = { pos: new THREE.Vector2(0, 0), active: false };
    let rect = box.getBoundingClientRect();
    const toNdc = (e, out) => out.set(((e.clientX - rect.left) / rect.width) * 2 - 1, -(((e.clientY - rect.top) / rect.height) * 2 - 1));
    const onMove = (e) => {
        toNdc(e, pointer.pos);
        pointer.active = true;
    };
    const onLeave = () => {
        pointer.active = false;
    };
    const onDown = (e) => {
        if (e.target instanceof Element && e.target.closest(INTERACTIVE))
            return;
        toNdc(e, pointer.pos);
        sim.click(pointer.pos);
    };
    // "Make a Wish": a gust from just left of the clock, across it toward the sun.
    const WISH_FROM = new THREE.Vector2(-0.6, -0.05);
    const onWish = () => sim.click(WISH_FROM);
    window.addEventListener("pointermove", onMove, { passive: true });
    window.addEventListener("pointerdown", onDown, { passive: true });
    window.addEventListener(DANDELION_WISH_EVENT, onWish);
    document.addEventListener("pointerleave", onLeave);
    // Desktop panels draw at ~60 fps; phones draw every frame (no cap).
    const gate = createDrawGate(window.matchMedia("(hover: hover) and (pointer: fine)").matches);
    const clock = createFrameClock();
    let frames = 0;
    let pending = 0; // seconds simulated since the last draw
    let lastT = 0;
    const draw = (dt, t) => {
        sim.update(Math.min(0.05, dt), t, pointer);
        sim.renderLayers();
        composer.render();
        frames++;
        // Every program is compiled once a frame has been drawn: the preloader may lift.
        if (frames === 1)
            markSceneReady();
    };
    // Resize only on a real change (CSS size or DPR), then draw in the same frame.
    let size = { w: 0, h: 0, dpr: 0 };
    const resize = () => {
        const w = Math.max(1, Math.round(box.clientWidth)), h = Math.max(1, Math.round(box.clientHeight)), dpr = dprOf();
        if (w === size.w && h === size.h && dpr === size.dpr)
            return false;
        size = { w, h, dpr };
        rect = box.getBoundingClientRect();
        renderer.setPixelRatio(dpr);
        renderer.setSize(w, h, false);
        camera.aspect = w / h;
        camera.updateProjectionMatrix();
        composer.setPixelRatio(dpr);
        composer.setSize(w, h);
        sim.resize(w, h);
        return true;
    };
    resize();
    const observer = new ResizeObserver(() => {
        if (resize()) {
            draw(0, lastT);
            gate.force();
        }
    });
    observer.observe(box);
    const onScrollOrResize = () => {
        rect = box.getBoundingClientRect();
    };
    window.addEventListener("resize", onScrollOrResize, { passive: true });
    let raf = 0;
    const frame = (now) => {
        raf = requestAnimationFrame(frame);
        const { dt, t } = clock.tick(now);
        lastT = t;
        pending += dt;
        if (!gate.shouldDraw(dt))
            return;
        draw(pending, t);
        pending = 0;
    };
    raf = requestAnimationFrame(frame);
    return {
        canvas,
        gl,
        frames: () => frames,
        dispose: () => {
            cancelAnimationFrame(raf);
            observer.disconnect();
            window.removeEventListener("pointermove", onMove);
            window.removeEventListener("pointerdown", onDown);
            window.removeEventListener(DANDELION_WISH_EVENT, onWish);
            window.removeEventListener("resize", onScrollOrResize);
            document.removeEventListener("pointerleave", onLeave);
            unwatch();
            sim.dispose();
            composer.renderTarget1.dispose();
            composer.renderTarget2.dispose();
            renderer.dispose();
        },
    };
};
```

### 4 · Mounting it

```js
const box = document.getElementById("scene");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let handle = null;
function mount() {
  const canvas = document.createElement("canvas");
  canvas.style.cssText = "display:block;width:100%;height:100%";
  canvas.setAttribute("aria-hidden", "true");
  box.appendChild(canvas);
  try {
    handle = createDandelionScene(canvas, { skipIntro: true, onContextChange: (ev) => {
      if (ev !== "lost") return;
      handle?.dispose(); handle = null;
      setTimeout(mount, 0);                     // rebuild on a fresh canvas; no fade-in
    } });
  } catch (err) {
    canvas.remove(); showStill(); markSceneReady();
  }
}
if (reduced) showStill(); else mount();
```

`skipIntro: true` — the preloader covers the build and reveals the scene already at full light.
`showStill()` puts the still `<picture>` (see Assets) in the box: the landscape still from
`(min-width: 1024px)`, the portrait one below, `width:100%; height:100%; object-fit:cover`, with
the scene-still alt text.

## Fixed parameters (bake these in)

Everything tunable is in **Tuning** above (palette, 210 seeds × 44 hairs, breath radius 0.9, blow
radius 4, wind 0.32, flight 14 s, regrow 6 s, 7 blades, 70 motes, 12 midges, 420 stem hairs, sun at
`0.62, 0.7`, aperture 0.12, focus depth 1.1, camera distance 7.4, height 0.25, fov 30, grain 0.022,
max DPR 1.5). Page timings: preloader bloom 2600 ms, spin 30°/s, scene-ready cap 4000 ms, reveal
spring `15/11.5`; entrance springs `120/28` (rules `60/22`, dots `260/16`), hover `320/26`, card
hover `90/24`; Blur In `25/12`, 50 ms stagger, 150 ms delay, 8px blur. Breakpoints 640 and 1024.

## Assets

Runtime files are included in the source project under public/assets/hero:

```
ASSET_BASE_URL = ./assets/hero
```

- `${ASSET_BASE_URL}/hero-grotto-1080.mp4` — the media card loop, desktop (`media="(min-width: 1024px)"`), H.264
- `${ASSET_BASE_URL}/hero-grotto-720.mp4` — the media card loop, tablet and phone
- `${ASSET_BASE_URL}/hero-grotto-poster.webp` — its poster (1080×810, the clip's first frame), under the video
- `${ASSET_BASE_URL}/dandelion-desktop.webp` — the scene still, landscape (1440×900), for reduced motion / no WebGL, `(min-width: 1024px)`
- `${ASSET_BASE_URL}/dandelion-mobile.webp` — the scene still, portrait (824×1830), below 1024px

The icons are the inline SVGs above. The scene itself loads no files.

## MOTDES 源码格式说明

以上完整提示词的输出目标是单个 HTML;本次交付的原版源码是 Next.js 工程,两者格式不同。提示词所用文件在工程的 public/assets/hero/ 内,路径改为实际随包素材。保留原版设计与参数,未补造导航子页面或接通外部业务服务。当前仅完成源码静态检查和交付整理,未验证依赖安装、构建、运行或真机效果。
[/hidecontent]
← 返回首页