πŸ¦†

Commit 09d5420 Β· User Name Β· 2026-10-18T23:59:00+02:00

Changeset
09d5420596245a214667fcf84131c135e4ced006

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d6ccfe0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.wrangler/
+*.log
+temp/
+public/quack-source.zip
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..107634c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 CELPHASE
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..333b811
--- /dev/null
+++ b/README.md
@@ -0,0 +1,107 @@
+# quack
+
+A tranquil 3-D duck pond in the browser, made for phones first. No UI, no HUD β€”
+the scene is the interface. Drag to look around, pinch to lean in, and tap the
+water to throw a piece of bread.
+
+quack is a port of [celphase's duck-pond](https://github.com/celphase/duck-pond) β€”
+a high-quality duck *audio* synthesizer written in Rust β€” from sound to sight.
+The ducks live the same lives: the time budget of dabbling, resting and preening,
+the depth bands they drift between, broods trailing their mother, a drake
+escorting his mate. The quacks are silent (no audio yet), but the calling model is
+here: the same call timers, social answers and brood recalls, spoken in posture
+and emitted as `CallEvent`s for the coming audio pass.
+
+## Run
+
+Node 20.19+ or 22.12+ (what Vite needs).
+
+```sh
+npm install
+npm run dev      # dev server (Vite)
+npm run build    # production build into dist/
+npm run preview  # serve the build locally
+```
+
+Add `?seed=N` for a repeatable pond. The default seed is `20261018`.
+
+Production builds always write source maps. The fifth title card points at
+`SOURCE.src_url` in `src/config.ts`; while that is empty it downloads
+`quack-source.zip`, packed from the working tree by `scripts/source-zip.mjs`
+(credentials, build output and the vendored Rust clone stay out).
+
+## Controls
+
+| input | action |
+| --- | --- |
+| drag | look around β€” the head turns in place, clamped so the pond stays in view |
+| pinch / wheel | zoom β€” open the view wide, or lean in close (clamped) |
+| tap / click | throw bread β€” it leaves your hand on release |
+| a press that drifts past 12 px | that was a camera drag, not a throw |
+| cursor (fine pointer) | every duck's head follows it across the screen |
+
+Hidden conveniences for whoever opens the console: `space` pauses, `T` runs the
+clock at a quarter speed, `Y` skips a season, and `1`–`4` pin the year at
+spring / summer / autumn / winter.
+
+## The pond
+
+- Twelve mallards: hens **Mabel**, **Clover** and **Hazel**; drakes **Rufus**
+  (Clover's mate) and **Bramble**, on his own; and two broods β€” Mabel trailing
+  Pip, Dot, Bean and Fig, Hazel with Nib, Tuft and Sprout.
+- A full year passes in 28 minutes, seven minutes to a season, each blending
+  into the next. The sun wanders and the light warms and cools; winter freezes
+  the pond from every edge inwards, but for one patch of open water at its
+  middle β€” the lead β€” which the flock gathers to and keeps open all season, and
+  out onto the ice of which they will walk for bread.
+- The flock runs duck-pond's activity model β€” swim, dabble, rest (and sleep:
+  head turned onto the back, bill tucked in but still visible), preen, stray.
+  Ducklings follow their mother and wait when they lose her; a drake keeps
+  near his mate.
+- Bread: one piece per throw, two or three bites each, and a stretch of
+  begging where the bread landed until the ducks give up and drift back to
+  their day. Bread nobody comes for sinks away.
+- You stand at the water's edge on the near bank. The ground climbs clear of the
+  water, so the shore is a border at your feet β€” tilt down and it is right there β€”
+  and `World.shore` marks out the walkable stretch of it (130 m each way, 18 m
+  deep).
+
+## Layout
+
+```
+index.html          entry point: canvas host, loading card, error card
+src/main.ts         boot, frame loop, input (tap-throw, hidden keys), debug handle
+src/config.ts       every tuning dial, in one export per scope
+src/world.ts        terrain, water, sky, shore, forest, grass, season paint
+src/seasons.ts      the year: four keyframe seasons, always blending
+src/pond.ts         the flock: roster, time budget, families, bread and begging
+src/duck.ts         duck rig and poses
+src/camera.ts       first-person rig: a planted eye, turning and zooming
+src/effects.ts      ripples, splashes, dust and sinking particles
+src/hints.ts        the title cards: controls, credit, source
+src/utils.ts        RNG, value noise, easing, geometry helpers
+vite.config.ts      production build shape: source maps always on
+public/             loading-card stills, generated source zip
+scripts/shot.mjs    puppeteer screenshot harness used while tuning
+scripts/sample.mjs  reads a shot back as a colour column, for tuning
+scripts/source-zip.mjs  packs the source-card fallback from the working tree
+scripts/loader-shot.mjs  re-shoots the card's backdrop and packs it to JPEG
+docs/port-notes.md  port decisions, tuned constants, debug recipes
+docs/deploy.md      build, deploy, rollback, loading-card re-shoots
+```
+
+Two folders on the side, deliberately kept out of this repo's history:
+
+- `duck-pond/` β€” the vendored Rust clone and the study notes behind the port.
+- `temp/` β€” scratch space (screenshots and such), git-ignored.
+
+## Credits
+
+- **[duck-pond](https://github.com/celphase/duck-pond)** by celphase (MIT):
+  the behavior model, time budgets, family rules and speeds are ported from
+  its `scene.rs`.
+- **[three.js](https://threejs.org)** (MIT): everything you see.
+
+## License
+
+MIT β€” see [LICENSE](LICENSE).
diff --git a/docs/behavior-divergence.md b/docs/behavior-divergence.md
new file mode 100644
index 0000000..7f3f481
--- /dev/null
+++ b/docs/behavior-divergence.md
@@ -0,0 +1,748 @@
+# Behaviour divergence: quack vs duck-pond
+
+A line-by-line comparison of the behaviour model in **quack** (the 3-D
+TypeScript pond, `src/pond.ts`) against **duck-pond** (the 2-D Rust audio
+pond, `duck-pond/src/scene.rs`), asking three questions:
+
+1. What does the Rust model do that quack **has not ported** yet?
+2. Where has quack taken **creative freedom** β€” invented behaviour the Rust
+   never had?
+3. Where does the behaviour **differ because of the two things quack has that
+   the Rust cannot**: the **flight** animation and the **winter** freeze?
+
+`docs/port-notes.md` is the working record of *how* the port was done. This
+file is the *balance sheet* β€” what came across, what did not, what was added,
+and what changed for a reason.
+
+Both projects are read-only for this document; nothing was modified.
+
+**Update.** The calling model has since been ported as behaviour: the nine
+intervals, sleeping suppression, hen lottery, brood calls and one-hop answer
+chain now live in `src/pond.ts`, tuned by `CALLS` in `src/config.ts`. The
+synthesis, propagation and spatialization are still absent, so calls are silent
+head/bill gestures plus `CallEvent`s on `Pond.onCall` for the audio core to
+come. Sections 2.1–2.4 and 4.4 have been rewritten to record what closed; the
+remaining audio gap is Β§2.5 and the dropped appendix. Line numbers in those
+rewritten sections are from the tree before the call port.
+
+**Conventions.** quack's own files are always written with their `src/` or
+`docs/` prefix (`src/pond.ts:611`). The Rust originals are quoted by their
+bare file name β€” `scene.rs`, `synth.rs`, `main.rs` β€” and always mean the
+vendored copy under `duck-pond/src/`. Line numbers are from the working tree
+at the time of writing (`src/pond.ts` 1643 lines, `src/duck.ts` 1423,
+`src/config.ts` 538, `src/world.ts` 1330, `scene.rs` 1494). This file uses the
+British "behaviour" the quack source uses; the Rust source uses "behaviour"
+too in its comments, and the Rust *docs* use "behavior" in the README.
+
+---
+
+## 1. The scorecard
+
+| | duck-pond (Rust, 2-D, audio) | quack (TypeScript, 3-D, visual) |
+| --- | --- | --- |
+| Activities | 6: `Swim Dabble Rest Preen Stray Feed` | 8: `swim dabble rest preen stray feed` **+ `beg` `fly`** |
+| Sub-actions (poses) | none β€” a duck is a letter in a map | 12: `sleep stretch look idle preen peck dabble upend drink chirp beg` (+ `startle`, dead) |
+| Calling model | 9 inter-call intervals, a 4-way call lottery, a social flag, a one-hop answer chain | **ported** β€” the same timing, lottery and chain, silent gestures and `CallEvent`s for the missing synth |
+| Food | 12–20 peas per throw, one peck each | one crust per throw, 2–3 bites, plus begging |
+| Water | one medium, deep enough to swim | three: swim (0.3 m), wade (0.05 m), walk the ice lid (0.5 frozen) |
+| Year | none β€” a timeless breeding pond | four seasons, 28 min a year, ice from every edge |
+| Locomotion | swim, waddle | swim, waddle, **lap**, **fly** |
+| Depth bands | 3–10 / 10–25 / 25–46 m from the listener | world-z bands 22–38 / 0–22 / βˆ’38…4 |
+| Personal space | a 0.6 m radius nudge for adults | a per-duck ellipse hull, family vs stranger gaps, and a hard separation pass |
+| RNG | ChaCha12 (`StdRng`), `seed_from_u64` | mulberry32, `seed ^ 0x51ab`, plus a private stream per duck |
+
+The **decision structure came across essentially intact**. Every transition
+weight, every bout length, every family rule and every speed constant is
+present in the TS with the same value. The timing of the *vocal* half is now
+present too; only its sound is missing. What is new is a whole visual and
+seasonal layer.
+
+---
+
+## 2. Part A β€” The calling model and remaining gaps
+
+### 2.1 The calling model β€” ported, silent for now
+
+The Rust's `Scene::call()` (`scene.rs:1189-1292`) is roughly a third of the
+whole simulation. **Its timing half is now ported** into `src/pond.ts`
+(`tickCalls`, `callInterval`, `call`, `showCall`), tuned by `CALLS` in
+`src/config.ts`. What remains absent is the synthesis, propagation and
+spatialization: quack turns each call into a silent head/bill gesture and emits
+a `CallEvent` β€” `{ kind, label, notes, duration, social, answering }` β€” on
+`Pond.onCall`, with the caller attached.
+
+The ported pieces:
+
+| Rust behaviour | Where | Status in quack |
+| --- | --- | --- |
+| Nine inter-call intervals, armed in precedence order | `scene.rs:1201-1211` | **ported** β€” `CALLS.interval`, same precedence |
+| Sleeping ducks swallow 80–90 % of their calls | `scene.rs:1213-1216` | **ported** β€” `CALLS.sleepSkip` |
+| The hen's call lottery: 45 % single quack / 25 % social series / 20 % decrescendo / 10 % chatter, with a 60 % feeding branch in front of it | `scene.rs:1219-1249` | **ported** β€” same branches, `CallKind` names |
+| The `social` flag β€” only the series and the decrescendo can set others off | `scene.rs:1241, 1244` | **ported** β€” `CallEvent.social` |
+| The answer chain: mate p = 0.6, others `0.3Β·(1 βˆ’ dist/40)`, within 40 m, one hop only | `scene.rs:1278-1291` | **ported** β€” `CALLS.answer`, `event.answering` closes the chain |
+| A resting duck woken by a neighbour answers | `scene.rs:1198` | **ported** β€” `answering` overrides the sleep suppression |
+| The drake's "mate out of reach" rule (8 m, 8–20 s instead of 45–180 s) | `scene.rs:1196, 1207-1208` | **ported** β€” `CALLS.mateAway`, `CALLS.interval.drakeAway` |
+| The hen's brood call and its 3.7 notes/s rate | `scene.rs:1229-1233`, `synth.rs:518-530` | **ported** β€” `CALLS.broodNotes`, `CallEvent.duration` |
+
+`CallEvent.duration` uses the same note-count formulas as `synth.rs`, so the
+future audio core can start a call at the right moment and fill in the same
+envelope. The old decision from `docs/port-notes.md` still holds for the sound
+itself β€” *"deliberately not ported for v1: the whole audio stack"* β€” but the
+timing, social chain and brood recall are behaviour, and they are now in the
+pond. The mirror rule (Β§3.12) is no longer the only source of the pond's
+lively-and-drowsy stretches.
+
+### 2.2 `gathering` β€” now read by the brood call
+
+`gathering` is set when a hen with young starts a swim bout, and it is now
+read by the brood branch of `call()`. A gathering hen gives the 3–6-note
+`kwek-kwek`; a hen with lost young gives the 6–10-note version. Both cue any
+*stray* duckling back to her. The flag is cleared when the call fires, so one
+gathering makes one call.
+
+The visible gesture is the same silent `chirp` head/bill lift used by every
+call. It deliberately does not borrow the `beg` pose: `beg` is an activity
+pose, and starting it from the call would interrupt whatever the hen was
+really doing.
+
+### 2.3 Two duckling states β€” both present
+
+The Rust distinguishes two duckling calls with quite different meanings
+(`scene.rs:1255-1256`):
+
+- **`peep peep`** β€” loud distress whistles, only when the duckling is *lost*
+  (`kind: 'peeps'`, repeated every 0.2–1 s);
+- **`pip-pip`** β€” soft contact notes, otherwise (`kind: 'contact'`, 2–5 notes,
+  every 4–15 s, or 1.5–5 s while feeding).
+
+The old random 0.3 Hz lost chirp in `updateRig` has been removed; the call
+scheduler now owns the gesture, so a content duckling is no longer silent and a
+lost one peeps at the Rust's pace.
+
+### 2.4 The duckling "peep β†’ mother answers β†’ return" chain β€” complete
+
+The Rust's chain is: a lost duckling peeps β†’ the mother's call timer is cut to
+0.5–2.5 s (`scene.rs:1265-1269`) β†’ she gives a brood call β†’ every *stray*
+duckling is cued back to her (`scene.rs:1270-1277`).
+
+All three steps are now in `call()` and `tickCalls()`. A lost peep shortens the
+mother's timer, her next call is forced into the brood branch by `lostYoung`,
+and the stray callbacks run while the call is being made. The separate
+feed-time `cue` remains as a safety net; the call is now the normal way home.
+
+### 2.5 The dabbling splash rhythm and the bill clack
+
+The Rust schedules a **sound** for dabbling β€” a 2–7 s patter of tiny splashes
+every 3–15 s while an adult feeds on the water (`scene.rs:884-886`,
+`950-960`) β€” and a clack-and-swallow when a duck eats a pea
+(`scene.rs:897-903`). These are audio, so they cannot port directly.
+
+But the Rust's dabbling rule carries a behavioural rule that *is* portable and
+was not carried: **only a duck on the water dabbles audibly; a duck grazing
+ashore is silent** (`scene.rs:884`). The TS reproduces the *visual* half
+correctly β€” a duck on the bank pecks at the grass rather than dabbling
+(`src/duck.ts:892-897`) β€” so this one is covered, but by a different mechanism.
+
+### 2.6 The Rust's "restore" bug β€” and the port's fix
+
+This is the one place where the two projects **disagree about the original's
+behaviour**, and quack is right.
+
+The Rust's `stop_feeding` (`scene.rs:699-717`) does **not** restore what a
+duck was doing before the peas arrived. A leader gets a brand-new activity
+(50 % Dabble / 25 % Rest / 25 % Preen) with a fresh 60–180 s bout; a follower
+is re-cued from its leader. `README.md:29` in the Rust project claims "Once the
+peas are gone, they go back to what they were doing", and that claim is false
+β€” a discrepancy documented in `duck-pond/behaviors.md` Β§7.1.
+
+quack **does** restore it:
+
+```ts
+// src/pond.ts:1056-1069
+private resumeAfterFeed(d: Duck): void {
+    const pre = d.preFeed;
+    d.preFeed = null;
+    ...
+    if (pre && pre.activity !== 'feed' && pre.activity !== 'beg') {
+        d.setActivity(pre.activity);
+        d.activityLeft = this.boutLength(pre.activity);
+    } else {
+        const r = this.rng.next();
+        const a: Activity = r < 0.5 ? 'dabble' : r < 0.75 ? 'rest' : 'preen';
+        d.setActivity(a);
+        d.activityLeft = this.rng.range(FLOCK.bout.afterFeed.min, FLOCK.bout.afterFeed.max);
+    }
+```
+
+`d.preFeed` is captured when a duck notices the bread (`src/pond.ts:624`) and
+restored here, with the Rust's 50/25/25 lottery kept as the fallback for a duck
+that was not doing anything in particular. The port matches the README and the
+original does not. **The Rust is the one that should be fixed.**
+
+### 2.7 Things the Rust has that were *replaced* rather than dropped
+
+These are not gaps so much as substitutions β€” the TS reached for a different
+mechanism for the same job.
+
+| Rust | quack's replacement |
+| --- | --- |
+| `PERSONAL_SPACE = 1.0` m from the listener (`scene.rs:79-80`) | a per-duck ellipse hull with family and stranger gaps (`src/config.ts:453-464`, `src/pond.ts:1097-1183`) |
+| Metric distance bands, 3–10 / 10–25 / 25–46 m (`scene.rs:95-99`) | world-z bands, `near` 22–38 / `mid` 0–22 / `far` βˆ’38…4 (`src/config.ts:155-157`) β€” a different axis entirely, because the listener is a stand point in a 3-D scene, not an origin |
+| Adults separate within 0.6 m (`scene.rs:1121-1128`) | `SPACE.reach` 1.3Γ— the hull distance, up to 0.28 m/s, plus two hard settling passes (`src/pond.ts:1127-1183`) |
+| `bank_point` on a 18 Γ— 7.5 m strip (`scene.rs:77-78, 117-124`) | a 52 m-wide beach, z 46.6–51.5, at least 2 m (5 m for a hen with young) from the thrower (`src/config.ts:143`, `src/pond.ts:562-569`) |
+| Two regions: `in_pond` and `on_bank` (`scene.rs:82-93`) | three media: swim, wade, walk the lid (`src/pond.ts:132-148`, `src/config.ts:171`) |
+| The shore-slide fallback (full / x / y / tangential step, `scene.rs:1166-1180`) | **ported** β€” the same four-step fallback (`src/pond.ts:1317-1344`) |
+| `stop_feeding` for a follower β†’ `cue(i)` (`scene.rs:701-704`) | `resumeAfterFeed` β†’ `followLead` (`src/pond.ts:1072`) |
+
+### 2.8 The RNG
+
+The two ponds are **not** bit-compatible, and cannot be. The Rust uses
+`rand`'s ChaCha12 via `StdRng::seed_from_u64` (`main.rs:140`);
+quack uses mulberry32 (`src/utils.ts:56-93`) seeded with `seed ^ 0x51ab`
+(`src/pond.ts:66`), with a private stream derived per duck
+(`src/duck.ts:726`).
+
+This is not a porting failure β€” it is unavoidable, since the two programs draw
+different numbers of randoms per frame (quack spends draws on laps, flights,
+bread, begging and the sub-action layer; the Rust spends them on calls,
+bubbles and the water's normal samples). But it has one consequence worth
+recording: **quack's per-duck private streams make the roster order
+load-bearing.** Change anything drawn before the ducklings and every later
+duck's spawn position and body scale moves (`src/pond.ts:111-116`).
+
+There is also one unseeded escape hatch: `between(min, max)` uses raw
+`Math.random()` (`src/utils.ts:15-18`), used for three cosmetic bread-ring
+values (`src/pond.ts:1466, 1555, 1559-1560`). These never feed back into a
+duck decision, but they mean a seeded quack is not perfectly reproducible
+frame-for-frame.
+
+---
+
+## 3. Part B β€” Creative freedom
+
+Everything in this part is behaviour quack has that duck-pond never had. Some
+of it is invention; some of it is the visual layer doing work the Rust left
+to the audio layer.
+
+### 3.1 Two new activities
+
+**`beg`** (`src/duck.ts:15`) β€” there are two distinct kinds, and they are
+good:
+
+- **The duck that got a bite and wants more.** `stopFeeding` sends any duck
+  that ate and finds the pond empty into `beg` at the spot of its last bite
+  for 25–40 s (`src/pond.ts:1037-1054`). This is the behaviour the Rust's
+  `stop_feeding` *comment* gestures at β€” *"though ducks on their own hang about
+  for a while in case more come"* (`scene.rs:697-698`) β€” but the Rust never
+  implements it. quack does.
+- **The curious visitor**, Β§3.5.
+
+**`fly`** (Β§4) β€” a duck on the wing.
+
+### 3.2 The sub-action layer
+
+The Rust's ducks have a position, a velocity and an activity. quack's ducks
+have a **third level**: a pose, re-rolled every few seconds, that carries the
+body.
+
+`Duck._nextAction()` (`src/duck.ts:875-929`) is a dice table per activity:
+
+| activity | sub-actions and their chances |
+| --- | --- |
+| `rest` | `sleep` 0.5 (20–70 s) / `stretch` 0.12 (2.6 s) / `look` 0.18 (4–12 s) / `idle` 0.2 (3–10 s) |
+| `preen` | `preen` 0.68 (6–18 s) / `stretch` 0.10 / `look` 0.12 / `idle` 0.10 |
+| `dabble`, afloat, adult | `upend` 0.26 (3.6–5.8 s, only where the water is over 0.8 m) / `dabble` 0.34 (2–7 s) / `drink` 0.15 (2–4 s) / `idle` 0.25 |
+| `dabble`, afloat, duckling | `drink` 0.35 (1.6–3 s) / `look` 0.15 / `idle` 0.5 β€” "picking insects off the surface" |
+| `dabble`, not afloat | `peck` 0.5 (2–5 s) / `look` 0.25 / `idle` 0.25 β€” grazing the bank, or the ice |
+| `swim` / `stray` | `look` 0.2 (3–8 s) / `drink` 0.04 / `idle` 0.76 |
+| `feed` | `idle` only (1.5–5 s) β€” "making for food: there is no time for sightseeing" |
+| `beg` | `beg`, with a duration of `1e9` so it never expires on its own |
+
+### 3.3 `upend` β€” bottom feeding, which the Rust never modelled
+
+The Rust's `Dabble` covers sieving the surface and grazing, and adds a
+*splash sound*; it never has a duck stand on its head. quack does, as a
+whole-body pivot about the waterline (`src/duck.ts:1009-1039`): a 0.8 s ramp
+over, a hold, a 0.75 s ramp back, the feet kicking at 7.5 rad/s, the stern
+wagging, and a ring-and-droplet burst at each end. The body tips by
+`DUCK.upend.pitch` = 1.62 rad, "a little past vertical" (`src/duck.ts:1368-1372`).
+
+This is a genuine addition, and an ecologically correct one β€” upending is a
+core mallard feeding behaviour, and the Rust's model has no way to express it
+because there are no bodies.
+
+### 3.4 The waddle
+
+The whole leg solve (`src/duck.ts:153-168`, `1296-1324`) has no analogue at
+all. One hinge per leg at the hip, the stance ramp as an arcsine, the planted
+sole holding its spot, the body riding whichever leg is down, the ankle
+cancelling the hinge, the lean onto the planted foot and the stern's
+answering wag. The cadence falls out of the speed rather than being keyframed,
+so a hen at 0.45 m/s waddles at 1.32 cycles/s and a duckling at its shorter
+legs waddles faster.
+
+### 3.5 The curious visitor
+
+`src/pond.ts:925-930` β€” at a bout end, a duck in the water within 46 m of the
+stand, with no bread down, may decide to come and look at you:
+`CURIOUS.chance` 0.08, at most one at a time, 150–420 s apart. It crosses at a
+purpose, then stands about in the shallows in front of you for 25–60 s with
+its head up and the odd imploring bill-open β€” **begging at nothing**.
+
+This is the single most quack-native idea in the port. It is a *reading* of
+the human, not a reaction to food, and it gives the viewer a relationship with
+the flock that the Rust β€” which has a listener but no visible one β€” never
+had.
+
+### 3.6 The lap of the lake
+
+`src/pond.ts:331-395` β€” a duck that would settle to the nearest pastime
+sometimes goes off **around** the water instead: `LAP.chance` 0.14 of bout
+ends. The circle is drawn fresh through the bird's own position each time, so
+the whole bout *is* the round; a random radius (4.5–9.5 m), a random way
+round, and two incommensurate harmonics bowing the path in and out by up to a
+quarter of the radius. `stepLap` walks the mark 3.5 m ahead along the arc, so
+the line the bird swims bows the way the lap does. 0.55–1.5 turns a bout, and
+30 % of them swing out of the picture and back.
+
+### 3.7 The sight cone
+
+`src/pond.ts:176-183` β€” a duck with its head up sees a landing spot within
+55 m and inside 1.15 rad of its bill, and sets off for the splash at 25–70 %
+of the piece's flight time, so it is *already moving* before the crust lands.
+Everyone else only hears it, and comes late.
+
+The Rust can only model the hearing half: peas are *heard*, at
+`U(0.3,1.5) + dist/12` seconds (`scene.rs:684`). The sight cone is a
+behavioural rule the 2-D original had no way to express, because a 2-D duck
+has no bill and no line of sight.
+
+### 3.8 The cursor gaze and the camera
+
+`src/pond.ts:1580-1602` and `src/duck.ts:1202-1211` β€” every duck pans and
+tilts for the pointer, shaped with `tanh` so the middle of the screen is
+gentle and the corners reach the full limit (yaw 1.55 rad, pitch 0.72 rad). A
+duck that is walking, sleeping, preening, dabbling or pecking ignores the
+cursor; only a swimming or loafing, idle/looking/begging duck tracks it. And
+when it is making for food, the gaze locks dead ahead β€” the food overrides the
+cursor.
+
+### 3.9 Personal space: hulls and hard separation
+
+`src/pond.ts:1097-1183`. Every duck carries an ellipse hull (0.31 m long,
+0.18 m wide, in body-scale units, so a drake is a bigger obstacle than a
+duckling) plus a gap: 0.03 m inside a family, 0.10 m between strangers. It is
+a nudge first β€” a soft push from 1.3Γ— the hull distance out, at up to
+0.28 m/s β€” and then `Pond.separate()` runs two passes over every pair,
+shares out any overlap and takes the closing speed out of the pair, so the
+next frame cannot drive them straight back in. A sleeper or an upended duck
+takes a quarter share and is given no new velocity at all.
+
+The Rust has only the first half: adults push apart within 0.6 m
+(`scene.rs:1121-1128`), and there is no hard pass, so two ducks in the Rust
+can overlap.
+
+### 3.10 The out-of-view rules
+
+`src/pond.ts:161-164, 186-190, 921-924` β€” a duck that ends a bout outside the
+camera's wedge is sent back to its band's water with p = 0.8, and a pastime
+already running out there is cut to 50 s. `dabble` is offered only in water
+inside the wedge, which was the leak that let a dabbler drift over the line
+and then hold position out there for a whole bout.
+
+This is a **camera rule masquerading as a behaviour rule**, and it is worth
+naming as such: the Rust's pond is a circle around a fixed listener, so the
+whole pond is always in view and the question never arises. quack has a
+first-person camera that pans and zooms, so it has to decide what "off
+screen" means for a bird.
+
+### 3.11 Bread: a different food economy
+
+The Rust's food is a handful of peas: 12–20 at a time, all landing within
+Β±35Β° at 2–7 m, each eaten in one peck, sinking after 180 s
+(`scene.rs:666-678, 897-903`).
+
+quack's is one crust at a time, thrown by hand, with a much richer lifecycle:
+
+- the throw leaves the hand 1.2 m along the view direction, 1.4 m down
+  (`src/main.ts:245-255`), arcs over `clamp(1.0 + 0.06Β·dist, 1.2, 5.5)` m, and
+  lands in `clamp(0.45 + 0.04Β·dist, 0.6, 1.9)` s;
+- the landing is clamped into the chase region β€” open water, shallows, the
+  ice, or the bank β€” and skewed off the cursor if it lands on top of existing
+  bread, re-rolled until it clears every piece by 0.24 m
+  (`src/pond.ts:1406-1432`, `src/config.ts:384-389`);
+- it takes 2 or 3 bites, shrinking to 80 % each time
+  (`src/pond.ts:1076-1088`), and every duck that saw or heard it comes;
+- it floats with a little ring every 1.4–4.2 s so it stays findable without
+  shouting like the splash it landed with β€” but a piece on the lid lies still
+  and does not ring at all (`src/pond.ts:1552-1563`);
+- unclaimed, it shrinks away over 4 s once it is 100 s old
+  (`src/pond.ts:1565-1569`).
+
+### 3.12 The mirror rule β€” ported, and it matters
+
+`src/pond.ts:886-891` keeps the Rust's 0.35 chance for a duck to copy a
+leaderless neighbour's activity, sampling only ducks with no leader of their
+own, and only for `dabble`, `rest` or `preen`. This is the Rust's
+`others_doing` rule (`scene.rs:1063-1066`), verbatim. It is no longer the
+*only* mechanism producing the pond's "lively and drowsy spells": the calling
+chain (Β§2.1) is back beside it.
+
+Measured on the Rust: the four leaderless ducks share an activity 11.5 % of
+the time against a 4.9 % chance baseline β€” a 2.3Γ— effect, modest but real
+(`duck-pond/behaviors.md` Β§3.8).
+
+### 3.13 The `startle` pose that never fires
+
+`src/duck.ts:963-966` and `1145-1154` implement a full startle β€” *"a whirr
+that swings from the waterline up over the back, never down through the
+surface the duck is sitting on"* β€” and it is wired into the wing-visibility
+test at `src/duck.ts:1260`. But `startAction('startle'` appears **nowhere**.
+It is dead code inherited from the bird rig the port came from.
+
+It is still unwired. The new `CallEvent`s give it an obvious trigger later β€” a
+nearby answer could startle a duck once there is a loudness model β€” but without
+audio there is no "loud" yet, so the pose stays dormant rather than firing at
+random.
+
+---
+
+## 4. Part C β€” Changed by the flight
+
+The Rust has **no flight at all**: no wings, no `fly` activity, and the only
+thing that is ever "airborne" is a pea in the air (`scene.rs:187, 676-677`).
+A duck in duck-pond never leaves the water.
+
+quack's flight is a whole subsystem, and it changes behaviour in six ways.
+
+### 4.1 The flight itself
+
+`FLIGHT.chance` 0.18 of bout ends, scaled by `WINTER.bouts` 0.55 in a freeze
+(`src/pond.ts:915-916`), become a low pass over the water: 16–34 m long
+(`WINTER.flightReach` 5–14 m in winter), 4.2–8.5 s in the air, riding
+0.8–1.45 m Γ— body scale over the surface at its peak, with the landing
+re-rolled until it is clear of the ducks already there and within 40 m of the
+thrower (`src/pond.ts:965-978`, `src/config.ts:310-328`). A bird that has just
+come down stays down for 20–45 s, so flights never chain into a shuttle.
+
+### 4.2 A flying duck leaves the flock's economy
+
+`src/pond.ts:1190` skips the flying duck in the steering pass entirely, and
+`src/pond.ts:1188` excludes it from the hull, the ripple wake, the cursor gaze
+and unclaimed bread. Nothing on the water may nudge it off. It is a bird
+above the flock, not of it.
+
+### 4.3 Who is allowed to fly
+
+```ts
+// src/pond.ts freeWing()
+/** Ducks that follow no one and have no brood fly and roam alone. */
+private freeWing(d: Duck): boolean {
+    return !d.leader && d.kind !== 'duckling' && this.young(d).length === 0;
+}
+```
+
+That admits **Clover** and **Bramble** only:
+
+| duck | may fly? | why |
+| --- | --- | --- |
+| Mabel | no | has a brood |
+| Hazel | no | has a brood |
+| Clover | **yes** | no leader, no brood |
+| Bramble | **yes** | no leader, no brood |
+| Rufus | no | has a leader (Clover) |
+| the 7 ducklings | no | `kind !== 'duckling'` |
+
+The earlier comment said "nobody to keep station on", which reads as *who
+follows you*, though the test is `leader` β€” who you follow. It now says
+"follows no one", which is what the code means. Clover's drake still keeps
+station on **her**; she is the one flying.
+
+### 4.4 The Clover–Rufus wrinkle
+
+Because `cue()` copies the leader's activity unless the leader is feeding,
+straying or begging (`src/pond.ts:785-786`), and `fly` is not on that list,
+Clover taking off **cues Rufus into `fly`** β€” an activity he can never
+perform, because `freeWing` excludes him.
+
+He recovers on the next frame: `case 'fly': if (!d.flying) this.nextActivity(d)`
+(`src/pond.ts:706-709`) re-picks him a fresh activity. So the visible effect is
+that Rufus's activity label reads `fly` for a frame or two, and β€” more
+substantively β€” **Rufus re-rolls his whole decision the moment his mate takes
+off and again when she lands** (`landFlight` calls `nextActivity`,
+`src/pond.ts:469`). A mated drake therefore re-plans his day on his mate's
+flight schedule rather than on his own.
+
+**Fixed.** `cue()` now treats `fly` like `feed`, `stray` and `beg`: a follower
+whose leader takes off is mapped to `swim`, so Rufus never wears an activity he
+cannot perform. The alternative β€” making `freeWing` test "does anything follow
+me" β€” was rejected because it would ground Clover, the one bird whose
+independent flight is part of the pond's character.
+
+Rufus still keeps station on Clover while she is away and picks up her
+post-flight activity when she lands. That is the follower rule doing its job,
+not an impossible `fly` re-roll.
+
+### 4.5 Flying for bread
+
+`src/pond.ts:536-560` β€” a free duck that sees a piece of bread 15 m or more
+away may take the wing for it, the chance rising linearly to 0.55 at 42 m. It
+comes down in the water 1.4–2.6 m off the piece on a random bearing and
+paddles the last of the way, so it never lands on its own mouthful, and only
+one bird flies for a piece β€” the rest swim, which is what keeps a far throw
+from emptying the pond into the air.
+
+### 4.6 The flight changes the picture rules
+
+A duck on the wing is not in the water, so it is not "in frame"
+(`src/pond.ts:162-164` requires `inPond` and not on the lid), and the lap's
+wedge test skips the winter case (`src/pond.ts:352`). The flight is
+therefore the one behaviour that can take a bird out of the picture on
+purpose β€” and `FLIGHT.far` = 40 m of the thrower is the constraint that keeps
+it readable.
+
+---
+
+## 5. Part D β€” Changed by the winter
+
+The Rust has **no seasons, no ice, no snow and no year** β€” a grep for
+`season|winter|ice|snow` across `duck-pond/src/` returns nothing but
+incidental matches inside `voice` and `f64`. duck-pond is a timeless
+breeding-season pond.
+
+quack's year is four keyframe seasons, 7 minutes each, 28 minutes round
+(`src/seasons.ts:43-114`, `src/config.ts:119`). The **only** variable that
+reaches the behaviour model is `world.ice` (`src/world.ts:495-498`); `snow`,
+`wind`, `leaf`, `flower` and the light are consumed by the paint and the
+particle systems alone. The winter test is `world.ice > WORLD.ice.season`
+(0.3), and autumn's `ice: 0.08` never reaches it.
+
+**So the behaviour model has exactly two regimes: open water, and freeze.**
+There is no behavioural difference between spring, summer and autumn.
+
+### 5.1 The twenty seasonal rules
+
+| # | rule | summer | winter | where |
+| --- | --- | --- | --- | --- |
+| 1 | where the flock lives | the depth bands | **the lead** | `src/pond.ts:237-255` |
+| 2 | roam target | anywhere in the ellipse | inside `0.95 Γ— r0` of the lead | `src/pond.ts:280-303` |
+| 3 | minimum roam distance from self | 14 m | 5 m | `src/pond.ts:281` |
+| 4 | lap radius | 4.5–9.5 m | 3–5.5 m | `src/pond.ts:333-336` |
+| 5 | lap / flight chance multiplier | 1 | **0.55** | `src/pond.ts:900` |
+| 6 | flight reach | 16–34 m | 5–14 m (a hop over the lead) | `src/pond.ts:969-970` |
+| 7 | a bid to dabble | stays a dabble | **half become loafing** | `src/pond.ts:960` |
+| 8 | a swim bout bound for the lid | β€” | 4.5 % of them | `src/pond.ts:238-252` |
+| 9 | a spell ashore | 35–90 s | 15–45 s on the lid | `src/pond.ts:155-159` |
+| 10 | the water pulls at a bout end | 0.75 | 0.85 | `src/pond.ts:910` |
+| 11 | a visit ashore | 0.35, or 0.5 for a hen with young | both halved | `src/pond.ts:997-999` |
+| 12 | frozen water | swimmable | **not** swimmable β€” the duck walks on it | `src/pond.ts:133-135` |
+| 13 | the ice | β€” | a legal place to stand | `src/pond.ts:147` |
+| 14 | a duck on the lid | β€” | out of the picture | `src/pond.ts:162-164` |
+| 15 | a dabbler on the lid | dabbles | **grazes** β€” pecks at the ice | `src/duck.ts:892-897` |
+| 16 | upending | allowed in water over 0.8 m | never on the lid | `src/duck.ts:903` |
+| 17 | a flight landing | a splash | a puff of snow | `src/pond.ts:463-468` |
+| 18 | a nibble | a ripple | dust, and no water stirred | `src/pond.ts:1085-1086` |
+| 19 | bread landing | a splash | dust | `src/pond.ts:1531-1536` |
+| 20 | bread on the lid | floats, bobs, rings | grounded, still, silent | `src/pond.ts:1542-1563` |
+
+### 5.2 The lead
+
+`WORLD.lead` = centre (βˆ’3.5, 29), r0 = 8.5 m, r1 = 11.5 m, rim wobbled by
+Β±1.85 m in two integer harmonics (`src/config.ts:180-183`,
+`src/world.ts:25-32`, `465-477`). The ice closes on it from every edge; the
+lead itself never closes.
+
+In a freeze the depth bands are **abandoned entirely**: `pondPoint` sends the
+flock to a disc of radius `0.9 Γ— r0` = 7.65 m around the lead, in water
+deeper than 0.8 m and not frozen (`src/pond.ts:240-255`). The bands and the
+camera's wedge no longer describe where the flock may be.
+
+### 5.3 The ice is a third medium, not a texture
+
+`src/pond.ts:132-148` β€” a duck may be in the water deep enough to paddle, in
+the shallows (0.05–0.3 m, too shallow to paddle and shallow enough to walk
+through), or on the lid of the ice. The **steering is identical in all
+three**; only the allowed region and the footing change. The same waddle
+solve runs on the ice; the duck simply stands at the water level instead of
+on the swell (`src/duck.ts:1351-1362`).
+
+`Pond.stranded()` (`src/pond.ts:1381-1396`) is the safety net: a duck standing
+where nothing allows it any more β€” the freeze closed around it, the thaw took
+the lid from under it β€” is walked out to the nearest legal spot, because
+steering cannot free a duck that has nowhere to steer.
+
+### 5.4 The half-dabble rule, and why it is right
+
+```ts
+// src/pond.ts:957-960
+// a freeze leaves a dabble little to find under the surface: half the
+// bids to dabble become loafing, so the flock rests and preens its way
+// through the cold instead of poking at the same few inches of water
+if (this.world.ice > WORLD.ice.season && next === 'dabble' && this.rng.chance(FLOCK.winterDabbleRest)) next = 'rest';
+```
+
+This is the best single line of behaviour in the port. Mallards do not feed
+the same way all year β€” winter foraging is a different, much more
+opportunistic business β€” and this is the one place quack says so.
+
+---
+
+## 6. Part E β€” the "fly/winter" reckoning
+
+Two things are true at once, and it is worth separating them.
+
+**The Rust is right about its own world.** duck-pond is a breeding-season
+pond: three hens, two drakes, two broods, and no time axis. A timeless pond
+with a breeding flock is a coherent thing to be. Nothing is wrong with it.
+
+**quack inherited that cast into a world where it stops being right.** quack
+has a year. And in a year, the flock that duck-pond models is a flock that
+should not exist in January.
+
+### 6.1 What quack's winter gets right
+
+The *behaviour* of quack's winter is better than the Rust's absence of
+winter, and in places better than a naive reading of the Rust:
+
+- **Gathering at open water.** The lead is exactly what wintering mallards
+  do: they concentrate on the last open water. The Rust has no analogue
+  because it has no winter.
+- **Walking on ice and snow.** Real, and modelled down to the swell being
+  pinned on the lid while the open pool still answers the clock
+  (`src/world.ts:453-463`).
+- **Feeding less.** The half-dabble rule (Β§5.4) is ecologically sound.
+- **Shorter spells on the ice, and more of a pull back to the water**
+  (`SHORE.iceStay` 15–45 s against `SHORE.stay` 35–90 s; `returnFromIce` 0.85
+  against `returnToWater` 0.75).
+- **Boldness.** The curious visitor begging at nothing is a *wintering*
+  behaviour β€” mallards at urban and park ponds learn to approach people, and
+  they are bolder in winter than in the breeding season. quack has this
+  behaviour all year, but it belongs to winter.
+
+### 6.2 What quack's winter gets wrong
+
+| # | the problem | where |
+| --- | --- | --- |
+| 1 | **The flock never changes.** The same twelve birds are on the pond in January and in July. | `src/pond.ts:76` β€” `buildFlock()` is called once and never again |
+| 2 | **Seven ducklings in January.** Pip, Dot, Bean, Fig, Nib, Tuft and Sprout are hatched in spring; by winter they are adults, or gone. The TS has seven ducklings standing on the ice with their mothers. | `src/pond.ts:105-117` |
+| 3 | **No migration at all.** A grep for `migrat` across `src/` finds two hits and both are *negations* β€” `// excursion rather than a migration out to the far water` (`src/pond.ts:989`) and *"not a migration"* (`docs/port-notes.md:266`). | β€” |
+| 4 | **No plumage change.** A drake in January wears eclipse plumage β€” drab mottled brown, not the green head. The TS's palettes are static constants (`src/duck.ts:250-336`); a drake in January looks exactly like a drake in June. | `src/duck.ts:250-294` |
+| 5 | **No departures.** In reality the brood goes south, or is shot, or simply does not make it. Nothing in quack ever leaves except by flying a lap and coming back. | β€” |
+| 6 | **The flock is a breeding flock, not a wintering flock.** Wintering mallards flock loosely and in numbers; a hen with a brood of four is a spring bird. The pair-and-brood structure is a breeding-season structure. | `src/pond.ts:83-118` |
+
+### 6.3 The options, honestly
+
+This is a design decision, not a bug, and there are three defensible answers.
+
+1. **Keep the cast and own it.** The pond is one year in a timeless place: the
+   same twelve birds, always. The `docs/port-notes.md:266` line β€” *"a visit is
+   one to three bouts β€” not instant, and not a migration"* β€” is already
+   half this argument. It should be said more plainly somewhere, because a
+   viewer who watches a year will notice that the ducklings never grow up.
+
+2. **Make the year a year.** Ducklings hatch in spring and are fledged by
+   autumn; in winter the flock is a different flock. This is the real answer
+   ecologically, and it is a large change: the cast has to become dynamic, the
+   plumage has to change with the season, and the breeding-brood structure
+   has to dissolve into a wintering flock.
+
+3. **Drop the winter, or drop the year.** If the cast is to stay fixed, the
+   ice is the thing that makes it visibly wrong, because ice is what says
+   "winter" to a viewer. The lead, the lid, the snow β€” the whole of Part D
+   would go, and with it quack's best seasonal behaviour.
+
+Of the three, option 1 is the chosen answer: quack is one year in a timeless
+place, with the same twelve birds throughout. That sentence now lives in
+`docs/port-notes.md`, where the winter notes can point at it. Options 2 and 3
+are both larger than they look.
+
+### 6.4 The smaller fly/winter point
+
+Flight and winter are entangled in one place: **`WINTER.bouts` = 0.55** scales
+both `FLIGHT.chance` and `LAP.chance` (`src/pond.ts:900, 916, 931`). The
+flock flies and swims about less in the cold, and the reason is the same for
+both β€” there is less to do and less to go for.
+
+That is the right way round, and it is worth noting that the Rust has no such
+coupling, because the Rust has no winter and no flight to couple.
+
+---
+
+## 7. Appendix A β€” the constant map
+
+### Ported with the same value
+
+| Rust | quack | value |
+| --- | --- | --- |
+| `HULL_SPEED` | `FLOCK.hullSpeed` | 0.71 m/s |
+| `DUCKLING_HULL_SPEED` | `FLOCK.ducklingHullSpeed` | 0.43 m/s |
+| `DUCKLING_BURST` | `FLOCK.ducklingBurst` | 1.26 m/s |
+| `BROOD_PACE` | `FLOCK.broodPace` | 0.3 m/s |
+| `LOST` | `FLOCK.lost` | 4.0 m |
+| `STRAY_EVERY` | `FLOCK.strayEvery` | 900 s |
+| swim bout | `FLOCK.swimBout` | 180 s |
+| dabble / rest bout | `FLOCK.bout.long` | 60–240 s |
+| preen bout | `FLOCK.bout.preen` | 30–120 s |
+| the mirror chance | `FLOCK.mirrorChance` | 0.35 |
+| the band re-roll | `FLOCK.bandChange` | 0.3 |
+| the arrive threshold | `FLOCK.arrive` | 0.7 m |
+| the cue divergences | hard-coded in `cue()` | 0.7 / 0.5 / 0.3 |
+| the call intervals | `CALLS.interval` | 0.5–8 s first; 0.2–180 s between calls |
+| the answer chain | `CALLS.answer` | mate 0.6, others `0.3Β·(1 βˆ’ dist/40)`, within 40 m |
+| the transition table | hard-coded in `nextActivity()` | 0.65/0.85, 0.45/0.75, 0.4/0.75, 0.5/0.7 |
+| cruise speeds | `src/duck.ts:733` | 0.45 / 0.5 / 0.33 m/s |
+| the line behind the mother | `src/pond.ts:859-862` | `βˆ’0.8 βˆ’ 0.55Β·rank`, lateral Γ—0.25 |
+| the drake's shove | `src/pond.ts:840-844` | +0.6 m along his own bearing |
+| the straggler threshold | `src/pond.ts:1193-1196` | 3.5 m, speed 0 |
+| the stray bout | `src/pond.ts:869-879` | 15–40 s, 5–9 m from the mother |
+| the lost hysteresis | `src/pond.ts:642-648` | 1.5 m, and Γ—2.5 while feeding |
+| the four-step shore slide | `src/pond.ts:1317-1344` | full / x / y / tangential |
+
+### Changed
+
+| Rust | quack | why |
+| --- | --- | --- |
+| 12–20 peas per throw | one crust per throw, 2–3 bites | a hand, not a machine |
+| peas only *heard* | heard **and** seen (Β§3.7) | a bill and a line of sight |
+| the pre-pea activity is lost | **restored** (Β§2.6) | the port fixed the original |
+| one region for the water | three media | the ice |
+| no flight | a whole flight system | the 3-D rig |
+| no year | four seasons | the year |
+| personal space by radius | hulls and gaps | body size matters |
+| one RNG stream | a master plus one per duck | per-duck jitter and poses |
+| `StandardNormal` for the water | value noise for the terrain | the terrain, not the audio |
+
+### Dropped
+
+| Rust | quack | note |
+| --- | --- | --- |
+| the synthesis, propagation and spatialization | silent `CallEvent`s on `Pond.onCall` | Β§2.1; the behaviour half is ported, the sound is the next pass |
+| the dabbling splash rhythm | β€” | the visual dabble does the work; a later audio hook can follow the calls |
+| the pea plop and bank tap | the bread's splash and dust | the effects table |
+| `PERSONAL_SPACE` from the listener | β€” | the camera replaced the listener's radius |
+| the water bed, drips, reverb | β€” | audio, out of scope |
+
+---
+
+## 8. Appendix B β€” where to look
+
+| concern | quack | duck-pond |
+| --- | --- | --- |
+| the activity set | `src/duck.ts:15` | `scene.rs:153-167` |
+| the roster | `src/pond.ts:83-118` | `scene.rs:1296-1327` |
+| the frame loop | `src/pond.ts:1606-1623` | `scene.rs:787-838` |
+| thinking | `src/pond.ts:611-772` | `scene.rs:850-946` |
+| the cue rule | `src/pond.ts:775-789` | `scene.rs:1006-1021` |
+| station keeping | `src/pond.ts:803-880` | `scene.rs:965-1000` |
+| the next activity | `src/pond.ts:884-1025` | `scene.rs:1042-1107` |
+| steering | `src/pond.ts:1187-1379` | `scene.rs:1109-1184` |
+| separation | `src/pond.ts:1097-1183` | `scene.rs:1121-1128` |
+| the calling model | `src/pond.ts` (`tickCalls`, `call`), `src/config.ts` (`CALLS`) | `scene.rs:1189-1292` |
+| the poses | `src/duck.ts:875-929` | β€” |
+| the waddle | `src/duck.ts:153-168, 1296-1324` | β€” |
+| flight | `src/pond.ts:427-500` | β€” |
+| the ice and the lead | `src/world.ts:465-498` | β€” |
+| the season keys | `src/seasons.ts:43-114` | β€” |
+| every dial | `src/config.ts` | `scene.rs:313-343` |
+| the RNG | `src/utils.ts:56-93` | `main.rs:139-142` |
+| the working porting record | `docs/port-notes.md` | `duck-pond/exploration.md` |
diff --git a/docs/deploy.md b/docs/deploy.md
new file mode 100644
index 0000000..e631507
--- /dev/null
+++ b/docs/deploy.md
@@ -0,0 +1,113 @@
+# Deploying quack
+
+quack is a static Vite build: `npm run build` writes `dist/`, and Cloudflare
+serves that directory as a Workers static-assets site. No server, no bindings.
+
+## What goes up
+
+`dist/` and nothing else:
+
+```
+index.html          entry point, loading card and title-card styles
+assets/index-*.js   the bundled pond (content-hashed by Vite)
+assets/index-*.js.map  its source map (production builds always write one)
+pond-loader-*.jpg   the loading card's opening-pond stills (from public/)
+quack-source.zip    the fifth title card's source download (generated)
+```
+
+`node_modules/`, `temp/`, local credential files and the Rust study clone never
+leave the machine. `dist/` is git-ignored; it is rebuilt from the working tree.
+The source *does* ship: `npm run build` writes `public/quack-source.zip` (and
+copies it into `dist/`) for the fifth title card, and the source maps expose the
+bundled modules. If `SOURCE.src_url` in `src/config.ts` is filled in, the card
+opens that URL instead; the zip is still generated.
+
+## One-time setup
+
+Node 20.19+ or 22.12+ (what Vite needs), then:
+
+```sh
+npm install
+npm exec --yes wrangler@latest -- login
+```
+
+`login` opens the browser once and stores an OAuth token. Check who Wrangler is
+going to deploy as:
+
+```sh
+npm exec --yes wrangler@latest -- whoami
+```
+
+If a `CLOUDFLARE_API_TOKEN` is already exported, Wrangler picks it up instead and
+`login` is not needed.
+
+## Deploying an update
+
+```sh
+npm run build
+npm exec --yes wrangler@latest -- deploy ./dist --name quack --compatibility-date 2026-10-19
+```
+
+Wrangler reads `dist/`, uploads the assets that changed, and prints the live URL
+and a version ID. The URL does not change between deployments.
+
+- `--name quack` is required for a static-assets deploy; there is no
+  `wrangler.toml` in this repo, by design.
+- `--compatibility-date` is the Workers runtime date stamped on the deploy. Bump
+  it when you want newer runtime behaviour; the date above is the first
+  deployment's.
+- The JS bundle and its source map are content-hashed by Vite, so `index.html`
+  always names the right build. The two loading JPEGs and `quack-source.zip`
+  keep fixed names; the zip is regenerated by every `npm run build`. Rename any
+  of them (and update `index.html` or `src/hints.ts`) if you need to defeat a
+  long cache.
+
+## Rolling back
+
+```sh
+npm exec --yes wrangler@latest -- deployments list --name quack
+npm exec --yes wrangler@latest -- rollback <version-id> --name quack
+```
+
+The list prints the version ID to pass to `rollback`.
+
+## Temporary / no-account preview
+
+For a throwaway URL without deploying under an account:
+
+```sh
+npm run build
+npm exec --yes wrangler@latest -- deploy ./dist --name quack --temporary --compatibility-date 2026-10-19
+```
+
+That only works while Wrangler is logged out β€” it refuses `--temporary` when an
+account is connected. It returns a `workers.dev` URL plus a claim URL; the
+deployment is removed after 60 minutes unless claimed. For this project, prefer
+the normal deploy above.
+
+## Checking a deploy
+
+Open the URL Wrangler printed on a phone as well as a desktop β€” the pond is
+mobile-first. Workers assets can take a few seconds to propagate; a 404
+immediately after a deploy usually means: wait and reload.
+
+## Re-shooting the loading card
+
+The card's backdrop is a still of the opening pond, so it is stale whenever the
+pond's look changes:
+
+```sh
+npm i --no-save sharp        # only this script needs it
+npm run dev                  # note the URL it prints
+node scripts/loader-shot.mjs <dev-url> public/pond-loader-portrait.jpg 430 932 2 480 50
+node scripts/loader-shot.mjs <dev-url> public/pond-loader-landscape.jpg 1440 900 1 880 50
+```
+
+The script pauses the world, hides the flock and the particles, and writes a
+progressive JPEG. Raw PNGs stay in `temp/loader/`. Rebuild and redeploy after.
+
+## Notes
+
+- `.wrangler/` is Wrangler's local cache (git-ignored); deleting it is safe.
+- `wrangler deploy` also accepts a single `.html` file, but this project wants
+  the folder so the hashed JS and the card images go up together.
diff --git a/docs/port-notes.md b/docs/port-notes.md
new file mode 100644
index 0000000..b2dd3d0
--- /dev/null
+++ b/docs/port-notes.md
@@ -0,0 +1,620 @@
+# Port notes
+
+Working notes for quack, the web port of celphase's duck-pond. The line-by-line
+study of the Rust project lives in `duck-pond/exploration.md`, beside the
+vendored clone (both kept out of this repo). This file is about *this* project:
+what was ported, what changed, and the conventions the code relies on.
+
+[`behavior-divergence.md`](behavior-divergence.md) is the balance sheet: what
+came across from the Rust model, what has **not** been ported yet, what quack
+invented, and where the behaviour differs because quack has flight and winter
+and the Rust has neither.
+
+## What came from where
+
+| in quack | source | lives in |
+| --- | --- | --- |
+| Activity model, time budget, families, swimming speeds | duck-pond `scene.rs` | `src/pond.ts` |
+| Calling model: intervals, sleeping suppression, hen lottery, social answers, brood recall | duck-pond `scene.rs` | `src/pond.ts`, `src/config.ts` (`CALLS`) |
+| Duck rig: body, wings, bill, eyes, poses | our own bird world's `bird.js` | `src/duck.ts` |
+| Flight: the glide, the climb-off beat and the landing flare, and the flap phase under them | our own bird world's `bird.js` air states | `src/duck.ts` |
+| Season-keyframe idea (re-paced: 7 minutes a season) | our own bird world's `seasons.js` | `src/seasons.ts` |
+| Bread, begging, the sight cone, flying to a far crust, the curious visitor, cursor gaze, the camera, pacing | new in quack | `src/pond.ts`, `src/main.ts`, `src/camera.ts` |
+| The lead: a hole the freeze never closes, and the rules that keep a duck from being caught by the ice | our own bird world's `js/world.js`, `js/bird.js` | `src/world.ts`, `src/pond.ts` |
+
+The research citations behind the upstream constants live in
+`duck-pond/ATTRIBUTION.md`.
+
+Deliberately not ported for v1: the whole audio stack (synthesis, propagation,
+spatialization). The calling model itself is now ported as behaviour: the
+intervals, lottery, one-hop answer chain and brood recall run in `src/pond.ts`,
+tuned by `CALLS`. A call is a silent head/bill gesture, and every call also
+leaves a `CallEvent` on `Pond.onCall` for the audio core that will follow; a
+lost duckling's "peep peep" and a content duckling's "pip-pip" are separate
+call kinds now, not a random chirp.
+
+## The flock
+
+Twelve mallards, built in `src/pond.ts`:
+
+- Hens: **Mabel** (brood: Pip, Dot, Bean, Fig), **Clover**, **Hazel**
+  (brood: Nib, Tuft, Sprout).
+- Drakes: **Rufus** (Clover's mate; trails her), **Bramble** (lone).
+- Ducklings follow their mother; drakes follow their mate.
+
+Activities: `swim | dabble | rest | preen | stray | feed | beg | fly`, each
+running sub-actions (`idle`, `look`, `sleep`, `stretch`, `preen`, `peck`,
+`drink`, `dabble`, `upend`, `chirp`, `beg`). `feed`, `beg` and `fly` are ours;
+the rest carry over duck-pond's time budget (~40 % feeding, ~30 % resting,
+~15 % preening). A duck on the wing is flown by the pond along a straight line
+and posed by the rig; only an unencumbered duck ever flies β€” a hen with a brood,
+or a duckling, keeps to the water.
+
+Calls are scheduled beside the activities, not inside them: `CALLS.interval`
+sets the next one by occasion (lost young, lost duckling, feeding, a drake's
+mate out of reach, dabbling, everything else), a resting duck skips 80–90 % of
+them, and a hen's series or decrescendo can set one hop of answers going.
+Ducklings peep when lost and pip-pip otherwise; a hen gathering her brood or
+answering lost young gives the `kwek-kwek` brood call and cues stray ducklings
+back. The audio synth is still absent β€” see the note above and
+`behavior-divergence.md` Β§2.1.
+
+## Pond geometry
+
+| piece | value |
+| --- | --- |
+| pond ellipse (`WORLD.pond`: cx, cz, rx, rz) | 0, βˆ’22, 46, 64 |
+| beach strip (`WORLD.bank`) | x ≀ 26, z 41.5–52.5 |
+| thrower's stand (`WORLD.thrower`, what the flock reacts to) | (0, 47) |
+| viewer's stand (`WORLD.viewStand`, the camera's eye) | (0, 48.3); the water in front of it is kept clear of reeds |
+| depth bands (`WORLD.bands`, world z) | 22–38 / 0–22 / βˆ’38–4 |
+| chase region for bread (`WORLD.chase`) | x Β±120, z βˆ’120…43.5, and any water a duck can be in β€” open, shallow or frozen |
+| walkable shore (`WORLD.shore`) | x Β±130 on the waterline, 3 m (edge) to 21 m inland |
+
+The near land is a **bank**, not a meadow that happens to be near the water: south
+of `shorelineZ` the ground climbs clear of the water (0.7 m at the water's edge to
+about 3 m, 90 m inland) and stays clear, so the camera always stands on dry ground
+and the water's edge is a clean line rather than a patch of land afloat in the
+shallows. That climb is steep β€” most of it inside 2.5 m β€” so the mesh has to
+resolve it: at 6.94 m steps the corners straddle the bank's foot, the interpolated
+surface floats above the ground, and the *drawn* water's edge lands 2.7 m out into
+the water, stranding the reeds and any duck the field puts in the shallows. At
+3.5 m steps the drawn edge lands within 0.1 m of the field's. `WORLD.shore` +
+`shorePoint` / `shorePointAt` / `shoreNormal` / `onShore` describe the walkable
+stretch of that bank, ready for later interaction.
+
+## Tuned constants
+
+Every dial lives in `src/config.ts`, one export per scope: `APP`, `INPUT`,
+`LOADER`, `HINTS`, `CAMERA`, `SEASONS`, `WORLD`, `FLOCK`, `CALLS`, `SHORE`,
+`WINTER`, `LAP`, `FLIGHT`, `BREAD`, `SIGHT`, `CURIOUS`, `SPACE`, `DUCK`,
+`EFFECTS`. A module imports only the groups it spends, and each field carries the reasoning
+that used to sit beside the code. Two things deliberately stay where they were
+written: the season colour keys in `src/seasons.ts` and the species data in
+`src/duck.ts` (read as whole pieces of art), and the rig's pose curves in
+`src/duck.ts` (a shape, not a number).
+
+The table gives the tuned value; `config.ts` is the source of truth. The last
+column is the module that spends it.
+
+| knob | value | spent in |
+| --- | --- | --- |
+| `SEASONS.seconds` | 420 (7 min; a year = 28 min) | `src/seasons.ts` |
+| `APP.openSeason` | the year opens in summer | `src/main.ts` |
+| winter ice | season key `ice` 1: the pond locks solid, but for the lead | `src/seasons.ts` |
+| `WORLD.lead` | centre βˆ’3.5, 29; r0 8.5, r1 11.5 m; rim wobbled Β±1.85 m | `src/world.ts` |
+| `WORLD.ice.walk` | 0.5 β€” how thick the lid must be to carry a duck | `src/world.ts` |
+| `WORLD.ice.still` | 0.3 β€” how frozen a spot must be for the swell under it to be pinned: the lid is perfectly still, and only the open pool still answers the clock (the water shader repeats it) | `src/world.ts` |
+| `WORLD.water` | wade 0.05 / swim 0.3 / deep 0.8 m: the shallows are walked, deeper water paddled, and 0.8 m is enough to upend | `src/pond.ts` |
+| winter goals | inside the lead (within 0.9 Γ— r0); the odd swim bout (`WINTER.loaf` 0.045) bound for the lid's rim instead | `src/pond.ts` |
+| a visit ashore | gated on a swim bout starting within `SHORE.gate` 26 m of the thrower: `SHORE.visit` 0.35, `SHORE.broodVisit` 0.5 for a hen with ducklings, both halved once the pond is iced over (`SHORE.winterVisit`) | `src/pond.ts` |
+| a spell ashore | `SHORE.stay` 35–90 s on the bank, `SHORE.iceStay` 15–45 s on the lid; the water pulls at each bout end β€” `SHORE.returnToWater` 0.75, `SHORE.returnFromIce` 0.85 | `src/pond.ts` |
+| the lap of the lake | `LAP.chance` 0.14 of the bout ends of an in-frame duck in the water β€” a wandering round drawn fresh through the bird's position each time: radius `LAP.radius` 4.5–9.5 m (`LAP.winterRadius` 3–5.5 m in the lead), a random way round, and a two-harmonic wobble (`LAP.wobble` 0.15, `LAP.wobble2` 0.1) bowing the path in and out by up to 25% of the radius; `LAP.turns` 0.55–1.5 turns a bout, `LAP.out` 0.3 of them swinging out of the picture; a duck out of the picture is sent back at `FLOCK.returnToView` 0.8, its pastime cut to `FLOCK.outViewBout` 50 s | `src/pond.ts` |
+| the flight | `FLIGHT.chance` 0.18 of the bout ends of an unencumbered, in-frame duck (Γ— `WINTER.bouts` 0.55 in the freeze), plus any far piece of bread (below); a low pass `FLIGHT.reach` 16–34 m, `FLIGHT.peak` 0.8–1.45 m Γ— body scale up, `FLIGHT.time` 4.2–8.5 s, the landing inside `FLIGHT.far` 40 m of the thrower; `FLIGHT.cool` 20–45 s after each landing | `src/pond.ts` |
+| hull speed, adult | `FLOCK.hullSpeed` 0.71 m/s | `src/pond.ts` |
+| hull speed / burst, duckling | `FLOCK.ducklingHullSpeed` 0.43 / `FLOCK.ducklingBurst` 1.26 m/s | `src/pond.ts` |
+| brood pace (hen with ducklings) | `FLOCK.broodPace` 0.3 m/s | `src/pond.ts` |
+| cruise speeds (hen / drake / duckling) | 0.45 / 0.5 / 0.33 m/s (per kind, in the rig) | `src/duck.ts` |
+| lost duckling | `FLOCK.lost` β€” stops beyond 4 m from mother | `src/pond.ts` |
+| stray mean interval | `FLOCK.strayEvery` 900 s | `src/pond.ts` |
+| calls | `CALLS.first` 0.5–8 s before the first; then `CALLS.interval.lostYoung` 1.5–3, `.lost` 0.2–1, `.ducklingFeed` 1.5–5, `.duckling` 4–15, `.feed` 2.5–6, `.drakeAway` 8–20, `.drake` 45–180, `.dabble` 15–60, `.other` 20–90 s, in that precedence; a resting duck skips `CALLS.sleepSkip` 0.9 (adult) / 0.8 (duckling) | `src/pond.ts` |
+| answers | a social call reaches 40 m: mate p = `CALLS.answer.mate` 0.6, others `CALLS.answer.other` 0.3 Γ— (1 βˆ’ dist/40); answer due in `CALLS.answerDelay` 0.8–2.5 s, one hop | `src/pond.ts` |
+| brood recall | a lost duckling cuts its mother's timer to `CALLS.motherDelay` 0.5–2.5 s; her brood call runs `CALLS.broodNotes.lost` 6–10 notes (gathering: 3–6) and cues stray ducklings | `src/pond.ts` |
+| `INPUT.tapSlop` | 12 px | `src/main.ts` |
+| cursor gaze limits | `DUCK.look.yawMax` 1.55, `DUCK.look.pitchMax` 0.72 rad | `src/duck.ts` |
+| gaze shaping | `DUCK.look.sweep` 0.9, `DUCK.look.tilt` 1.1 | `src/pond.ts` |
+| bread flight time | `BREAD.time`: clamp(0.45 + dist * 0.04, 0.6, 1.9) s | `src/pond.ts` |
+| bites per piece | `BREAD.bites`: 2 or 3 (50/50) | `src/pond.ts` |
+| bread lifetime | `BREAD.lifetime` 100 s, then it sinks | `src/pond.ts` |
+| begging time | `BREAD.begTime` 25–40 s at the landing spot | `src/pond.ts` |
+| seeing the bread | a duck head-up with the landing spot within `SIGHT.range` 55 m and inside `SIGHT.half` 1.15 rad of its bill starts for the splash at 25–70% of the piece's flight; everyone else only hears the splash and comes late | `src/pond.ts` |
+| flying for bread | a free duck with a piece `FLIGHT.food.near` 15 m or more away, rising to `FLIGHT.food.max` 0.55 at `FLIGHT.food.far` 42 m; it comes down `FLIGHT.food.gap` 1.4–2.6 m off the piece on open water, one bird to a piece | `src/pond.ts` |
+| the curious duck | `CURIOUS.chance` 0.08 of bout ends for a duck in the water within `CURIOUS.gate` 46 m of the stand with no bread down; one at a time, `CURIOUS.every` 150–420 s apart, `CURIOUS.time` 25–60 s of begging after the swim over | `src/pond.ts` |
+| floating bread rings | `BREAD.ring.every` 1.4–4.2 s, `BREAD.ring.scale` 0.2–0.3 (2–3Γ— the piece), in open water only β€” a piece out on the lid lies still | `src/pond.ts` |
+| bread scatter | a tap within `BREAD.scatter.crowded` 0.55 m of floating bread is skewed `BREAD.scatter.near`–`BREAD.scatter.far` 0.24–0.6 m off the cursor, re-rolled until it clears every piece by `BREAD.scatter.gap` 0.24 m | `src/pond.ts` |
+| personal space | hull `SPACE.hullLen` 0.31 m long Γ— `SPACE.hullBeam` 0.18 m beam, per unit of body scale; gap `SPACE.familyGap` 0.03 m inside a family, `SPACE.strangerGap` 0.10 m between strangers | `src/pond.ts` |
+| the waddle | one hinge per leg at the hip; a step covers `2Β·DUCK.legs.lengthΒ·sin(DUCK.legs.swing)` β‰ˆ 0.14 m Γ— body scale over `DUCK.legs.stance` 0.62 of the cycle, so the cadence falls out of the speed; swing sole lifts `DUCK.legs.lift` 0.022, and the body rides ~0.015 m Γ— body scale through a cycle | `src/duck.ts` |
+| the sleeping sit | a duck asleep on solid ground folds its hips to `DUCK.sit.tuck` 1.55 rad and settles `DUCK.sit.drop` 0.04 rig units Γ— body scale, until the belly is 7–9 mm Γ— body scale *into* the shore or lid; eases at 2.6/s | `src/duck.ts` |
+| breathing | `DUCK.breath.amount` 1.4% at `DUCK.breath.rate` 3.2 rad/s, on the torso mesh alone β€” head, bill, wings, tail and legs keep their size | `src/duck.ts` |
+| avoidance field | a soft nudge from `SPACE.reach` 1.3Γ— the hull distance out, up to `SPACE.push` 0.28 m/s | `src/pond.ts` |
+| camera | first person: the eye planted at `WORLD.viewStand`, `CAMERA.eyeHeight` 2 m above the ground; `CAMERA.yawLimit` Β±0.85, pitch `CAMERA.pitchMin`…`CAMERA.pitchMax` βˆ’1.0…0.45, resting at `CAMERA.pitchStart` βˆ’0.06 | `src/camera.ts` |
+| camera zoom | `CAMERA.fov`: 10–78Β°, opens at 52Β°; wheel and pinch ask for equal *ratios* per pixel, and the ease runs in log space. The floor holds a slice of horizon rather than a number of degrees: 10Β° vertical on a 16:9 screen (`CAMERA.fov.refAspect`), opening up on narrower ones (`fovFloor(aspect)`) so a phone's portrait frame always shows the same width of pond at full lean-in | `src/camera.ts` |
+| shore bank floor | `WORLD.bankFloor` 0.7 m at the edge β†’ 3.0 m at 90 m inland | `src/world.ts` |
+| walkable shore | `WORLD.shore`: Β±130 m along the waterline; edge 3 m, depth 18 m | `src/world.ts` |
+| terrain mesh | `WORLD.segments` 432 (3.5 m) over `WORLD.size` 1500 m; the slope is read off the grid, not re-sampled | `src/world.ts` |
+| ripples | `EFFECTS.ripple`: lift 0.035, life 1.3–1.8 s, growth 0.4 + 2.4 t, alpha 0.55, 24 rings | `src/effects.ts` |
+| particle pools | `EFFECTS.capacity`: 180 feathers, 320 droplets, 220 leaves, 300 snowflakes; `EFFECTS.weather` holds the shedding rates | `src/effects.ts` |
+| tone mapping | ACES, `APP.exposure` 0.98 | `src/main.ts` |
+| default seed | `APP.seed` 20261018 (`?seed=N`) | `src/main.ts` |
+
+## Conventions
+
+- Units are meters and seconds; y-up; a duck faces +z before posing.
+- **Head pitch sign**: positive X tips a +z-facing part (the bill) down β€” for an
+  unyawed bill. In the sleep pose the head is yawed 2.85 rad (~163Β°), so the
+  sign flips and a *negative* pitch is what lowers the bill onto the back.
+- **Sleep pose**: the head is drawn right around over a shoulder with the bill
+  trailed back along the flank. Tangency of the jaw's underside with the
+  torso/wing shell sits near βˆ’0.18 rad, but flush is the wrong answer β€” the tip
+  disappears into the body volume from most angles, the player's included. The
+  code holds the bill at **+0.06 rad**: still lying on the feathers, top edge
+  clear of the body line, so the tip reads from either flank. Checked on hen,
+  drake and duckling, close up and from the stand.
+- **Sleeping is sitting**: a duck that dozes off on solid ground folds its legs
+  up under itself and settles onto its belly; dozing upright on straight legs
+  read as standing with the head tucked, which is a horse, not a duck. The
+  trigger is `action === 'sleep'` while on land or the lid and still; the hips
+  fold to `DUCK.sit.tuck` (1.55 rad, the swimming tuck) and the standing height
+  drops by `DUCK.sit.drop` (0.04 rig units Γ— body scale), a little *more* than
+  it takes to bring the belly down to the surface β€” the body presses a feather's
+  depth into the shore or ice instead of touching it tangentially. Tangency
+  reads as hovering, and the ice would draw the contact as a hard line; sunk in,
+  the belly and the stern are cut by the ground plane and the bird sits in it.
+  The idle bob fades out with `sit` so the settled body does not breathe itself
+  clear of the ground, and the whole settle eases at 2.6/s, so lying down and
+  getting up are movements. Sleeping afloat is untouched: a loafing duck already
+  has its legs at 1.55 and rides the swell. (Measured at drake scale 1.46: the
+  body drops 5.8 cm, the belly's lowest point crosses the ice by ~7 mm, the
+  folded feet ride 13 cm up inside the belly and never break its silhouette.)
+- **Breathing is the body's alone**: the idle breath is a scale on the torso
+  mesh, not on the root. Scaling the whole rig made the head, bill and feet
+  pulse along with the chest, and quietly changed the legs' length every breath
+  β€” so a planted sole crept a millimetre or two while the duck stood still.
+  Now the chest fills and empties and everything else holds its size; the neck
+  is short enough that the 1.4% seam stays buried in the plumage. Measured at
+  drake scale 1.456: torso world scale runs 1.436–1.477, while head, bill,
+  wings, tail and feet all stay at 1.456.
+- **Wing stretch**: both wings come up and wave, one a little higher than the
+  other, and both *stay* up. A floating duck's shoulders sit barely above the
+  water, so a wing swinging down out of the stretch dips straight into it and the
+  pair reads as one broken diagonal; the asymmetric part is therefore added on
+  both sides rather than being a mirror of the flap.
+- **Cursor gaze**: every duck pans and tilts for the pointer across the whole
+  viewport; the easing keeps the middle gentle while the corners reach the full
+  gaze limits. Driven by `pond.setCursor(x, y)` with NDC coordinates.
+- **Tap vs drag**: no time limit β€” press as long as you like, release to throw.
+  Only a drift past `INPUT.tapSlop` turns a press into a camera drag. Right-button
+  presses and second fingers are ignored. A duck that never sees the throw reacts
+  only once the bread lands; one that does (below) is off while it is still in
+  the air.
+- **Bread**: leaves the hand just below the camera (1.2 m along the throw
+  direction, at least 1 m above the water), arcs to the landing spot, then
+  splashes or kicks up dust. Landing spots are clamped into the chase region so
+  the flock can always reach them, on open water, in the shallows or out on the
+  ice. A piece left floating nudges the surface now
+  and then β€” a small ring, a couple of times the size of the piece β€” so it stays
+  findable without shouting like the splash it landed with.
+- **Seeing the throw**: a duck with its head up has the whole pond in front of it,
+  but no reason to look up β€” unless something arcs over the water. A piece whose
+  landing spot lies within `SIGHT.range` (55 m) and inside `SIGHT.half`
+  (1.15 rad) of the bird's bill is *seen*: that duck sets off for the splash at
+  25–70 % of the piece's flight (`sees`, `maybeFlyToFood`), so it is already
+  moving β€” and sometimes already eating β€” by the time the ring spreads. The rest
+  only hear the splash, the near ones first (`T/12` per metre), so the flock
+  comes in waves, and the birds facing the other way or asleep always come late.
+  A duck on the wing keeps its invitation until it is down, so a flight for one
+  piece is never cut short by another. Measured (20 min, a throw every 110 s):
+  22 ducks set off for a piece before it touched down, against 101 that came
+  after the splash.
+- **Rings ride the swell**: every ripple β€” splash, wake, nibble, bread nudge β€”
+  is pinned to `waterHeightAt(x, z, time)` for its whole life, never to the
+  height it was spawned at. The swell runs Β±0.3 m here, so a ring left at a
+  fixed height hangs in the air beside its duck the moment the surface drops
+  away from it; a wake spawned at the duck's own waterline would otherwise start
+  6 cm *under* the surface. `Effects.update` stores the world and clock so
+  `ripple()` and the pool can re-seat every ring each frame.
+- **Repeated taps**: a landing spot within `BREAD.scatter.crowded` (0.55 m) of
+  bread already there is skewed off the cursor β€” a random bearing,
+  `BREAD.scatter.near`–`BREAD.scatter.far` (0.24–0.6 m) out β€” and re-rolled
+  until it clears every piece by `BREAD.scatter.gap` (0.24 m, a piece being a
+  0.1 m pebble). A few taps on one spot then make a little scatter of crusts to
+  pick at, close together but never intersecting. The skew stands only if the
+  ducks can still reach the spot.
+- **Winter, and the lead**: the cold comes in from every edge, but it never
+  closes one patch of water β€” `WORLD.lead` (centre βˆ’3.5, 29; rim 8.5 to
+  11.5 m out), set a little left of the middle of the gaze the camera opens on.
+  `iceAt(x, z)` is how frozen a spot is: 0 inside the lead, 1 far from it, ramped
+  across the rim, and the rim is wobbled by `leadWobble(angle)` in integer
+  harmonics so it closes on itself without a seam. The water shader repeats both
+  formulas from the same constants (passed as uniforms), so the sheet that is
+  drawn and the sheet the rules know about agree to the metre. Its swell is
+  pinned as soon as the sheet starts to skin over β€” `WORLD.ice.still` 0.3, well
+  short of the `WORLD.ice.walk` 0.5 that lets a duck stand on it β€” and the
+  fragment shader repeats the same pinning, so the whole lid is perfectly
+  still: on screen, and in `waterHeightAt`, which is what a duck standing on
+  it, and a ring that lands on it, both ride. Only the open pool still answers
+  the clock. Measured at full ice: all 78 sampled lid points had zero swell
+  amplitude across 24 s of clock, and the ice read 0 changed pixels in a
+  straight-down render two seconds later, while the pool moved 45 % of its
+  pixels in the same frame (and open water in summer 44 %, the control). Through
+  the winter the lid reaches further in, so the flock is pushed off the
+  shallows, gathers in the lead, and begins to stand about on the ice.
+- **What a duck may be in**: `solidAt` β€” ice at `WORLD.ice.walk` (0.5) or thicker β€”
+  joins the media a duck may occupy. It swims open water, wades the shallows
+  (0.05–0.3 m: too shallow to paddle, shallow enough to walk through), and walks
+  the lid; those are the only rules that changed, and steering is the same in
+  all three. `Pond.stranded()` is the net under all of it: a duck standing where
+  nothing allows it any more β€” the freeze closed around it, the thaw took the lid
+  from under it, a shove left it high and dry β€” is walked out to the nearest spot
+  that is allowed, because steering cannot free a duck that has nowhere to steer,
+  and one that is not freed stands still for good and a hen waiting on her brood
+  waits for ever. Measured through a freeze (30 s of sim once the pond is locked):
+  no duck-frame had a duck standing where the rules forbade it, and none was left
+  stuck β€” five of the twelve paddling in the lead and seven about on the lid, and
+  a crust thrown on the lid is walked to and eaten.
+- **Ice that reads as ice**: the sheet takes the lead's rim as a slush edge that
+  rides the freezing front, then a grained, patched lid β€” snow lying on it in
+  patches and the ice scoured bare between them a shade darker, so that even at
+  the grazing angle the camera opens on the sheet is a surface rather than a
+  mist. It turns properly solid just *past* `WORLD.ice.walk`, so nothing is ever
+  seen paddling on something that already reads as ice, and the open water
+  deepens and cools as the season turns, which is what makes the lead read as the one hole in
+  the lid. (borb's sheet was a single colour-and-alpha mix; the grain, the slush
+  and the patches are ours.)
+- **Bread on ice, and in the shallows**: a crust may land on the lid or in the
+  shallows, and the flock walks out to it β€” nothing is ever re-landed on water
+  they cannot reach. A piece on the lid does not bob and does not ring (it is not
+  floating); it kicks up dust when it lands, like one on the bank.
+- **Ashore is a visit, not a move**: the bank and the winter lid are places to
+  pass the time, never somewhere to go *to*. A duck leaves the water only on a
+  whim of the swim model β€” a swim bout starting within `SHORE.gate` (26 m of the
+  thrower) turns into a visit with `SHORE.visit` 0.35 (`SHORE.broodVisit` 0.5
+  for a hen with ducklings; `SHORE.winterVisit` halves both, because a spell
+  ashore then means a march over the frozen lid) β€” and once it is out, no bout
+  targets another spot out there: a pastime is all the footing model offers, and
+  each bout end walks the duck back to the water, `SHORE.returnToWater` 0.75 on
+  the bank and `SHORE.returnFromIce` 0.85 on the lid. The odd bout takes another
+  pastime instead, so a visit is one to three bouts β€” not instant, and not a
+  migration.
+  Measured in summer (45 min): four adult visits for the flock, 112–226 s each,
+  ~12 % of duck-time out of the water, and most of that one hen's brood on the
+  bank; in winter (20 min Γ— 3 seeds): 7–12 % of duck-time on the lid, over half
+  of it the walk across to the bank, the rest loafing in 15–45 s bouts. No duck
+  ever stood anywhere the rules forbade, and none was left out for long.
+- **One year, one cast**: quack is a single year in a timeless place. The same
+  twelve birds are here in January as in June, and the ducklings never grow up;
+  the seasons turn around them. This is a deliberate simplification, not a
+  migration model, and it is the reason the winter cast can stay fixed.
+- **The picture keeps the flock**: a duck that ends a bout outside the camera's
+  wedge is sent back to its band's water (`FLOCK.returnToView` 0.8), and a
+  pastime already running out there is cut to `FLOCK.outViewBout` 50 s. `dabble`
+  is offered only in water inside the wedge, which was the leak that let a
+  dabbler drift over the line and then hold position out there for a whole bout;
+  a duck already outside keeps its ground (and dabbles where it stands) until
+  the bout ends.
+- **The lap of the lake**: a duck that would settle to the nearest pastime
+  sometimes goes off around the water instead β€” `LAP.chance` 0.14 of the bout
+  ends of an in-frame duck, 0.55 of that in the freeze (`WINTER.bouts`). The
+  round is drawn fresh every time, *through the duck's own position*, so the
+  whole bout is the round and never a swim out to some far-off circle: a random
+  radius (`LAP.radius` 4.5–9.5 m, `LAP.winterRadius` 3–5.5 m in the lead), a
+  random way round, and two radial wobbles (`LAP.wobble` 0.15, `LAP.wobble2`
+  0.1, incommensurate with the circle and with each other) that bow the path in
+  and out by up to a quarter of the radius, so no two laps read the same and
+  none closes into a stiff circle or ellipse. The pond walks the mark along the
+  arc a few metres ahead of the bird (`stepLap`),
+  and a bout swims `LAP.turns` 0.55–1.5 turns of it β€” part of the round, or all
+  the way about β€” before the bird gets its day back. A round that is asked
+  for out of the picture (`LAP.out` 0.3) skips the wedge test, so part of it
+  plays off the edge of the frame and back in. Measured (30 min summer): four
+  laps, radii 4.6–8.9 m, three one way round and one the other, 0.7–1.16 turns
+  each, the path holding the round to 0.91–0.96 of its radius, the net turn
+  matching the lap's own direction every time, all four inside the wedge.
+- **The flight**: now and then a duck takes the short way across on the wing
+  instead of swimming it. `FLIGHT.chance` 0.18 of the bout ends of an
+  unencumbered duck that is inside the picture become a low pass over the water,
+  `FLIGHT.reach` 16–34 m long and `FLIGHT.time` 4.2–8.5 s in the air, riding
+  `FLIGHT.peak` 0.8–1.45 m Γ— body scale over the surface at its peak; the
+  landing spot is re-rolled until it is clear of the ducks already there and
+  within `FLIGHT.far` (40 m of the thrower), where the flock still reads β€” a
+  duck out in the far water flies shorter and tends back towards it.
+  A bird that has just come down stays down for `FLIGHT.cool` 20–45 s, so flights
+  never chain into a shuttle. The pond flies the bird (a straight line, held off
+  and on the water, climb β€” glide β€” settle), and the rig does the wings, from
+  borb's air poses: a hard beat to get up, a long glide with barely a tip moving,
+  then the flare and backbeat that sets it down, with the bill lifted on the
+  climb and again on the flare and the wingbeat carrying the body up and down.
+  Splashes at both ends β€” a puff of snow if it sets down on the lid β€” legs
+  tucked, tail spread. Nothing on the water touches a bird on the wing β€” it is
+  listed for no hull, no ripple wake, no cursor and no unclaimed bread, and
+  steering leaves it alone β€” so it passes over the flock and over the ice with no
+  trouble. Measured (30 min summer, no bread): eight flights from the flock's two
+  eligible birds, 17.8–32.9 m, every one landing on open water, none chained; in
+  winter, three short hops over the lead in 25 min.
+- **Flying for bread**: a crust that comes down across the pond is worth the
+  wing rather than the paddle. A duck that has decided to come for a piece
+  measures it, and if the bird is `FLIGHT.food.near` (15 m) or more away the
+  chance rises with the distance to `FLIGHT.food.max` 0.55 at `FLIGHT.food.far`
+  42 m (`maybeFlyToFood`); it comes down in the water `FLIGHT.food.gap` 1.4–2.6 m
+  off the piece on a random bearing and paddles the last of the way, so it never
+  lands on its own mouthful, and only one bird flies for a piece β€” the rest
+  swim, which is what keeps a far throw from emptying the pond
+  into the air. Only ducks with nothing to look after fly for it (a hen with a
+  brood stays on the water), and a piece out on the bank is left to the walkers. Measured: with a throw every 110 s, six food flights in 20 min at
+  25.7–51.4 m, every one onto water and five of six still going for the bread
+  at touchdown.
+- **The curious duck**: now and then it is the duck that notices the visitor,
+  not the other way round. At a bout end, a duck in the water within
+  `CURIOUS.gate` (46 m of the stand), with no bread down, may take it into its
+  head to come over: `CURIOUS.chance` 0.08, and the flock keeps at least
+  `CURIOUS.every` 150–420 s between visits, so it reads as one bird's idea
+  rather than a habit. It crosses at a purpose (`hurry` until it is within 4 m
+  of the shallows in front of the stand), then stands about there for
+  `CURIOUS.time` 25–60 s, head up and the odd imploring bill-open, begging at
+  nothing β€” until a piece of bread lands anywhere and the flock's usual business
+  resumes. In a freeze the
+  bank is out of reach, so it comes to the nearest open water instead: the rim of
+  the lead. Measured (25 min winter): two such visits, 66–83 s each, each ending
+  9.3–9.7 m from the stand at the lead's near rim; in summer one bird crossed
+  43 m to beg 5.8 m from the stand.
+- **Personal space**: every duck carries a hull β€” an ellipse half a body long
+  (`SPACE.hullLen`, 0.31 m) and a quarter wide (`SPACE.hullBeam`, 0.18 m), in
+  its own units of body scale, so a drake is a larger obstacle than a duckling β€”
+  plus a little water between hulls: `SPACE.familyGap` (0.03 m) for a mother and
+  her brood, a pair of mates or siblings, `SPACE.strangerGap` (0.10 m) between
+  strangers. It is a nudge first: steering eases every duck off the space its
+  neighbours hold, from `SPACE.reach` 1.3Γ— the hull distance out (up to
+  `SPACE.push` 0.28 m/s), which is what keeps ordinary meetings from turning
+  into contact. Anything that does touch is settled after everyone has moved:
+  `Pond.separate()` measures every pair against
+  that hull distance and shares out any overlap, then takes the closing speed out
+  of the pair, so the next frame cannot drive them straight back in. A duck that
+  cannot give ground (a bank at its back) hands its share to the other; a
+  sleeper or an upended duck takes a quarter share and is given no new velocity
+  at all, so nothing can set it adrift or swing it about. Two ducks therefore
+  meet nose to nose further apart than they meet flank to flank, and a scrum on
+  one crust shoulders up like real dabbling ducks instead of intersecting.
+- **Anchored poses**: a duck in `sleep` or `upend` takes no steering input at
+  all β€” no wander, no drift on the spot, and no slow turn as its mark shifts.
+  Only a neighbour's crowd-avoidance push can move it (a quarter of the parting
+  push above, and never any new velocity). A duck standing on its
+  bill is planted in the mud; if it kept paddling after a mark it would drag the
+  pose off the spot it chose.
+- **The waddle**: a duck on land hinges each leg exactly once, at the hip, and
+  keeps the step on the ground. Each leg is a hip group (`hipL`/`hipR`), an
+  ankle group and a foot mesh, so the sole can be held flat while the shank
+  swings; both sides share one shank and one foot geometry, mirrored by the
+  hinge's own position. Through the stance (`p < DUCK.legs.stance`, 0.62 of a
+  cycle) the hinge angle is the arcsine of a linear ramp β€”
+  `asin(sin(DUCK.legs.swing)Β·(2p/DUCK.legs.stance βˆ’ 1))` β€” which is exactly the
+  angle that holds a foot of length `DUCK.legs.length` (0.15) on the spot it
+  landed on while the body crosses the ground over it: the planted sole never
+  skates, and it lies flat the whole time, because the ankle cancels the hinge.
+  The swing carries the foot back to the front on a smooth ease, sole lifted
+  clear of the ground (`DUCK.legs.lift`, 0.022, the shank telescoping up into
+  the belly) and toe up. A step covers
+  `2Β·DUCK.legs.lengthΒ·sin(DUCK.legs.swing)`, about 0.14 m per unit of body
+  scale, spread over `DUCK.legs.stance` of the cycle, so the cadence falls
+  straight out of the speed β€”
+  `speedΒ·DUCK.legs.stance/(2Β·DUCK.legs.lengthΒ·sin(DUCK.legs.swing)Β·scale)`,
+  near 1.9 cycles/s at a hen's cruise, quicker for a duckling's shorter legs
+  because the
+  step in metres shrinks with it. The body rides the leg that is on the
+  ground β€” the one hanging lowest of them β€” at exactly that leg's vertical
+  reach (`legReach`), so the planted sole holds its height as well as its
+  spot, and the body rides back up out of the lean, which presses the outside
+  foot into the ground by as much again; what is left over is the step's own
+  weight: high over the straight planted leg, low as the feet spread. Over
+  that it leans onto the planted foot (`DUCK.waddle.roll` 0.13 rad) and the
+  stern answers once a step (`DUCK.waddle.wag`). The hips answer the lean in
+  turn, so the legs hang plumb and the body rocks *over* the planted foot
+  rather than swinging the foot through an arc β€” which is what holds the sole
+  still
+  sideways as well as fore-and-aft. A duck without a step's worth of weight
+  in it β€” standing, floating, or swimming with the legs tucked β€” damps
+  `stride` to 0, so the legs ease back under the body and a stop never
+  freezes mid-stride. Measured on the ice with `scripts/gait.mjs` (a hen at
+  body scale 1.46, walking 0.45 m/s): the planted sole holds its spot β€” no
+  forward travel at all over a stance, and sideways under 2% of the ground the
+  body covers β€” and its height to within a centimetre, most of that the body's
+  own breathing. The swing sole clears by up to 4.7 cm, the lean runs Β±5Β°, and
+  the body rides 1.4 cm per unit of body scale through a cycle. Cadence came
+  out at 1.32 cycles/s against 1.32 predicted, sweeping 100% of the ground it
+  crossed.
+- **Bottom feeding (`upend`)**: the whole body tips forward about the waterline
+  pivot β€” the duck's own `root` origin, ~9 cm under the surface, roughly the
+  body's centre, which is where a real upender turns. `DUCK.upend.pitch` is
+  1.62 rad (a little past vertical) reached over a 0.8 s ramp, held with the feet
+  paddling and the stern wagging, then rocked back in 0.75 s. The `dabble`
+  activity offers it only to a duck that is afloat β€” never out on the lid β€” and
+  only where `depthAt > DUCK.upend.depth` (0.8 m), so the bill really is in the
+  mud; ducklings never upend. Rings and droplets fire at both ends of the move.
+  Measured: the head goes ~0.24 m under the surface (visible through the
+  transparent water) and the tail ~0.47 m above it.
+- **Camera**: first person, planted on the shore. Drag turns yaw (right = turn
+  right) and pitch (down = the gaze drops); pinch and wheel change the field of
+  view. The eye never moves, so the pond keeps its place and only the gaze
+  swings across it. Reeds are kept clear of the water directly in front of the
+  stand (`WORLD.viewStand`), so no stem parks itself in the eye.
+  Zooming has no centre of its own β€” it narrows the frame around wherever the
+  gaze rests β€” so the gaze rests a touch below level (`CAMERA.pitchStart`,
+  βˆ’0.06 rad): that is the line a full lean-in keeps, and it frames the whole
+  flock from the near families to the far band.
+
+## The loading card
+
+The pond is held behind a card for its first moment: the view it opens on, shot
+without a duck in it and thrown out of focus, with "quack ... quack" coming up a
+letter at a time over it. When the world is up, the card fades away and the live
+pond is left behind, framed the same way.
+
+- **The face**: `public/pond-loader-portrait.jpg` and `-landscape.jpg`, shot by
+  `scripts/loader-shot.mjs` from the camera's opening pose (summer, default
+  seed, `app.paused`), with the flock and the particle pools hidden. Progressive
+  JPEG (mozjpeg, 4:2:0, q50) at 480 and 880 px wide β€” 3.9 and 5.4 kB, so the
+  card has its picture before the module is down.
+- **The wash**: `filter: blur(17px)`, with the image stretched to the element
+  (`100% 100%`) rather than cover-cropped. Under that blur the stretch never
+  shows, and it keeps the far shore on the same line of the screen as the live
+  camera's at any aspect ratio β€” card 43.0–43.4 % down, live 44.0 %, measured on
+  a phone and a 4:3 tablet β€” so the handover has no jump to hide.
+- **The word**: five spans β€” word, three dots, word β€” each easing out of a 6 px
+  blur and *fully in* before the next starts (word 0.38 s, dot 0.2 s, 0.05 s
+  between; 1.61 s end to end), so the line reads in five distinct steps rather
+  than as one smear.
+- **The handover**: `hideLoadingSoon()` keeps the card to `LOADER.min` = 1950 ms
+  from navigation β€” enough for the last word even on a fast load, with a beat on
+  the finished line β€” then `.done` fades it over `LOADER.fade` 900 ms and removes
+  it `LOADER.removeDelay` 400 ms later. The class also drops `pointer-events`,
+  so the pond can be dragged while the fade is still going.
+  `prefers-reduced-motion` puts the line up at once and fades in 0.3 s.
+
+## The title cards
+
+Five lines the pond cannot show for itself come up low over the water after the
+loading card has gone, one at a time, and leave again: the throw instruction,
+the look instruction, the zoom instruction, the credit to celphase's duck-pond,
+and the source card.
+
+- **One line, one card**: `HINTS.tipHold` 3 s holds for the three tips,
+  `HINTS.creditHold` 7.5 s for the credit and `SOURCE.hold` 6.5 s for the
+  source, `HINTS.fade` 0.6 s fades (the CSS runs 0.55 s), `HINTS.gap` 0.5 s of
+  quiet water between. The first starts 1.9 s after the loading card begins to
+  fade β€” with a normal boot that is about four seconds after navigation, so the
+  pond is revealed first and the tips come second.
+- **The source card**: `SOURCE.src_url` in `src/config.ts` is the public
+  repository. When it is a non-empty string the card opens that URL in a new
+  tab; when it is empty or null the card instead offers
+  `public/quack-source.zip`, a download of this working tree. The zip is built
+  by `scripts/source-zip.mjs` from `src/`, `public/`, `scripts/`, `docs/`,
+  `audio/` and the root project files, and leaves out `node_modules`, `dist`,
+  `temp`, `duck-pond`, `.wrangler` and every local credential file. The build
+  runs it before Vite, so the copy in `dist/` is current. It is generated, not
+  committed.
+- **Source maps**: `vite.config.ts` sets `build.sourcemap: true`, so every
+  production build ships its `.js.map` beside the bundle. That keeps the live
+  site debuggable and lets anyone compare the deployed code with the source zip
+  module by module.
+- **Device wording**: `(hover: none) and (pointer: coarse)` picks "tap to throw
+  bread", "drag to look around" and "pinch to zoom"; a fine pointer gets
+  "click", "drag" and "scroll to zoom". The wording follows the *primary* input,
+  so a touchscreen laptop still gets mouse words. The controls are described as
+  they are: drag turns the gaze, pinch or wheel changes the field of view.
+- **Hardly there**: an 0.33-alpha panel with a 9 px backdrop blur, a 1 px border
+  and a 13 px radius β€” legible over bright water without turning into a HUD. The
+  container is `pointer-events: none`; only the links re-enable the pointer, so
+  taps and drags pass through the card to the pond everywhere but the link
+  itself.
+- **Stays for the link**: on hover-capable devices the card does not leave while
+  the pointer is on its link, so the credit or source can be read or followed at
+  leisure. `prefers-reduced-motion` collapses the fades to 0.01 s; the cards
+  still come and go.
+
+## Debugging
+
+- `window.quack = { app, world, seasons, effects, pond, rig, camera, renderer, scene }`
+  is set once the pond is up; `app.paused`, `app.timeScale` and `seasons.hold(i)`
+  are the quick levers. `world.ice`, `world.iceAt(x, z)` and `world.solidAt(x, z)`
+  answer what the freeze is doing where.
+- `app.step(raw = 1/60)` advances the world by exactly `raw` seconds whatever
+  the clock is doing β€” pause and `timeScale` are ignored β€” and renders, so a
+  probe can hold `app.paused = true` and walk the sim in fixed slices without
+  the frame rate colouring the answer. `scripts/gait.mjs` uses it to film and
+  measure the waddle; each duck carries its two feet at `d.feet`
+  (`[footL, footR]`, index 0 is the +x side) for a probe to watch, and
+  `d.stride`, `d.walkPhase`, `d.roll` and `d.vel` say what the gait is doing,
+  and `d.sit` how far a sleeping duck has settled onto its belly.
+- Holding winter: `seasons.hold(3)` glides there in about a second. To catch the
+  freeze part way, hold it with `app.timeScale = 1` and poll `world.ice` on a
+  timer, pausing the app as it passes the value you want β€” 0.4 is a good
+  mid-freeze, lid advancing and the lead still wide. `world.waterHeightAt(0, 45, t)`
+  is the check that the shore has stopped moving: every `t` must give the same
+  number, or the lid at the edge of a frozen pond is still heaving; the same
+  check on screen is two renders of the lid two seconds of clock apart with every
+  mesh but the water hidden β€” 0 differing pixels, while the pool in the same
+  frame moves.
+- `pond.force(name, activity, left)` pins a duck; `pond.throwBread(x, z, ...)`
+  lands a piece wherever you like; `pond.setCursor(nx, ny)` / `setCursor(null)`
+  drives the gaze (works headless).
+- Forcing a flight: `d.setActivity('fly')` then
+  `pond.beginFlight(d, { x, z })` (the target needs open water) β€” the pond flies
+  it from where it stands while the rig poses it. `d.flying`, `d.flyH` (height
+  over the water), `d.flyPhase` (0–1 through the pass) and `d.flyPitch` say what
+  it is doing; `d.flight` is the plan itself. Walking `app.step` through a pass
+  at 0.05 s slices gives the glide and the landing for a shot. `d.flyCool` is
+  the seconds left before that bird will take the wing again.
+- Forcing a lap: `pond.startLap(d, true)` (the flag is the wedge test) returns
+  whether it found a round, and `d.lap` holds it (`x`, `z`, `r`, `a`, `dir`,
+  `wob`, `left`). The path is measured from the plan, not the frame: sample
+  `hypot(d.pos.x - d.lap.x, d.pos.z - d.lap.z) / d.lap.r` each second (it holds
+  ~0.9–1.1), and add up the signed heading change around `d.facing` β€” the net
+  turn must have the sign of `d.lap.dir` and about its size (turns Γ— 2Ο€).
+- Bread in the air: `pond.sees(d, x, z)` is the sight cone (awake, within
+  `SIGHT.range`, inside `SIGHT.half` of the bill); `d.notice` is the seconds
+  until that duck sets off, so a probe can read the early reaction before the
+  splash. `pond.maybeFlyToFood(d)` makes the fly-or-swim roll for the nearest
+  unclaimed piece; `piece.claimed` says who is coming for it.
+- Curiosity: `pond.curious` is the seconds until the flock may notice the visitor
+  again; `pond.begPoint(d)` is where that duck would come to, and 0 if there is
+  no water it can reach in front of the stand. `pond.force(name, 'beg', left)`
+  poses a visit for a shot.
+- Overlap checks: `pond.contact(a, b, ux, uz)` is the centre distance at which
+  two hulls touch β€” reach it through a unit vector and compare with the real
+  gap, so a probe can report the worst clearance in a crowd rather than trusting
+  a screenshot. `pond.separate()` can be called by hand to settle a planted
+  crowd before a shot.
+- Shore: `world.shore` is the walkable section; `world.shorePoint(x, across)`
+  returns a point on it (`across` 0 = water's edge, 1 = inland edge),
+  `world.shorePointAt(t)` marches it west to east, `world.onShore(x, z)` tests a
+  spot and `world.shoreNormal(x)` gives the inland direction.
+- Screenshots: `node scripts/shot.mjs <url> <out> [w] [h] [waitMs] ["js"]` β€”
+  the optional JS string runs in the page first (to pose the camera or season).
+  Output goes under `temp/`, which is git-ignored.
+- The loading card is plain HTML/CSS in `index.html`, up before any module is:
+  `document.getAnimations()` lists its five span animations, so a probe can
+  pause them and seek a frame (`a.currentTime = ms`). `scripts/loader-shot.mjs
+  <url> <out> [w] [h] [dpr] [outWidth] [q]` re-shoots its backdrop from the
+  pond's opening pose and writes a progressive JPEG (needs
+  `npm i --no-save sharp`; the raw PNG lands in `temp/`). Hide the flock
+  (`d.root.visible = false`), the particle pools and the ripples first β€” the
+  card is the pond with no bird in it.
+- Reading a shot back as data: `node scripts/sample.mjs <png> [x0..1]` prints the
+  colour down that column, sampling every 2.5 % of the height. Colours on the way
+  down a column settle questions a glance cannot ("is that band the bank or the
+  shallow water?"): compare the shot against the same frame with
+  `world.water.visible = false`.
+- Forcing a sleep pose: `m.setActivity('rest')`, `m.activityLeft = 600`, wait
+  ~0.5 s, then `m.startAction('sleep', 600)`, wait ~2.6 s. `m._sleepSide = 1`
+  pins which way the head turns. A duckling needs the pair re-asserted every
+  ~100 ms β€” family glue replaces the action otherwise.
+- Freezing an action mid-pose: re-assert `d.startAction('upend', 4.5)` and
+  `d.actionTime = d.actionDuration - 1.6` every ~16 ms to hold it in the middle
+  of the move, and pin `pos`/`target`/`facing` in the same tick β€” `force()`
+  clears the action, and a drifting duck is a moving frame. `app.timeScale = 6`
+  fits a whole upend cycle into the shot window.
+- Camera: the eye is planted, so pose it directly β€” `rig.look(yaw, pitch, fov)`
+  aims it at once with no easing, `rig.stand(x, z)` puts the stand down
+  somewhere else (it re-reads the ground), then `app.paused = true` freezes the
+  scene for the shot.
+- TypeScript 7 quirk: `instanceof` does not narrow `unknown` values from
+  `import * as THREE`; use explicit casts.
+
+## Open threads
+
+- **Night**: the seasons already carry the sun across the year; a proper
+  day/night cycle within a season is the next step.
+- **Audio**: the calling model is now ported and emits `CallEvent`s on
+  `Pond.onCall`; the next pass is the synth (the Rust's `synth.rs`), propagation
+  and spatialization. The dabbling splash rhythm and bill clack can hang off
+  the same event layer when it is built; the visual `chirp`/`beg` gestures stay
+  as the fallback for muted playback.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..320c5ec
--- /dev/null
+++ b/index.html
@@ -0,0 +1,260 @@
+<!doctype html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
+		<meta name="theme-color" content="#0c1620" />
+		<meta name="mobile-web-app-capable" content="yes" />
+		<meta name="apple-mobile-web-app-capable" content="yes" />
+		<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+		<!-- the drake in the tab: the svg is the master, the pngs are rendered
+		     from it by scripts/duck-icon.mjs -->
+		<link rel="icon" type="image/png" sizes="16x16" href="/duck-16.png" />
+		<link rel="icon" type="image/png" sizes="32x32" href="/duck-32.png" />
+		<link rel="icon" type="image/svg+xml" href="/duck.svg" />
+		<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+		<title>Ententeich</title>
+		<!-- the pond's own face, blurred behind the word: the one the card opens
+		     on, shot without a duck in it (scripts/loader-shot.mjs) -->
+		<link rel="preload" as="image" href="/pond-loader-portrait.jpg" media="(max-aspect-ratio: 1/1)" />
+		<link rel="preload" as="image" href="/pond-loader-landscape.jpg" media="(min-aspect-ratio: 1/1)" />
+		<style>
+			:root {
+				color-scheme: dark;
+			}
+			* {
+				margin: 0;
+				padding: 0;
+				box-sizing: border-box;
+			}
+			html,
+			body {
+				height: 100%;
+				overflow: hidden;
+				background: #0c1620;
+				overscroll-behavior: none;
+			}
+			#app {
+				position: fixed;
+				inset: 0;
+			}
+			#app canvas {
+				display: block;
+				width: 100%;
+				height: 100%;
+				touch-action: none;
+				-webkit-user-select: none;
+				user-select: none;
+				-webkit-tap-highlight-color: transparent;
+			}
+			/* the card: the pond it opens on, thrown out of focus behind the word,
+			   and gone into the live pond the moment that is ready */
+			#loading {
+				position: fixed;
+				inset: 0;
+				display: grid;
+				place-items: center;
+				overflow: hidden;
+				background: #0c1620;
+				transition: opacity 0.9s ease;
+				z-index: 2;
+			}
+			#loading.done {
+				opacity: 0;
+				pointer-events: none;
+			}
+			#loading.done .loading__line {
+				opacity: 0;
+				transform: translateY(-5px);
+			}
+			.loading__photo {
+				position: absolute;
+				/* over the edges, so the blur's soft border stays off screen */
+				inset: -7%;
+				background: url('/pond-loader-portrait.jpg') center / 100% 100% no-repeat;
+				/* the wash is stretched to the viewport rather than cover-cropped:
+				   under this much blur nobody can tell, and it keeps the far shore
+				   on the same line of the screen as the live camera's, at any
+				   aspect ratio, so the two meet cleanly when the card goes */
+				filter: blur(17px) saturate(1.04);
+			}
+			@media (min-aspect-ratio: 1/1) {
+				.loading__photo {
+					background-image: url('/pond-loader-landscape.jpg');
+				}
+			}
+			.loading__tint {
+				position: absolute;
+				inset: 0;
+				background:
+					radial-gradient(145% 105% at 50% 42%, rgba(8, 20, 30, 0) 28%, rgba(8, 20, 30, 0.4) 100%),
+					linear-gradient(180deg, rgba(8, 20, 30, 0.26), rgba(8, 20, 30, 0.08) 38%, rgba(8, 20, 30, 0.3));
+			}
+			.loading__line {
+				position: relative;
+				display: flex;
+				align-items: baseline;
+				gap: 0.46em;
+				color: #f4f9fd;
+				font: 500 clamp(21px, 5.6vw, 30px) / 1.2 ui-rounded, system-ui, -apple-system, "Segoe UI", sans-serif;
+				letter-spacing: 0.13em;
+				text-shadow: 0 2px 18px rgba(6, 16, 24, 0.55), 0 0 3px rgba(6, 16, 24, 0.4);
+				transition: opacity 0.55s ease, transform 0.9s ease;
+			}
+			/* "quack ... quack", one piece at a time β€” the word, then the three
+			   dots, then the word again. A piece is fully blended in before the
+			   next starts: word 0.38 s, dot 0.2 s, 0.05 s between.
+			   0.05-0.43 Β· 0.48-0.68 Β· 0.73-0.93 Β· 0.98-1.18 Β· 1.23-1.61 */
+			.loading__line .word,
+			.loading__dots span {
+				opacity: 0;
+				animation: blend-in 0.38s ease-out forwards;
+			}
+			.loading__line .word:first-child {
+				animation-delay: 0.05s;
+			}
+			.loading__dots {
+				display: inline-flex;
+				gap: 0.07em;
+			}
+			.loading__dots span {
+				animation-duration: 0.2s;
+			}
+			.loading__dots span:nth-child(1) {
+				animation-delay: 0.48s;
+			}
+			.loading__dots span:nth-child(2) {
+				animation-delay: 0.73s;
+			}
+			.loading__dots span:nth-child(3) {
+				animation-delay: 0.98s;
+			}
+			.loading__line .word:last-child {
+				animation-delay: 1.23s;
+			}
+			@keyframes blend-in {
+				from {
+					opacity: 0;
+					filter: blur(6px);
+				}
+				65% {
+					filter: blur(1px);
+				}
+				to {
+					opacity: 1;
+					filter: blur(0);
+				}
+			}
+			@media (prefers-reduced-motion: reduce) {
+				#loading {
+					transition-duration: 0.3s;
+				}
+				.loading__line .word,
+				.loading__dots span {
+					animation: none;
+					opacity: 1;
+				}
+			}
+			/* the title cards: one line low over the water, hardly a surface at
+			   all β€” the word is the thing, and the pond stays visible through */
+			#hints {
+				position: fixed;
+				left: 0;
+				right: 0;
+				bottom: calc(18px + env(safe-area-inset-bottom, 0px));
+				z-index: 1;
+				padding: 0 12px;
+				pointer-events: none;
+				text-align: center;
+			}
+			.hint {
+				display: inline-block;
+				max-width: min(92vw, 30rem);
+				margin: 0;
+				padding: 0.58rem 0.95rem 0.62rem;
+				border: 1px solid rgba(232, 246, 255, 0.16);
+				border-radius: 13px;
+				background: rgba(8, 18, 26, 0.33);
+				-webkit-backdrop-filter: blur(9px) saturate(1.06);
+				backdrop-filter: blur(9px) saturate(1.06);
+				color: rgba(242, 249, 253, 0.95);
+				font: 500 clamp(13px, 3.5vw, 15px) / 1.35 ui-rounded, system-ui, -apple-system, "Segoe UI", sans-serif;
+				letter-spacing: 0.022em;
+				text-wrap: pretty;
+				text-shadow: 0 1px 10px rgba(0, 8, 14, 0.4);
+				opacity: 0;
+				transform: translateY(7px);
+				transition: opacity 0.55s ease, transform 0.55s ease;
+			}
+			.hint.on {
+				opacity: 1;
+				transform: translateY(0);
+			}
+			.hint a {
+				color: inherit;
+				text-decoration: underline;
+				text-decoration-color: rgba(242, 249, 253, 0.42);
+				text-decoration-thickness: 1px;
+				text-underline-offset: 0.18em;
+				pointer-events: auto;
+				cursor: pointer;
+			}
+			.hint a:hover {
+				text-decoration-color: currentColor;
+			}
+			@media (prefers-reduced-motion: reduce) {
+				.hint {
+					transition-duration: 0.01s;
+				}
+			}
+			.visually-hidden {
+				position: absolute;
+				width: 1px;
+				height: 1px;
+				margin: -1px;
+				overflow: hidden;
+				clip-path: inset(50%);
+				white-space: nowrap;
+			}
+			#error {
+				position: fixed;
+				inset: auto 12px 12px 12px;
+				display: none;
+				padding: 10px 14px;
+				border-radius: 10px;
+				background: rgba(120, 30, 30, 0.85);
+				color: #ffe8e8;
+				font: 500 13px/1.4 system-ui, sans-serif;
+				z-index: 3;
+			}
+			#error.show {
+				display: block;
+			}
+		</style>
+	</head>
+	<body>
+		<div id="app"></div>
+		<div id="loading" role="status" aria-live="polite">
+			<div class="loading__photo" aria-hidden="true"></div>
+			<div class="loading__tint" aria-hidden="true"></div>
+			<p class="loading__line" aria-hidden="true">
+				<span class="word">quack</span>
+				<span class="loading__dots"><span>.</span><span>.</span><span>.</span></span>
+				<span class="word">quack</span>
+			</p>
+			<p class="visually-hidden">loading the pond</p>
+		</div>
+		<div id="error"></div>
+		<script type="module" src="/src/main.ts"></script>
+		<script>
+			// If the module never boots (network, syntax, WebGL), surface it.
+			setTimeout(() => {
+				if (!window.__quackStarted) {
+					const el = document.getElementById('error');
+					el.textContent = 'the pond did not start β€” check the console, and make sure WebGL is available';
+					el.classList.add('show');
+				}
+			}, 12000);
+		</script>
+	</body>
+</html>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a749bab
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1598 @@
+{
+  "name": "quack",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "quack",
+      "version": "0.1.0",
+      "license": "MIT",
+      "dependencies": {
+        "three": "^0.186.1"
+      },
+      "devDependencies": {
+        "fflate": "^0.8.3",
+        "puppeteer": "^25.14.0",
+        "typescript": "^7.0.2",
+        "vite": "^8.3.4"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.154.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
+      "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/oxc-project"
+      }
+    },
+    "node_modules/@puppeteer/browsers": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.5.tgz",
+      "integrity": "sha512-UoCo3Pynbm37aLEEoydSiG+t24ui7sLD40+07CKxzxNy4PpSowJMkvlLIiV67XjP1lMVynhQVnuwlTABqBH8LQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "modern-tar": "^0.8.5",
+        "yargs": "^18.0.0"
+      },
+      "bin": {
+        "browsers": "lib/main-cli.js"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      },
+      "peerDependencies": {
+        "proxy-agent": ">=8.0.1",
+        "yauzl": "^2.10.0 || ^3.4.0"
+      },
+      "peerDependenciesMeta": {
+        "proxy-agent": {
+          "optional": true
+        },
+        "yauzl": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm-eabi": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
+      "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
+      "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
+      "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
+      "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
+      "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
+      "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
+      "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
+      "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
+      "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
+      "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
+      "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
+      "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
+      "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
+      "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
+      "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@typescript/typescript-aix-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+      "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+      "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+      "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+      "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+      "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+      "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-loong64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+      "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-mips64el": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+      "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+      "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-riscv64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+      "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-s390x": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+      "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+      "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-sunos-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+      "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+      "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+      "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
+      "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+      "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/chromium-bidi": {
+      "version": "157.0.8090-0",
+      "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
+      "integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "mitt": "^3.0.1",
+        "zod": "^4.6.5"
+      },
+      "engines": {
+        "node": ">=20.19.0 <22.0.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "devtools-protocol": "*"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+      "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^7.2.0",
+        "strip-ansi": "^7.1.0",
+        "wrap-ansi": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/cliui/node_modules/string-width": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+      "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^10.3.0",
+        "get-east-asian-width": "^1.0.0",
+        "strip-ansi": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/devtools-protocol": {
+      "version": "0.0.1696802",
+      "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1696802.tgz",
+      "integrity": "sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg==",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/emoji-regex": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+      "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-east-asian-width": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
+      "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lilconfig": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz",
+      "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antonk52"
+      }
+    },
+    "node_modules/mitt": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/modern-tar": {
+      "version": "0.8.6",
+      "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
+      "integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.20",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+      "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.30",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
+      "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.20",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/puppeteer": {
+      "version": "25.14.0",
+      "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.14.0.tgz",
+      "integrity": "sha512-+s1Iqbl9DsGLOaQdgCQyhtgHZsF9hi4Iu8iuBqxzraxUXoRG1QkYQI0CA1/VsNK1gZWJfs50BL/oOb1L+Yz8Yg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@puppeteer/browsers": "3.2.5",
+        "chromium-bidi": "157.0.8090-0",
+        "devtools-protocol": "0.0.1696802",
+        "lilconfig": "^3.1.3",
+        "puppeteer-core": "25.14.0",
+        "typed-query-selector": "^2.12.3"
+      },
+      "bin": {
+        "puppeteer": "lib/puppeteer/node/cli.js"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      }
+    },
+    "node_modules/puppeteer-core": {
+      "version": "25.14.0",
+      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.14.0.tgz",
+      "integrity": "sha512-LQitnes3tBeVbdZ7V/4iwJPLZGgkl9uBVK0W7YYMh68j/3Y9eNuvj4yl+Fdo7zN0SuajOMrhd7aPj3DifK8k6A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@puppeteer/browsers": "3.2.5",
+        "chromium-bidi": "157.0.8090-0",
+        "devtools-protocol": "0.0.1696802",
+        "typed-query-selector": "^2.12.3",
+        "webdriver-bidi-protocol": "1.0.1",
+        "ws": "^8.22.0"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
+      "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.154.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm-eabi": "1.2.14",
+        "@rolldown/binding-android-arm64": "1.2.14",
+        "@rolldown/binding-darwin-arm64": "1.2.14",
+        "@rolldown/binding-darwin-x64": "1.2.14",
+        "@rolldown/binding-freebsd-x64": "1.2.14",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.14",
+        "@rolldown/binding-linux-arm64-musl": "1.2.14",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.14",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.14",
+        "@rolldown/binding-linux-x64-gnu": "1.2.14",
+        "@rolldown/binding-linux-x64-musl": "1.2.14",
+        "@rolldown/binding-openharmony-arm64": "1.2.14",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.14",
+        "@rolldown/binding-win32-x64-msvc": "1.2.14"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+      "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
+      "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "get-east-asian-width": "^1.5.0",
+        "strip-ansi": "^7.1.2"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+      "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^6.2.2"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/three": {
+      "version": "0.186.1",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
+      "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/typed-query-selector": {
+      "version": "2.12.3",
+      "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
+      "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/typescript": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+      "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc"
+      },
+      "engines": {
+        "node": ">=16.20.0"
+      },
+      "optionalDependencies": {
+        "@typescript/typescript-aix-ppc64": "7.0.2",
+        "@typescript/typescript-darwin-arm64": "7.0.2",
+        "@typescript/typescript-darwin-x64": "7.0.2",
+        "@typescript/typescript-freebsd-arm64": "7.0.2",
+        "@typescript/typescript-freebsd-x64": "7.0.2",
+        "@typescript/typescript-linux-arm": "7.0.2",
+        "@typescript/typescript-linux-arm64": "7.0.2",
+        "@typescript/typescript-linux-loong64": "7.0.2",
+        "@typescript/typescript-linux-mips64el": "7.0.2",
+        "@typescript/typescript-linux-ppc64": "7.0.2",
+        "@typescript/typescript-linux-riscv64": "7.0.2",
+        "@typescript/typescript-linux-s390x": "7.0.2",
+        "@typescript/typescript-linux-x64": "7.0.2",
+        "@typescript/typescript-netbsd-arm64": "7.0.2",
+        "@typescript/typescript-netbsd-x64": "7.0.2",
+        "@typescript/typescript-openbsd-arm64": "7.0.2",
+        "@typescript/typescript-openbsd-x64": "7.0.2",
+        "@typescript/typescript-sunos-x64": "7.0.2",
+        "@typescript/typescript-win32-arm64": "7.0.2",
+        "@typescript/typescript-win32-x64": "7.0.2"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.3.4",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
+      "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.33.0",
+        "picomatch": "^4.0.7",
+        "postcss": "^8.5.29",
+        "rolldown": "~1.2.12",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.7.1",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/webdriver-bidi-protocol": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
+      "integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/wrap-ansi": {
+      "version": "9.0.2",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+      "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^6.2.1",
+        "string-width": "^7.0.0",
+        "strip-ansi": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/string-width": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+      "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^10.3.0",
+        "get-east-asian-width": "^1.0.0",
+        "strip-ansi": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+      "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs": {
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
+      "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^9.0.1",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "string-width": "^8.2.1",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^22.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=23"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "22.0.0",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+      "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=23"
+      }
+    },
+    "node_modules/zod": {
+      "version": "4.6.7",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+      "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0447754
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+  "name": "quack",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "duck pond",
+  "license": "MIT",
+  "scripts": {
+    "dev": "node scripts/source-zip.mjs && vite",
+    "build": "node scripts/source-zip.mjs && vite build",
+    "preview": "vite preview",
+    "source": "node scripts/source-zip.mjs"
+  },
+  "dependencies": {
+    "three": "^0.186.1"
+  },
+  "devDependencies": {
+    "fflate": "^0.8.3",
+    "puppeteer": "^25.14.0",
+    "typescript": "^7.0.2",
+    "vite": "^8.3.4"
+  },
+  "allowScripts": {
+    "[email protected]": true
+  }
+}
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..301b4a8
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/duck-16.png b/public/duck-16.png
new file mode 100644
index 0000000..d403462
Binary files /dev/null and b/public/duck-16.png differ
diff --git a/public/duck-32.png b/public/duck-32.png
new file mode 100644
index 0000000..f5937c5
Binary files /dev/null and b/public/duck-32.png differ
diff --git a/public/duck.svg b/public/duck.svg
new file mode 100644
index 0000000..2b89c69
--- /dev/null
+++ b/public/duck.svg
@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="duck">
+	<defs>
+		<clipPath id="tile">
+			<rect width="64" height="64" rx="16" />
+		</clipPath>
+	</defs>
+	<g clip-path="url(#tile)">
+		<!-- the pond at night, as the tab's ground -->
+		<rect width="64" height="64" fill="#0c1620" />
+		<!-- the drake in profile: head up and to the right, body off the corner.
+		     the neck is drawn along its own axis, so the ring and the breast fall
+		     where a mallard wears them, and the back runs along the top -->
+		<g transform="translate(28 33) rotate(123)">
+			<!-- the green neck -->
+			<rect x="-10" y="-11" width="30" height="22" fill="#276b3e" />
+			<!-- the white ring -->
+			<rect x="14" y="-11" width="6" height="22" fill="#e2ddcc" />
+			<!-- the chestnut breast, on the bird's front -->
+			<rect x="20" y="-14" width="60" height="22" fill="#6e4530" />
+			<!-- the grey-brown back along the top contour -->
+			<rect x="20" y="8" width="60" height="6" fill="#b4b0a0" />
+		</g>
+		<!-- the head -->
+		<ellipse cx="33" cy="23" rx="16" ry="15" fill="#276b3e" />
+		<!-- the bill, broad and blunt -->
+		<path d="M46 16 L57.5 16.5 A4.5 4.5 0 0 1 57.5 25.5 L46 26 Z" fill="#c9b13c" />
+		<!-- the eye, with a catchlight in it -->
+		<circle cx="39" cy="19" r="2.7" fill="#101010" />
+		<circle cx="39.9" cy="18.1" r="0.95" fill="#ffffff" />
+	</g>
+</svg>
diff --git a/public/pond-loader-landscape.jpg b/public/pond-loader-landscape.jpg
new file mode 100644
index 0000000..2445264
Binary files /dev/null and b/public/pond-loader-landscape.jpg differ
diff --git a/public/pond-loader-portrait.jpg b/public/pond-loader-portrait.jpg
new file mode 100644
index 0000000..537502c
Binary files /dev/null and b/public/pond-loader-portrait.jpg differ
diff --git a/scripts/duck-icon.mjs b/scripts/duck-icon.mjs
new file mode 100644
index 0000000..2f919bf
--- /dev/null
+++ b/scripts/duck-icon.mjs
@@ -0,0 +1,42 @@
+// The duck in the tab. public/duck.svg is the master; the PNGs are rendered
+// from it so the two can never drift apart. Usage:
+//   node scripts/duck-icon.mjs
+// Writes public/duck-32.png (the fallback for browsers that will not take an
+// SVG favicon) and public/apple-touch-icon.png (iOS pins the home-screen
+// icon from this one, and it does not like transparency).
+import puppeteer from 'puppeteer';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const svg = readFileSync(resolve('public/duck.svg'), 'utf8');
+
+const targets = [
+	{ size: 16, out: 'public/duck-16.png' },
+	{ size: 32, out: 'public/duck-32.png' },
+	{ size: 180, out: 'public/apple-touch-icon.png' },
+];
+
+const browser = await puppeteer.launch({
+	headless: true,
+	args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+	for (const { size, out } of targets) {
+		const page = await browser.newPage();
+		await page.setViewport({ width: size, height: size, deviceScaleFactor: 1 });
+		await page.setContent(
+			'<!doctype html><meta charset="utf-8">' +
+				'<style>html,body{margin:0;padding:0;background:transparent}' +
+				`svg{display:block;width:${size}px;height:${size}px}</style>` +
+				svg,
+			{ waitUntil: 'load' },
+		);
+		const png = await page.screenshot({ type: 'png', omitBackground: true });
+		writeFileSync(resolve(out), png);
+		console.log(`${out}  ${size}x${size}  ${(png.length / 1024).toFixed(1)} kB`);
+		await page.close();
+	}
+} finally {
+	await browser.close();
+}
diff --git a/scripts/gait.mjs b/scripts/gait.mjs
new file mode 100644
index 0000000..e852ea9
--- /dev/null
+++ b/scripts/gait.mjs
@@ -0,0 +1,591 @@
+// The gait probe: boot the pond headless, put one duck into a clean walk on
+// the winter ice lid, and shoot it side-on as a filmstrip β€” then measure the
+// step cycle from the foot positions it recorded. Usage:
+//   node scripts/gait.mjs [duckIndexOrName] [frames] [outDir]
+//     duckIndexOrName  index into quack.pond.ducks, or a duck's name
+//                      (default: an adult already walking, else the first
+//                      leaderless adult)
+//     frames           filmstrip frames (default 24; more than 30 clamps)
+//     outDir           where the PNGs and report.txt land (default temp/gait)
+//
+// The sim is stepped by hand β€” app.paused = true, app.step(1/120) in a loop β€”
+// so the wall clock and the headless frame rate never enter the measurement.
+// Only the last step of each frame is rendered at full size; the rest run
+// tiny, with the shadow pass off, which keeps the capture to a couple of
+// minutes on swiftshader.
+//
+// The dev server is the one on port 5180 (as our other probes use); point
+// QUACK_URL at another if it is running elsewhere.
+import puppeteer from 'puppeteer';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+const url = process.env.QUACK_URL || 'http://localhost:5180/?seed=20261018';
+const sel = process.argv[2] || '';
+const frames = Math.min(Number(process.argv[3]) || 24, 30);
+const outDir = process.argv[4] || 'temp/gait';
+
+const STEP = 1 / 120; // the sim slice app.step is called with
+const PER_FRAME = 5; // slices per filmstrip frame: 1/24 s of sim each
+const WARM_SECONDS = 1.6; // settle before the camera is pinned
+const PLANT_TOL = 0.006; // m over the ground level that still counts as planted
+const CAM_OUT = 2.4; // m the camera stands off the walk line
+const CAM_EYE = 0.55; // m the eye rides over the lid
+
+// The rig's step geometry, straight out of src/duck.ts (kept in step by hand:
+// a change there wants a change here).
+const LEG_LEN = 0.15; // hip to sole, rig units
+const LEG_SWING = 0.5; // radians the hinge swings either side of centre
+const LEG_STANCE = 0.62; // share of a cycle a foot spends planted
+const CADENCE_CAP = 2.6; // the rig's own ceiling on steps per second
+
+const consoleErrors = [];
+const pageErrors = [];
+let exitCode = 0;
+
+// ---------------------------------------------------------------- statistics
+
+function median(xs) {
+	if (!xs.length) return NaN;
+	const s = [...xs].sort((a, b) => a - b);
+	const m = s.length >> 1;
+	return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
+}
+
+function mean(xs) {
+	return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
+}
+
+const mm = (m) => `${(m * 1000).toFixed(1)} mm`;
+const f2 = (v) => (Number.isFinite(v) ? v.toFixed(2) : 'β€”');
+const deg = (rad) => (rad * 180) / Math.PI;
+
+/** Runs of consecutive frames where `flags[f]` is true, as [first, last] pairs. */
+function runs(flags) {
+	const out = [];
+	let start = -1;
+	for (let f = 0; f <= flags.length; f++) {
+		const on = f < flags.length && flags[f];
+		if (on && start < 0) start = f;
+		else if (!on && start >= 0) {
+			out.push([start, f - 1]);
+			start = -1;
+		}
+	}
+	return out;
+}
+
+/**
+ * How far a foot travels over the ground, against how far the body travels,
+ * between two frames of one interval. `ratio` is the net xz pair (0 pinned,
+ * 1 skating), `path` the walked distance, `fwd` the along-the-walk part and
+ * `lat` the sideways excursion in metres.
+ */
+function ratioOver(a, b, footA, footB) {
+	const dx = b.x - a.x;
+	const dz = b.z - a.z;
+	const body = Math.hypot(dx, dz);
+	if (body < 1e-6) return null;
+	const fx = footB.x - footA.x;
+	const fz = footB.z - footA.z;
+	const ux = dx / body;
+	const uz = dz / body;
+	return {
+		ratio: Math.hypot(fx, fz) / body,
+		fwd: (fx * ux + fz * uz) / body,
+		lat: Math.abs(fx * -uz + fz * ux),
+	};
+}
+
+function pathRatio(frames, a, b, i) {
+	let foot = 0;
+	let body = 0;
+	for (let f = a + 1; f <= b; f++) {
+		const p = frames[f].feet[i];
+		const q = frames[f - 1].feet[i];
+		foot += Math.hypot(p.x - q.x, p.z - q.z);
+		const r = frames[f].root;
+		const s = frames[f - 1].root;
+		body += Math.hypot(r.x - s.x, r.z - s.z);
+	}
+	return body > 1e-6 ? foot / body : null;
+}
+
+// -------------------------------------------------------------------- the tool
+
+const browser = await puppeteer.launch({
+	headless: true,
+	args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+	const page = await browser.newPage();
+	await page.setViewport({ width: 800, height: 600, deviceScaleFactor: 2, isMobile: false, hasTouch: false });
+	page.on('console', (msg) => {
+		const line = `[console.${msg.type()}] ${msg.text()}`;
+		console.log(line);
+		if (msg.type() === 'error') consoleErrors.push(line);
+	});
+	page.on('pageerror', (err) => {
+		const line = `[pageerror] ${err.message}`;
+		console.log(line);
+		pageErrors.push(line);
+	});
+	page.on('requestfailed', (req) => console.log(`[requestfailed] ${req.url()} ${req.failure()?.errorText || ''}`));
+
+	// 1. boot, exactly as scripts/shot.mjs waits for the page
+	await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
+	await page.waitForFunction(() => window.quack && window.quack.app && window.quack.app.started, { timeout: 30000 });
+	await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
+	await page.evaluate(() => document.getElementById('loading')?.remove());
+
+	// 2. winter: the pond freezes to a lid ducks can walk on, and the lid's
+	//    swell is gone where the ice is solid, so the ground line is flat.
+	//    The year glides to the season over sim time, and the sim is about to
+	//    be held, so the glide is burned in one update instead of stepping.
+	const season = await page.evaluate(() => {
+		const q = window.quack;
+		q.seasons.hold(3);
+		q.seasons.update(1.0);
+		q.world.applySeason(q.seasons.state);
+		q.app.paused = true;
+		return { name: q.seasons.label, ice: q.seasons.state.ice, snow: q.seasons.state.snow };
+	});
+
+	// 3. let the freeze settle, then pick the duck, put it on the lid and
+	//    point it down a clear line. The settle matters: a moment after the
+	//    ice closes, ducks that were paddling steer across the lid, so there
+	//    is usually one already walking at pace to take as it stands.
+	const prep = await page.evaluate(({ sel, warm }) => {
+		const q = window.quack;
+		const ducks = q.pond.ducks;
+		const step = 1 / 120;
+		const tiny = () => {
+			q.renderer.shadowMap.enabled = false;
+			q.renderer.setSize(8, 8, false);
+		};
+		const full = () => {
+			q.renderer.setSize(window.innerWidth, window.innerHeight, false);
+			q.renderer.shadowMap.enabled = true;
+		};
+
+		tiny();
+		for (let i = 0; i < 360; i++) q.app.step(step); // 3 s: the flock finds the ice
+
+		let idx = -1;
+		if (sel !== '') {
+			const n = Number(sel);
+			if (Number.isInteger(n) && ducks[n]) idx = n;
+			else idx = ducks.findIndex((d) => d.name.toLowerCase() === sel.toLowerCase());
+			if (idx < 0) {
+				throw new Error(`no duck matches "${sel}" β€” have ` + ducks.map((d, i) => `${i}:${d.name}`).join(', '));
+			}
+		} else {
+			// one already walking at a real pace: a free adult at cruise, or a
+			// mother at the brood pace; a follower only ever strays, so it is out
+			idx = ducks.findIndex(
+				(d) =>
+					d.state === 'walk' &&
+					d.activity === 'swim' &&
+					!d.leader &&
+					d.kind !== 'duckling' &&
+					Math.hypot(d.vel.x, d.vel.z) > 0.25,
+			);
+			if (idx < 0) {
+				// to be placed: a free adult walks at cruise, where a mother or
+				// a follower only ever strays at 0.2 m/s
+				idx = ducks.findIndex(
+					(d) => !d.leader && d.kind !== 'duckling' && !ducks.some((o) => o.leader === d && o.kind === 'duckling'),
+				);
+			}
+			if (idx < 0) idx = ducks.findIndex((d) => !d.leader && d.kind !== 'duckling');
+			if (idx < 0) idx = 0;
+		}
+		const d = ducks[idx];
+
+		// the flat lid: solid, fully frozen (no swell riding on it) and water
+		const lid = (x, z) => q.world.solidAt(x, z) && q.world.iceAt(x, z) >= 0.999 && q.world.isWater(x, z);
+		const runClear = (x, z, dx, dz, len) => {
+			for (let t = 0; t <= len; t += 0.5) if (!lid(x + dx * t, z + dz * t)) return false;
+			return true;
+		};
+
+		// a duck already walking on the lid with room ahead keeps its ground;
+		// anything else is set down on a chosen line, along x, well clear of
+		// the open lead
+		let start = null;
+		let dir = null;
+		let tier = 'placed on the ice';
+		if (d.state === 'walk' && d.activity === 'swim' && Math.hypot(d.vel.x, d.vel.z) > 0.05 && lid(d.pos.x, d.pos.z)) {
+			for (const s of [1, -1]) {
+				if (runClear(d.pos.x, d.pos.z, s, 0, 6)) {
+					start = { x: d.pos.x, z: d.pos.z };
+					dir = { x: s, z: 0 };
+					tier = 'already walking';
+					break;
+				}
+			}
+		}
+		if (!start) {
+			let best = null;
+			for (let z = 4; z <= 26; z += 2) {
+				for (let x = -24; x <= 24; x += 2) {
+					for (const s of [1, -1]) {
+						if (!runClear(x, z, s, 0, 12)) continue;
+						if (!runClear(x, z + s * 2.4, 0, 0, 0.1)) continue; // the camera needs ice under it too
+						let score = Math.min(Math.hypot(x + 3.5, z - 29), 24);
+						for (const o of ducks) if (o !== d && Math.hypot(o.pos.x - x, o.pos.z - z) < 6) score -= 4;
+						if (!best || score > best.score) best = { x, z, s, score };
+					}
+				}
+			}
+			if (!best) throw new Error('no clear run of ice found for a walk');
+			start = { x: best.x, z: best.z };
+			dir = { x: best.s, z: 0 };
+		}
+
+		// what makes it walk: free adults 'swim' at cruise β€” including a mother
+		// already on the move, who holds the brood pace; a follower, or a duck
+		// set down with young in tow, keeps to 'stray' (its own target, 0.2 m/s)
+		const hasYoung = ducks.some((o) => o.leader === d && o.kind === 'duckling');
+		const act = d.leader || (hasYoung && tier !== 'already walking') ? 'stray' : 'swim';
+
+		// a stray duckling that loses its mother stops dead, so she is parked
+		// just behind the line where it can still see her
+		if (d.leader && d.kind === 'duckling') {
+			const mom = d.leader;
+			mom.pos.x = start.x - dir.x * 3;
+			mom.pos.z = start.z - dir.z * 3;
+			mom.vel.x = 0;
+			mom.vel.z = 0;
+			mom.target.x = mom.pos.x;
+			mom.target.z = mom.pos.z;
+			mom.setActivity('rest');
+			mom.activityLeft = 300;
+			d.lost = false;
+		}
+
+		// only a placed duck is teleported: one already walking is kept as it
+		// stands, and just sent on down the line we chose
+		if (tier !== 'already walking') {
+			d.pos.x = start.x;
+			d.pos.z = start.z;
+			d.vel.x = 0;
+			d.vel.z = 0;
+			d.roll = 0;
+			d.stride = 1; // skip the blend ramp: the walk starts at full amplitude
+		}
+		d.facing.x = dir.x;
+		d.facing.z = dir.z;
+		d.heading = Math.atan2(dir.x, dir.z);
+		d.quat.set(0, Math.sin(d.heading / 2), 0, Math.cos(d.heading / 2));
+		d.floating = false;
+		d.target.x = d.pos.x + dir.x * 10;
+		d.target.z = d.pos.z + dir.z * 10;
+		d.setActivity(act);
+		d.activityLeft = 300; // no rethinking the activity under the capture
+		d.setState('walk');
+
+		// settle: the damped pose fields (stride, legTuck, roll, the heading
+		// slerp) need a second or so, and stepping is cheap while the render
+		// is the size of a stamp and the shadows are off
+		for (let i = 0; i < Math.round(warm * 120); i++) q.app.step(step);
+		full();
+
+		const p = ducks[idx];
+		const sp = Math.hypot(p.vel.x, p.vel.z);
+		return {
+			idx,
+			name: p.name,
+			kind: p.kind,
+			tier,
+			activity: p.activity,
+			scale: p.scale,
+			state: p.state,
+			speed: sp,
+			start: { x: p.pos.x, z: p.pos.z },
+			dir,
+			warn: sp < 0.15 ? `pace came out at only ${sp.toFixed(2)} m/s` : '',
+		};
+	}, { sel, warm: WARM_SECONDS });
+	if (prep.warn) console.log(`[warn] ${prep.name}: ${prep.warn}`);
+
+	// 4. the camera: side-on, perpendicular to the walk, fixed for the whole
+	//    capture. The rig clamps a look()'s yaw to Β±0.85 rad of down-pond, so
+	//    the gaze is put on the rig directly β€” it is the same three numbers
+	//    place() reads, just without the guard.
+	const cam = await page.evaluate(({ i, frameCount }) => {
+		const q = window.quack;
+		const d = q.pond.ducks[i];
+		const sp = Math.hypot(d.vel.x, d.vel.z);
+		const fx = sp > 1e-4 ? d.vel.x / sp : d.facing.x;
+		const fz = sp > 1e-4 ? d.vel.z / sp : d.facing.z;
+
+		// aim at the middle of the stretch the capture covers
+		const travel = sp * frameCount * 5 * (1 / 120);
+		const mx = d.pos.x + fx * travel * 0.5;
+		const mz = d.pos.z + fz * travel * 0.5;
+
+		// either side of the walk; the one nearest down-pond keeps the duck
+		// crossing the frame left to right
+		let best = null;
+		for (const s of [1, -1]) {
+			const px = s * fz;
+			const pz = -s * fx;
+			const yaw = Math.atan2(-px, pz);
+			if (!best || Math.abs(yaw) < Math.abs(best.yaw)) best = { px, pz, yaw };
+		}
+		const ex = mx + best.px * 2.4;
+		const ez = mz + best.pz * 2.4;
+
+		const r = q.rig;
+		r.stand(ex, ez, 0.55);
+		const dx = mx - ex;
+		const dz = mz - ez;
+		const targetY = d.root.position.y + 0.02;
+		const yaw = Math.atan2(dx, -dz);
+		const pitch = Math.atan2(targetY - r.eye.y, Math.hypot(dx, dz));
+		r.yaw = r.wantYaw = yaw;
+		r.pitch = r.wantPitch = pitch;
+		r.fov = r.wantFov = 40;
+		r.camera.fov = 40;
+		r.camera.updateProjectionMatrix();
+		return {
+			eye: { x: r.eye.x, y: r.eye.y, z: r.eye.z },
+			mid: { x: mx, y: targetY, z: mz },
+			dist: Math.hypot(dx, dz),
+			yaw,
+			pitch,
+			fov: r.camera.fov,
+			travel,
+		};
+	}, { i: prep.idx, frameCount: frames });
+
+	// 5. the filmstrip: one screenshot per frame, the sim advanced under it
+	mkdirSync(outDir, { recursive: true });
+	const records = [];
+	const files = [];
+	const simPerFrame = PER_FRAME * STEP;
+	for (let f = 0; f < frames; f++) {
+		const rec = await page.evaluate(({ i, k }) => {
+			const q = window.quack;
+			const d = q.pond.ducks[i];
+			const V = d.root.position.constructor;
+			const v = new V();
+			const phaseBefore = d.walkPhase; // the clock as this frame found it
+			// only the last slice is drawn at full size; the rest only need the
+			// world matrices a render leaves behind
+			q.renderer.setSize(8, 8, false);
+			for (let s = 0; s < k - 1; s++) q.app.step(1 / 120);
+			q.renderer.setSize(window.innerWidth, window.innerHeight, false);
+			q.app.step(1 / 120);
+			const p = d.root.position;
+			return {
+				feet: d.feet.map((foot) => {
+					foot.getWorldPosition(v);
+					return { x: v.x, y: v.y, z: v.z };
+				}),
+				root: { x: p.x, y: p.y, z: p.z },
+				pos: { x: d.pos.x, z: d.pos.z },
+				stride: d.stride,
+				roll: d.roll,
+				phase: d.walkPhase,
+				phaseBefore,
+				speed: Math.hypot(d.vel.x, d.vel.z),
+				state: d.state,
+			};
+		}, { i: prep.idx, k: PER_FRAME });
+		rec.t = (f + 1) * simPerFrame;
+		records.push(rec);
+		const file = join(outDir, `f${String(f).padStart(2, '0')}.png`);
+		await page.screenshot({ path: file });
+		files.push(file);
+	}
+
+	// ---------------------------------------------------------- the report
+	const elapsed = records[records.length - 1].t;
+	const scale = prep.scale;
+	const names = ['L (βˆ’x)', 'R (+x)'];
+
+	// what the duck actually covered: the root's ground track between the frame
+	// boundaries β€” the ground the feet have to keep up with
+	let travel = 0;
+	for (let f = 1; f < frames; f++) {
+		travel += Math.hypot(records[f].pos.x - records[f - 1].pos.x, records[f].pos.z - records[f - 1].pos.z);
+	}
+	const span = (frames - 1) * simPerFrame;
+	const speedGround = travel / span;
+
+	// ground level: the median over frames of the lowest of the two feet
+	const ground = median(records.map((r) => Math.min(r.feet[0].y, r.feet[1].y)));
+
+	// planted by height: a foot this close to the ground level is down
+	const planted = names.map((_, i) => records.map((r) => r.feet[i].y < ground + PLANT_TOL));
+
+	// the rig's own clock: which foot is in stance by phase (feet[0] is hipL,
+	// which aimLeg drives from walkPhase itself)
+	const inStancePhase = names.map((_, i) =>
+		records.map((r) => {
+			const c = (r.phase / (Math.PI * 2) + i * 0.5) % 1;
+			return r.stride > 0.5 && c < LEG_STANCE;
+		}),
+	);
+
+	const block = (label, flags, foot) => {
+		const intervals = runs(flags).filter(([a, b]) => b > a);
+		const rs = [];
+		const fwds = [];
+		const lats = [];
+		const paths = [];
+		let samples = 0;
+		for (const [a, b] of intervals) {
+			const r = ratioOver(records[a].root, records[b].root, records[a].feet[foot], records[b].feet[foot]);
+			if (!r) continue;
+			rs.push(r.ratio);
+			fwds.push(r.fwd);
+			lats.push(r.lat);
+			const p = pathRatio(records, a, b, foot);
+			if (p !== null) paths.push(p);
+			samples += b - a + 1;
+		}
+		return { label, intervals: intervals.length, samples, mean: mean(rs), max: rs.length ? Math.max(...rs) : NaN, fwd: mean(fwds), lat: mean(lats), path: mean(paths) };
+	};
+
+	const notPlanted = names.map((_, i) => planted[i].map((v) => !v));
+	const out = [];
+	const line = (s = '') => out.push(s);
+
+	line(`gait probe β€” ${prep.name} (duck ${prep.idx}, ${prep.kind}), scale ${scale.toFixed(3)}`);
+	line(`  ${url} Β· ${season.name} held (ice ${season.ice.toFixed(2)}, snow ${season.snow.toFixed(2)}) Β· seed 20261018`);
+	line(`  walk: ${prep.tier}, activity '${prep.activity}', along ${prep.dir.x > 0 ? '+x' : 'βˆ’x'} from (${prep.start.x}, ${prep.start.z}), warmed to ${f2(prep.speed)} m/s`);
+	line(`  ${frames} frames Γ— ${PER_FRAME}Γ—1/120 s = ${elapsed.toFixed(3)} s of sim (ground covered ${mm(travel)} at ${f2(speedGround)} m/s; the rig's speed field reads ${f2(mean(records.map((r) => r.speed)))} m/s, ${records.filter((r) => r.state === 'walk').length}/${frames} frames in state 'walk')`);
+	line(`  camera: eye (${cam.eye.x.toFixed(2)}, ${cam.eye.y.toFixed(2)}, ${cam.eye.z.toFixed(2)}) ${cam.dist.toFixed(2)} m out, yaw ${deg(cam.yaw).toFixed(1)}Β°, pitch ${deg(cam.pitch).toFixed(1)}Β°, fov ${cam.fov}Β° β€” fixed`);
+	line();
+	line(`ground level ${mm(ground)} (median of the per-frame lowest foot; a "foot" is the foot mesh origin)`);
+	line(`  speed          mean ${f2(mean(records.map((r) => r.speed)))} m/s, min ${f2(Math.min(...records.map((r) => r.speed)))} m/s, max ${f2(Math.max(...records.map((r) => r.speed)))} m/s`);
+	line(`  stride blend   mean ${f2(mean(records.map((r) => r.stride)))} of 1 (walk amplitude)`);
+	line();
+
+	line('STANCE SLIP β€” threshold planted (foot y < ground + 6 mm)');
+	for (let i = 0; i < 2; i++) {
+		const b = block('', planted[i], i);
+		const v = b.mean < 0.1 ? 'pinned' : b.mean <= 0.25 ? 'slight slide' : 'skating';
+		line(`  foot ${names[i]}: ${b.intervals} stance intervals, ${b.samples}/${frames} frames down (${((100 * b.samples) / frames).toFixed(0)}%)`);
+		line(`    slip ratio  mean ${f2(b.mean)}  max ${f2(b.max)}  β†’ ${v}`);
+		line(`    of it      forward ${f2(b.fwd)}  sideways ${mm(b.lat)}  (walked-path ratio ${f2(b.path)})`);
+	}
+	{
+		const b = block('', notPlanted[0], 0);
+		const b2 = block('', notPlanted[1], 1);
+		line(`  moving reference (not planted): L mean ratio ${f2(b.mean)} over ${b.intervals} runs, R ${f2(b2.mean)} over ${b2.intervals}`);
+	}
+	line();
+
+	line('SWING CLEARANCE β€” over frames the foot is not planted');
+	for (let i = 0; i < 2; i++) {
+		const ys = records.filter((_, f) => notPlanted[i][f]).map((r) => r.feet[i].y - ground);
+		const max = ys.length ? Math.max(...ys) : NaN;
+		const med = median(ys);
+		line(`  foot ${names[i]}: max ${mm(max)}, median ${mm(med)}, min ${mm(ys.length ? Math.min(...ys) : NaN)}, ${ys.length}/${frames} frames up β†’ ${max < 0.008 ? 'dragging' : 'clears the ground'}`);
+	}
+	// and how steady the foot is while it counts as down: a sole that rides
+	// up and down through the stance is not pinned however slow it slides
+	for (let i = 0; i < 2; i++) {
+		const ys = records.filter((_, f) => planted[i][f]).map((r) => r.feet[i].y - ground);
+		if (ys.length > 1) {
+			line(`  foot ${names[i]} height while planted: ${mm(Math.min(...ys))} to ${mm(Math.max(...ys))} (spread ${mm(Math.max(...ys) - Math.min(...ys))} over ${ys.length} frames)`);
+		}
+	}
+	line();
+
+	line('BY THE RIG CLOCK β€” same split, but stance/swing taken from walkPhase');
+	for (let i = 0; i < 2; i++) {
+		const b = block('', inStancePhase[i], i);
+		const ys = records.filter((_, f) => !inStancePhase[i][f]).map((r) => r.feet[i].y - ground);
+		line(`  foot ${names[i]}: stance ${b.samples}/${frames} frames (${((100 * b.samples) / frames).toFixed(0)}%, stance share is ${(LEG_STANCE * 100).toFixed(0)}%)`);
+		line(`    slip over phase-stance  mean ${f2(b.mean)}  max ${f2(b.max)}   forward ${f2(b.fwd)}  sideways ${mm(b.lat)}`);
+		line(`    clearance over phase-swing  max ${mm(ys.length ? Math.max(...ys) : NaN)}  median ${mm(median(ys))}`);
+	}
+	// how flat the foot is while it is nominally down
+	for (let i = 0; i < 2; i++) {
+		const ys = records.filter((_, f) => inStancePhase[i][f]).map((r) => r.feet[i].y - ground);
+		line(`    foot ${names[i]} height while in phase-stance  min ${mm(Math.min(...ys))}  max ${mm(Math.max(...ys))}  spread ${mm(Math.max(...ys) - Math.min(...ys))}`);
+	}
+	line();
+
+	// cadence: lift-offs counted off the foot's own height record, and the
+	// walkPhase unwrapped over the same sim time
+	line('CADENCE');
+	let cadFromFeet = NaN;
+	let events = 0;
+	const rates = [];
+	for (let i = 0; i < 2; i++) {
+		const ev = [];
+		for (let f = 1; f < frames; f++) if (planted[i][f - 1] && !planted[i][f]) ev.push(records[f].t);
+		let rate = NaN;
+		if (ev.length >= 2) rate = (ev.length - 1) / (ev[ev.length - 1] - ev[0]);
+		else if (ev.length === 1) rate = 1 / elapsed;
+		if (Number.isFinite(rate)) rates.push(rate);
+		line(`  lift-offs  foot ${names[i]}: ${ev.length} event${ev.length === 1 ? '' : 's'}${ev.length ? ` at t=${ev.map((t) => t.toFixed(2)).join(', ')} s` : ''} β†’ ${f2(rate)} per second`);
+		events += ev.length;
+	}
+	cadFromFeet = rates.length ? mean(rates) : NaN;
+	// the phase the rig advances inside each frame, summed over all of them β€”
+	// frame to frame would miss the first frame's own slice of the second
+	let cycles = 0;
+	for (let f = 0; f < frames; f++) cycles += (((records[f].phase - records[f].phaseBefore) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
+	cycles /= Math.PI * 2;
+	const cadPhase = cycles / elapsed;
+	const speedMean = mean(records.map((r) => r.speed));
+	const cadExpected = (speedMean * LEG_STANCE) / (2 * LEG_LEN * Math.sin(LEG_SWING) * scale);
+	const reach = 2 * LEG_LEN * Math.sin(LEG_SWING) * scale;
+	line(`  from lift-offs   ${f2(cadFromFeet)} cycles/s β€” mean of the feet's rise-event rates (a foot lifts once a cycle)`);
+	line(`  both feet        ${events} rise events over ${elapsed.toFixed(3)} s = ${f2(events / elapsed)} steps/s (two steps a cycle)`);
+	line(`  from walkPhase   ${f2(cadPhase)} cycles/s (${cycles.toFixed(2)} cycles unwrapped over ${elapsed.toFixed(3)} s)`);
+	line(`  expected         ${f2(cadExpected)} cycles/s = speed ${f2(speedMean)} Γ— stance ${LEG_STANCE} / (2 Γ— ${LEG_LEN} Γ— sin(${LEG_SWING}) Γ— ${scale.toFixed(3)})`);
+	if (cadExpected > CADENCE_CAP) line(`  note             the rig caps cadence at ${CADENCE_CAP}/s, so this walk is above what the legs can step`);
+	line(`  step reach       ${mm(reach)} of ground per stance (2Β·LΒ·sinΒ·scale); body travel per cycle ${mm(cadPhase > 0 ? speedGround / cadPhase : NaN)} (measured)`);
+	{
+		const implied = (cadPhase * reach) / LEG_STANCE;
+		line(`  ground covered   the measured cadence sweeps ${f2(implied)} m/s of ground under the duck, against the ${f2(speedGround)} m/s it covered β€” ${(100 * (speedGround > 0 ? implied / speedGround : 0)).toFixed(0)}% (100% = every metre is stepped, less = the feet skate with the body)`);
+	}
+	if (events < 3) line(`  note             ${events} lift-off${events === 1 ? '' : 's'} is a coarse count over ${elapsed.toFixed(2)} s of sim β€” walkPhase is the precise figure`);
+	line();
+
+	// bob and roll, off the same records
+	const ys = records.map((r) => r.root.y);
+	const bob = Math.max(...ys) - Math.min(...ys);
+	let bumps = 0;
+	for (let f = 1; f < frames - 1; f++) if (ys[f] >= ys[f - 1] && ys[f] > ys[f + 1]) bumps++;
+	const rolls = records.map((r) => r.roll);
+	const rollPP = Math.max(...rolls) - Math.min(...rolls);
+	const bobVerdict = bob <= 0.018 * scale ? 'step-weight' : bob > 0.025 * scale ? 'hop' : 'in between';
+	line('BOB AND ROLL');
+	line(`  root y          peak-to-peak ${mm(bob)} (${mm(bob / scale)} at scale 1), ${f2(bumps / elapsed)} bumps/s β†’ ${bobVerdict} (thresholds ${mm(0.018 * scale)} / ${mm(0.025 * scale)} at this scale)`);
+	line(`  roll            peak-to-peak ${deg(rollPP).toFixed(1)}Β°, mean |roll| ${deg(mean(rolls.map(Math.abs))).toFixed(1)}Β°`);
+	line();
+
+	line(`frames β€” ${frames} PNGs at ${frames * 2 * PER_FRAME} ms of sim each, ${outDir}`);
+	for (const file of files) line(`  ${file}`);
+
+	const report = out.join('\n');
+	console.log(`\n${report}\n`);
+	writeFileSync(join(outDir, 'report.txt'), report + '\n');
+
+	// 6. the run's health: a duck that never walked, an error in the console
+	if (records.filter((r) => r.state === 'walk').length < frames) {
+		console.log(`[warn] ${frames - records.filter((r) => r.state === 'walk').length} frame(s) were not in state 'walk'`);
+	}
+	if (!(ground + PLANT_TOL > 0)) console.log('[warn] ground level is not above zero β€” the duck may not be on the lid');
+	if (consoleErrors.length || pageErrors.length) {
+		console.log(`[fatal] ${consoleErrors.length} console error(s), ${pageErrors.length} page error(s)`);
+		exitCode = 1;
+	}
+} catch (err) {
+	console.log(`[fatal] ${err && err.stack ? err.stack : err}`);
+	exitCode = 1;
+} finally {
+	await browser.close();
+}
+
+process.exit(exitCode);
diff --git a/scripts/loader-shot.mjs b/scripts/loader-shot.mjs
new file mode 100644
index 0000000..493aee9
--- /dev/null
+++ b/scripts/loader-shot.mjs
@@ -0,0 +1,66 @@
+// The loading card's backdrop: the pond as it opens, with not a duck in it,
+// shot at one device shape and squeezed into a progressive JPEG small enough to
+// be down before the module is β€” the card blurs it anyway, so it wants to be
+// cheap rather than sharp. Usage:
+//   node scripts/loader-shot.mjs [url] [out] [width] [height] [dpr] [outWidth] [quality]
+// The raw PNG is kept under temp/ so the capture can be looked at unblurred.
+// Needs `npm i --no-save sharp` β€” the only thing in the repo that uses it.
+import puppeteer from 'puppeteer';
+import sharp from 'sharp';
+import { mkdirSync } from 'node:fs';
+import { dirname, resolve, basename } from 'node:path';
+
+const url = process.argv[2] || 'http://localhost:5180/';
+const out = resolve(process.argv[3] || 'public/pond-loader.jpg');
+const width = Number(process.argv[4]) || 430;
+const height = Number(process.argv[5]) || 932;
+const dpr = Number(process.argv[6]) || 2;
+const outWidth = Number(process.argv[7]) || width;
+const quality = Number(process.argv[8]) || 50;
+
+const browser = await puppeteer.launch({
+	headless: true,
+	args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+	const page = await browser.newPage();
+	await page.setViewport({ width, height, deviceScaleFactor: dpr, isMobile: dpr > 1, hasTouch: dpr > 1 });
+	page.on('pageerror', (err) => console.log(`[pageerror] ${err.message}`));
+	await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
+	await page.waitForFunction('window.quack && window.quack.app.started', { timeout: 30000 });
+
+	// hold the pond at the pose it opens on, with the flock out of the shot, the
+	// odd feather or droplet out of it too, and the card itself out of the shot,
+	// then wait for the frame it is drawn in
+	await page.evaluate(async () => {
+		const q = window.quack;
+		q.app.paused = true;
+		document.getElementById('loading')?.remove();
+		for (const d of q.pond.ducks) d.root.visible = false;
+		for (const pool of [q.effects.feathers, q.effects.bits, q.effects.leaves, q.effects.snowflakes]) {
+			pool.mesh.visible = false;
+		}
+		for (const r of q.effects.ripples) r.mesh.visible = false;
+		await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+	});
+
+	const raw = resolve('temp/loader', basename(out).replace(/\.jpe?g$/i, '.png'));
+	mkdirSync(dirname(raw), { recursive: true });
+	await page.screenshot({ path: raw, type: 'png' });
+
+	const png = await sharp(raw);
+	const meta = await png.metadata();
+	mkdirSync(dirname(out), { recursive: true });
+	const info = await png
+		.resize({ width: outWidth, kernel: 'lanczos3' })
+		.jpeg({ quality, progressive: true, mozjpeg: true, chromaSubsampling: '4:2:0' })
+		.toFile(out);
+
+	console.log(
+		`${out}  ${info.width}x${info.height}  ${(info.size / 1024).toFixed(1)} kB  ` +
+			`(from ${meta.width}x${meta.height} at ${dpr}x)  raw: ${raw}`,
+	);
+} finally {
+	await browser.close();
+}
diff --git a/scripts/sample.mjs b/scripts/sample.mjs
new file mode 100644
index 0000000..80ef72f
--- /dev/null
+++ b/scripts/sample.mjs
@@ -0,0 +1,41 @@
+// Sample the middle column of a PNG, top to bottom, so a shot can be read as
+// data instead of eyeballed. Usage:
+//   node scripts/sample.mjs <png> [x0..1]
+import puppeteer from 'puppeteer';
+import { pathToFileURL } from 'node:url';
+import { resolve } from 'node:path';
+
+const file = resolve(process.argv[2] || 'shot.png');
+const fx = Number(process.argv[3] ?? 0.5);
+const url = pathToFileURL(file).href;
+
+const browser = await puppeteer.launch({
+	headless: true,
+	args: ['--allow-file-access-from-files', '--hide-scrollbars'],
+});
+try {
+	const page = await browser.newPage();
+	await page.goto(url);
+	const rows = await page.evaluate((frac) => {
+		const img = document.querySelector('img');
+		img.style.imageRendering = 'pixelated';
+		const c = document.createElement('canvas');
+		c.width = img.naturalWidth;
+		c.height = img.naturalHeight;
+		const g = c.getContext('2d');
+		g.drawImage(img, 0, 0);
+		const x = Math.min(img.naturalWidth - 1, Math.round(frac * (img.naturalWidth - 1)));
+		const out = [];
+		for (let i = 0; i <= 40; i++) {
+			const y = Math.min(img.naturalHeight - 1, Math.round((i / 40) * (img.naturalHeight - 1)));
+			const d = g.getImageData(x, y, 1, 1).data;
+			const hex = [d[0], d[1], d[2]].map((v) => v.toString(16).padStart(2, '0')).join('');
+			out.push(`${String(i * 2.5).padStart(5)}%  #${hex}`);
+		}
+		return out;
+	}, fx);
+	console.log(`${file} @ x=${fx}`);
+	console.log(rows.join('\n'));
+} finally {
+	await browser.close();
+}
diff --git a/scripts/shot.mjs b/scripts/shot.mjs
new file mode 100644
index 0000000..6bd509c
--- /dev/null
+++ b/scripts/shot.mjs
@@ -0,0 +1,39 @@
+// Screenshot helper for tuning: opens the pond in headless Chrome at a phone-ish
+// viewport and writes a PNG. Usage:
+//   node scripts/shot.mjs [url] [out] [width] [height] [waitMs]
+import puppeteer from 'puppeteer';
+import { mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+const url = process.argv[2] || 'http://localhost:5173/';
+const out = process.argv[3] || 'shot.png';
+const width = Number(process.argv[4]) || 430;
+const height = Number(process.argv[5]) || 932;
+const waitMs = Number(process.argv[6]) || 5000;
+
+const browser = await puppeteer.launch({
+	headless: true,
+	args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+	const page = await browser.newPage();
+	await page.setViewport({ width, height, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
+	page.on('console', (msg) => console.log(`[console.${msg.type()}] ${msg.text()}`));
+	page.on('pageerror', (err) => console.log(`[pageerror] ${err.message}`));
+	page.on('requestfailed', (req) => console.log(`[requestfailed] ${req.url()} ${req.failure()?.errorText || ''}`));
+
+	await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
+	await new Promise((r) => setTimeout(r, waitMs));
+	// optional JS to run before the shot, e.g. to pose the camera or season
+	const js = process.argv[7];
+	if (js) {
+		await page.evaluate(js);
+		await new Promise((r) => setTimeout(r, 1200));
+	}
+	mkdirSync(dirname(out), { recursive: true });
+	await page.screenshot({ path: out });
+	console.log(`saved ${out}`);
+} finally {
+	await browser.close();
+}
diff --git a/scripts/source-zip.mjs b/scripts/source-zip.mjs
new file mode 100644
index 0000000..934e77b
--- /dev/null
+++ b/scripts/source-zip.mjs
@@ -0,0 +1,55 @@
+// Pack the site's own source for the fifth title card. The fallback has to be
+// more than a link to a repository that may not exist yet: this zip is built
+// from the same working tree, so anyone can download the pond, unpack it and
+// run `npm install && npm run build`.
+//
+// It deliberately includes the committed audio wasm and the Rust stage patch;
+// it excludes node_modules, builds, scratch screenshots, the vendored Rust
+// clone and every local credential file.
+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
+import { basename, dirname, join, relative, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { zipSync } from 'fflate';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const output = join(root, 'public', 'quack-source.zip');
+const skipDirs = new Set(['node_modules', 'dist', 'temp', '.git', '.wrangler', 'duck-pond']);
+const skipFiles = new Set(['quack-source.zip', '.credentials.local.md']);
+const entries = [
+	'index.html',
+	'package.json',
+	'package-lock.json',
+	'tsconfig.json',
+	'vite.config.ts',
+	'README.md',
+	'LICENSE',
+	'.gitignore',
+	'src',
+	'public',
+	'scripts',
+	'docs',
+	'audio',
+];
+const files = {};
+
+/** Add one file or directory, skipping generated output and local-only files. */
+function add(path) {
+	const base = basename(path);
+	if (skipDirs.has(base) || skipFiles.has(base) || base.startsWith('.env')) return;
+	const name = relative(root, path).split(sep).join('/');
+	if (statSync(path).isDirectory()) {
+		for (const child of readdirSync(path)) add(join(path, child));
+		return;
+	}
+	files[name] = readFileSync(path);
+}
+
+for (const entry of entries) {
+	const path = join(root, entry);
+	if (existsSync(path)) add(path);
+}
+
+mkdirSync(join(root, 'public'), { recursive: true });
+const zip = zipSync(files, { level: 9 });
+writeFileSync(output, zip);
+console.log(`source zip ${relative(root, output)} (${(zip.length / 1024).toFixed(0)} KiB, ${Object.keys(files).length} files)`);
diff --git a/src/camera.ts b/src/camera.ts
new file mode 100644
index 0000000..344711b
--- /dev/null
+++ b/src/camera.ts
@@ -0,0 +1,201 @@
+// The camera rig: a pair of eyes planted on the near shore. Dragging turns the
+// head β€” yaw and pitch, in place, the way a person standing at the water's edge
+// looks around β€” and pinching opens or closes the view. The viewpoint never
+// moves, so the pond stays where it is and only the gaze swings across it.
+import * as THREE from 'three';
+import { clamp, damp } from './utils';
+import { CAMERA, WORLD } from './config';
+import type { World } from './world';
+
+/** the half-width of horizon a full lean-in keeps, as a tangent */
+const FOV_MIN_HALF_HORIZON = Math.tan((CAMERA.fov.min * Math.PI) / 360) * CAMERA.fov.refAspect;
+
+/** The narrowest field of view a screen of this shape may lean in to, in degrees. */
+function fovFloor(aspect: number): number {
+	if (!(aspect > 0)) return CAMERA.fov.min;
+	// narrower than 16:9, the floor rises to keep the horizon width; wider, it
+	// stays at the plain angle, so no screen ever leans in to a vertical slit
+	return Math.max(CAMERA.fov.min, (2 * Math.atan(FOV_MIN_HALF_HORIZON / aspect) * 180) / Math.PI);
+}
+
+const _target = new THREE.Vector3();
+
+export class CameraRig {
+	camera: THREE.PerspectiveCamera;
+	world: World;
+	/** where the viewer stands, on the bank */
+	readonly eye = new THREE.Vector3();
+	/** the gaze: yaw turns right from down-pond, pitch lifts the chin (radians) */
+	yaw = 0;
+	pitch: number = CAMERA.pitchStart;
+	/** the field of view in degrees β€” smaller leans in closer */
+	fov: number = CAMERA.fov.start;
+
+	// what the head is easing towards
+	private wantYaw = 0;
+	private wantPitch: number = CAMERA.pitchStart;
+	private wantFov: number = CAMERA.fov.start;
+	// who is touching the screen, and where
+	private fingers = new Map<number, { x: number; y: number }>();
+	private dragId = -1;
+	private lastX = 0;
+	private lastY = 0;
+	private pinchSpan = 0;
+
+	constructor(camera: THREE.PerspectiveCamera, domElement: HTMLElement, world: World) {
+		this.camera = camera;
+		this.world = world;
+
+		this.eye.set(
+			WORLD.viewStand.x,
+			world.surfaceHeightAt(WORLD.viewStand.x, WORLD.viewStand.z) + CAMERA.eyeHeight,
+			WORLD.viewStand.z,
+		);
+		this.camera.fov = this.fov;
+		this.camera.updateProjectionMatrix();
+		this.place();
+		this.listen(domElement);
+	}
+
+	update(dt: number): void {
+		this.yaw = damp(this.yaw, this.wantYaw, CAMERA.turnRate, dt);
+		this.pitch = damp(this.pitch, this.wantPitch, CAMERA.turnRate, dt);
+		// eased in log space as well, or the last degrees of a lean-in would
+		// cross the frame in a single step
+		this.fov = Math.exp(damp(Math.log(this.fov), Math.log(this.wantFov), CAMERA.zoomRate, dt));
+		// a screen that changes shape under us β€” a rotate, a resized window β€”
+		// can leave the gaze narrower than its own floor allows
+		const floor = fovFloor(this.camera.aspect);
+		if (this.fov < floor) {
+			this.fov = floor;
+			this.wantFov = Math.max(this.wantFov, floor);
+		}
+		if (Math.abs(this.camera.fov - this.fov) > 1e-3) {
+			this.camera.fov = this.fov;
+			this.camera.updateProjectionMatrix();
+		}
+		this.place();
+	}
+
+	/**
+	 * Aim the head at once β€” for screenshots and the console, with no easing.
+	 * `yaw` turns right from down-pond, `pitch` lifts the chin, `fov` is degrees.
+	 */
+	look(yaw: number, pitch: number, fov = this.fov): this {
+		this.wantYaw = this.yaw = clamp(yaw, -CAMERA.yawLimit, CAMERA.yawLimit);
+		this.wantPitch = this.pitch = clamp(pitch, CAMERA.pitchMin, CAMERA.pitchMax);
+		this.wantFov = this.fov = clamp(fov, fovFloor(this.camera.aspect), CAMERA.fov.max);
+		this.camera.fov = this.fov;
+		this.camera.updateProjectionMatrix();
+		this.place();
+		return this;
+	}
+
+	/** Put the eye down somewhere else β€” for close-ups. Reads the ground again. */
+	stand(x: number, z: number, height = CAMERA.eyeHeight): this {
+		this.eye.set(x, this.world.surfaceHeightAt(x, z) + height, z);
+		this.place();
+		return this;
+	}
+
+	/** Sit the camera on the eye and turn it to the gaze. */
+	private place(): void {
+		const c = this.camera;
+		c.position.copy(this.eye);
+		const flat = Math.cos(this.pitch);
+		_target.set(
+			this.eye.x + Math.sin(this.yaw) * flat,
+			this.eye.y + Math.sin(this.pitch),
+			this.eye.z - Math.cos(this.yaw) * flat,
+		);
+		c.lookAt(_target);
+	}
+
+	private turn(dx: number, dy: number): void {
+		// the view follows the finger: drag right and the head turns right,
+		// drag down and the gaze drops to the water
+		this.wantYaw = clamp(this.wantYaw + dx * CAMERA.turnPerPx, -CAMERA.yawLimit, CAMERA.yawLimit);
+		this.wantPitch = clamp(this.wantPitch - dy * CAMERA.turnPerPx, CAMERA.pitchMin, CAMERA.pitchMax);
+	}
+
+	/**
+	 * Zoom by a step in log space: a pinch or a wheel notch is worth the same
+	 * fraction of the view whether it is wide open or leaning on a duck, so the
+	 * step never feels slow at one end of the range and wild at the other.
+	 */
+	private zoom(logStep: number): void {
+		this.wantFov = clamp(this.wantFov * Math.exp(logStep), fovFloor(this.camera.aspect), CAMERA.fov.max);
+	}
+
+	private listen(dom: HTMLElement): void {
+		dom.addEventListener('pointerdown', (e) => {
+			if (e.pointerType === 'mouse' && e.button !== 0) return;
+			this.fingers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+			if (this.fingers.size === 1) {
+				this.dragId = e.pointerId;
+				this.lastX = e.clientX;
+				this.lastY = e.clientY;
+			} else {
+				// a second finger means a pinch, not a turn
+				this.dragId = -1;
+				this.pinchSpan = this.span();
+			}
+			try {
+				dom.setPointerCapture(e.pointerId);
+			} catch {
+				// a synthetic or already-released pointer: nothing to capture
+			}
+		});
+
+		dom.addEventListener('pointermove', (e) => {
+			const finger = this.fingers.get(e.pointerId);
+			if (!finger) return;
+			finger.x = e.clientX;
+			finger.y = e.clientY;
+
+			if (this.fingers.size >= 2) {
+				const span = this.span();
+				if (this.pinchSpan > 0) this.zoom((this.pinchSpan - span) * CAMERA.pinchPerPx);
+				this.pinchSpan = span;
+				return;
+			}
+			if (e.pointerId !== this.dragId) return;
+			this.turn(e.clientX - this.lastX, e.clientY - this.lastY);
+			this.lastX = e.clientX;
+			this.lastY = e.clientY;
+		});
+
+		const release = (e: PointerEvent) => {
+			this.fingers.delete(e.pointerId);
+			this.pinchSpan = 0;
+			if (this.fingers.size === 0) {
+				this.dragId = -1;
+				return;
+			}
+			if (this.fingers.size === 1) {
+				// one finger left over after a pinch: let it carry on turning
+				const [id, finger] = [...this.fingers.entries()][0];
+				this.dragId = id;
+				this.lastX = finger.x;
+				this.lastY = finger.y;
+			}
+		};
+		dom.addEventListener('pointerup', release);
+		dom.addEventListener('pointercancel', release);
+
+		dom.addEventListener(
+			'wheel',
+			(e) => {
+				e.preventDefault();
+				this.zoom(e.deltaY * CAMERA.wheelPerPx);
+			},
+			{ passive: false },
+		);
+	}
+
+	private span(): number {
+		const fingers = [...this.fingers.values()];
+		if (fingers.length < 2) return 0;
+		return Math.hypot(fingers[0].x - fingers[1].x, fingers[0].y - fingers[1].y);
+	}
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..634e434
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,593 @@
+// Every knob in quack, grouped by the scope that spends it.
+//
+// A group is its own export, so a module imports only what it needs and the
+// numbers stay out of the code that reads them:
+//
+//     import { CAMERA, WORLD } from './config';
+//
+// The shape each group takes follows the notes in docs/port-notes.md: layout
+// lives in WORLD, the flock's day and pace in FLOCK, its voice in CALLS, a lap
+// in LAP, a flight in FLIGHT, the bread and the flock's answer to it in BREAD,
+// and so on. Values that had a name in the port (`ICE_STILL`, `HULL_SPEED`,
+// `SHORE_GATE`) keep a close one here, lower-cased under its group.
+//
+// What is *not* here: the seasonal colour keys and species proportions, which
+// are read as whole pieces of art where they are written; the water shader's
+// wave shapes, which are a matched pair with `waterHeightAt`; and the multi-
+// number pose curves in duck.ts, which are a rig, not a dial. Their tuned
+// extremes all point back to the groups below.
+
+// ------------------------------------------------------------------ app
+
+/** Boot and debug plumbing: what the page starts with before there is a pond. */
+export const APP = {
+	/** the pond when the URL carries no `?seed=` */
+	seed: 20261018,
+	/** the year opens in summer: 0 spring, 1 summer, 2 autumn, 3 winter */
+	openSeason: 1,
+	/** ACES exposure, before the season keys settle in */
+	exposure: 0.98,
+	/** device pixel ratio is capped here: phones report 3+ */
+	maxPixelRatio: 2,
+	/** how long the browser gets to paint the loading card before boot blocks it */
+	bootDelay: 60,
+	/** the longest wall-clock slice one frame may hand the sim, in seconds */
+	maxStep: 0.05,
+	/** the clear colour the scene opens under, before the first season applies */
+	clear: 0xc3dcee,
+	/** initial fog before the season keys settle in (replaced during boot) */
+	fog: { near: 160, far: 720 },
+	/** T holds the year here while it is toggled */
+	slowMotion: 0.25,
+} as const;
+
+/** The one invisible gesture rule on the canvas. */
+export const INPUT = {
+	/** px a press may drift and still count as a tap rather than a drag */
+	tapSlop: 12,
+} as const;
+
+/** The opening card. The word's CSS animation lives in index.html; these are
+ *  the waits around it, in ms. */
+export const LOADER = {
+	/** hold the pond back this long once the world is ready, for the word */
+	min: 1950,
+	/** the card's fade into the live pond (keep in step with `.done`) */
+	fade: 900,
+	/** after the fade, the empty element is removed */
+	removeDelay: 400,
+} as const;
+
+/** The title cards that explain the pond after the loader has gone, in ms. */
+export const HINTS = {
+	/** quiet water between the loader's fade and the first card */
+	afterLoader: 1000,
+	/** how long a controls card stays up */
+	tipHold: 3000,
+	/** how long the credit card stays up */
+	creditHold: 7500,
+	/** fade out (keep in step with the `.hint` transition) */
+	fade: 600,
+	/** quiet water between two cards */
+	gap: 500,
+} as const;
+
+/** The fifth title card: a public source link, or a download of this site's own source. */
+export const SOURCE = {
+	/**
+	 * Public repository URL. Leave empty (or set to null) while the repo is
+	 * private: the card then offers `quack-source.zip`, built from the working
+	 * tree by `scripts/source-zip.mjs`.
+	 */
+	src_url: '' as string | null,
+	/** how long the source card stays up, in ms */
+	hold: 6500,
+} as const;
+
+// --------------------------------------------------------------- camera
+
+/**
+ * The planted eye on the near shore. The viewpoint never moves: dragging turns
+ * the head in place, and pinching/zooming narrows the frame around the gaze.
+ */
+export const CAMERA = {
+	/** how high the eye rides above the ground it stands on */
+	eyeHeight: 2.0,
+	/** clipping planes for the whole scene */
+	near: 0.15,
+	far: 6000,
+	/** how far the head may turn, each way, from straight down the pond */
+	yawLimit: 0.85,
+	/** how far the head may tip: down at the water, up at the sky */
+	pitchMin: -1.0,
+	pitchMax: 0.45,
+	/** where the gaze rests at boot: a touch below level */
+	pitchStart: -0.06,
+	fov: {
+		/** the field of view in degrees: the widest view and the closest lean */
+		min: 10,
+		max: 78,
+		/** the view it opens on */
+		start: 52,
+		/** the reference screen shape the narrow floor is held to */
+		refAspect: 16 / 9,
+	},
+	/** radians a drag of one pixel turns the head */
+	turnPerPx: 0.0028,
+	/** zoom a wheel pixel asks for, as log(fov) */
+	wheelPerPx: 0.0025,
+	/** zoom a stretched pinch pixel asks for, as log(fov) */
+	pinchPerPx: 0.0022,
+	/** how quickly the head catches up with the finger */
+	turnRate: 10,
+	zoomRate: 10,
+} as const;
+
+// -------------------------------------------------------------- seasons
+
+/** One slow year: the timeline's length and how gently it turns. */
+export const SEASONS = {
+	/** seconds spent in one season; a full year is four of them */
+	seconds: 420,
+	/** the tail of each season blends into the next, the rest holds still */
+	blend: 0.4,
+	/** a requested season key morphs in about this long, near or far */
+	jump: 0.8,
+} as const;
+
+// ---------------------------------------------------------------- world
+
+/**
+ * The pond's water-plan and the ground around it, in world x/z. The pond is a
+ * wide ellipse in front of the shore; ducks live in the middle of it and keep
+ * to the beach strip when they come out.
+ */
+export const WORLD = {
+	/** the terrain and water meshes: a square around the pond, and its vertices */
+	size: 1500,
+	segments: 432,
+	/** the surface every height is measured from */
+	waterLevel: 0,
+	/** fog the world falls back to before a season sets its own */
+	fog: { near: 210, far: 900 },
+	pond: { cx: 0, cz: -22, rx: 46, rz: 64 },
+	/** the beach strip: x half-width and the z band ducks walk on */
+	bank: { x: 26, z0: 41.5, z1: 52.5 },
+	/** the spot just off the bank the thrower stands at (the camera side) */
+	thrower: { x: 0, z: 47 },
+	/** where the viewer stands on the shore, right at the water's edge */
+	viewStand: { x: 0, z: 48.3 },
+	/**
+	 * Preferred stretches of open water, as world z: the near shore sits at
+	 * high z, so `near` hugs it and `far` reaches the deep middle. A soft
+	 * preference β€” a pick with no room in its window falls back to any water
+	 * deep enough.
+	 */
+	bands: {
+		near: { lo: 22, hi: 38 },
+		mid: { lo: 0, hi: 22 },
+		far: { lo: -38, hi: 4 },
+	},
+	/** bread can be chased anywhere in this stretch of open water, plus the bank */
+	chase: { x: 120, zMin: -120, zMax: 43.5 },
+	/**
+	 * The waterline in front of x: a wobble that fades in beyond the duck pond,
+	 * so the middle stretch runs straight and the flock has a reliable edge.
+	 */
+	shoreline: { z: 46, wobble: 10, noise: 22, straightFrom: 38, straightTo: 95 },
+	/** the walkable bank: along the waterline, and inland past its edge */
+	shore: { halfSpan: 130, edge: 3, depth: 18 },
+	/** the beach's rise: at the waterline, over the meadow, and its noise */
+	bankFloor: { edge: 0.7, rise: 2.3, span: 90, noise: 0.6 },
+	/** depths that decide what a duck can do: wade, swim, or dive for a dabble */
+	water: { wade: 0.05, swim: 0.3, deep: 0.8 },
+	/**
+	 * Ice. `walk` carries a duck's weight; `still` pins the swell (the water
+	 * shader repeats it β€” change both together); `season` is where the year
+	 * counts as winter; `open` is the most ice a point may carry and still be
+	 * picked as open water for the flock.
+	 */
+	ice: { walk: 0.5, still: 0.3, season: 0.3, open: 0.4 },
+	/**
+	 * Winter's lead: the one patch of water the cold never closes, a little
+	 * left of the middle of the opening view.
+	 */
+	lead: { x: -3.5, z: 29, r0: 8.5, r1: 11.5 },
+	/** the meadow: the blooms the world has always had, and its seed bank */
+	flowers: { field: 700, bank: 500 },
+} as const;
+
+// ---------------------------------------------------------------- flock
+
+/** The flock's pace, its day, and the little social rules that shape it. */
+export const FLOCK = {
+	/**
+	 * Paddling ducks can't outswim the waves they make: hull speed is 0.71 m/s
+	 * for an adult and 0.43 m/s for a 9-day-old duckling, which bursts across
+	 * the surface at 1.26 m/s to catch up.
+	 */
+	hullSpeed: 0.71,
+	ducklingHullSpeed: 0.43,
+	ducklingBurst: 1.26,
+	/** a hen leading ducklings goes at a pace they can paddle */
+	broodPace: 0.3,
+	/** a duckling further than this from its mother calls for her */
+	lost: 4.0,
+	/** mean time between a foraging duckling's wanders away from its mother */
+	strayEvery: 900.0,
+	/** how long a swim bout holds its mark, s */
+	swimBout: 180,
+	/** how close counts as arrived on a swimming mark, m */
+	arrive: 0.7,
+	/** extra time on the clock after a beg's own spell, s */
+	begPad: 60,
+	/** chance neighbours copy what a neighbour is doing, keeping the flock in
+	 *  loose sympathy rather than lockstep */
+	mirrorChance: 0.35,
+	/** chance a swim bout sends a duck to a different depth band */
+	bandChange: 0.3,
+	/** chance a bout that ends out of the camera's picture sends a duck back in */
+	returnToView: 0.8,
+	/** once out of the picture, a pastime may still run this long before home */
+	outViewBout: 50,
+	/** in a freeze, half the bids to dabble become loafing */
+	winterDabbleRest: 0.5,
+	/** bout lengths by what the duck is doing, s */
+	bout: {
+		long: { min: 60, max: 240 },
+		preen: { min: 30, max: 120 },
+		other: { min: 30, max: 180 },
+		/** after a meal, before settling back into the day */
+		afterFeed: { min: 60, max: 180 },
+	},
+} as const;
+
+// ---------------------------------------------------------------- calls
+
+/**
+ * The calling model: when a duck calls, who answers, and how quickly a lost
+ * duckling or a brood call brings the next one forward. Ported from
+ * duck-pond's `scene.rs`; the synth and propagation are still absent, so a
+ * call is a silent head/bill gesture and a `CallEvent` for the audio core.
+ */
+export const CALLS = {
+	/** seconds until a newly built duck's first call */
+	first: { min: 0.5, max: 8 },
+	/** seconds until the next call, by the occasion that sets the pace */
+	interval: {
+		lostYoung: { min: 1.5, max: 3 },
+		lost: { min: 0.2, max: 1 },
+		ducklingFeed: { min: 1.5, max: 5 },
+		duckling: { min: 4, max: 15 },
+		feed: { min: 2.5, max: 6 },
+		drakeAway: { min: 8, max: 20 },
+		drake: { min: 45, max: 180 },
+		dabble: { min: 15, max: 60 },
+		other: { min: 20, max: 90 },
+	},
+	/** share of calls a resting duck lets pass: ducklings are a little chattier */
+	sleepSkip: { duckling: 0.8, adult: 0.9 },
+	/** a drake whose mate is this far off calls after her, m */
+	mateAway: 8,
+	/** the answer chain: its reach, a mate's probability, a stranger's base */
+	answer: { range: 40, mate: 0.6, other: 0.3 },
+	/** how soon an answer, or a mother's answer to a lost peep, is due, s */
+	answerDelay: { min: 0.8, max: 2.5 },
+	motherDelay: { min: 0.5, max: 2.5 },
+	/** how soon a new occasion brings the next call forward, s */
+	lostDelay: { min: 0.1, max: 0.5 },
+	gatheringDelay: { min: 0.3, max: 1.2 },
+	noticeDelay: { min: 0.2, max: 1.5 },
+	/** brood-call notes: more when calling lost young than when gathering */
+	broodNotes: {
+		lost: { min: 6, max: 10 },
+		gather: { min: 3, max: 6 },
+	},
+} as const;
+
+// ---------------------------------------------------------------- shore
+
+/**
+ * Neither the bank nor the winter lid is home: a duck that steps out of the
+ * water has come visiting. It settles for a doze or a groom, then the pond
+ * pulls it back β€” most spells ashore end in a walk to the water, while the odd
+ * one takes another pastime.
+ */
+export const SHORE = {
+	/** how long a visit to grass or ice lasts, s */
+	stay: { min: 35, max: 90 },
+	/** the lid is colder underfoot, so a bird on it is quicker to leave */
+	iceStay: { min: 15, max: 45 },
+	/** chance a swim bout starting near the bank becomes a visit ashore */
+	visit: 0.35,
+	/** a hen with ducklings is readier to take them out */
+	broodVisit: 0.5,
+	/** a spell ashore means a march over the frozen lid, so winter tempts less */
+	winterVisit: 0.5,
+	/** how close to the thrower a duck must be for the bank to tempt it */
+	gate: 26,
+	/** chance a spell on the bank or the lid ends with a swim back */
+	returnToWater: 0.75,
+	returnFromIce: 0.85,
+} as const;
+
+// --------------------------------------------------------------- winter
+
+/** What a freeze changes about the flock's day. */
+export const WINTER = {
+	/** the flock does less of everything on the small open lead */
+	bouts: 0.55,
+	/** share of swim bouts bound for the lid's rim rather than the lead */
+	loaf: 0.045,
+	/** a flight across the lead is a short hop, not a lap of the open lake */
+	flightReach: { min: 5, max: 14 },
+} as const;
+
+// ------------------------------------------------------------------ lap
+
+/**
+ * Now and then a bout that would settle to the nearest pastime becomes a lap
+ * of the lake: a wandering round rather than a line across it. The pond draws
+ * the circle through the bird each time, picks the way round at random, and
+ * bows the path with two wobbles so no two laps read the same.
+ */
+export const LAP = {
+	/** chance a bout that would settle becomes a lap instead */
+	chance: 0.14,
+	/** share of laps that swing out of the camera's picture and back in */
+	out: 0.3,
+	/** how much of the round one bout swims: part of it, or the whole way */
+	turns: { min: 0.55, max: 1.5 },
+	/** the two harmonics that bow the round in and out */
+	wobble: 0.15,
+	wobble2: 0.1,
+	/** radius ranges, m: open water and the winter lead */
+	radius: { min: 4.5, max: 9.5 },
+	winterRadius: { min: 3, max: 5.5 },
+	/** how far along the round the duck aims, m, and the cap on that angle */
+	lookahead: 3.5,
+	lookaheadMax: 1.1,
+	/** attempts to draw a round that stays in water, and samples along it */
+	tries: 40,
+	samples: 24,
+	/** a swimming lap never stalls below this speed, m/s */
+	minSpeed: 0.3,
+} as const;
+
+// --------------------------------------------------------------- flight
+
+/**
+ * A short flight across the pond: up off the water, a long low glide, a
+ * settle. A duck on the wing is over the far water in a few seconds, so a
+ * crust beyond a short paddle is worth flying for β€” the further off it comes
+ * down, the likelier the bird takes the air.
+ */
+export const FLIGHT = {
+	/** chance a bout that would be a swim becomes a flight (scaled in winter) */
+	chance: 0.18,
+	/** the speed it crosses the water at, m/s */
+	speed: 5.5,
+	/** bounds on how long a flight takes, s */
+	time: { min: 4.2, max: 8.5 },
+	/** how far a flight may reach across the pond, m */
+	reach: { min: 16, max: 34 },
+	/** how far from the thrower the flock's picture still reads */
+	far: 40,
+	/** height it rides over the surface, at scale 1, m */
+	peak: { min: 0.8, max: 1.45 },
+	/** clearance from other ducks when choosing a landing spot, body lengths */
+	clearance: 2.2,
+	/** attempts to find a clear, readable landing spot */
+	tries: 14,
+	/** seconds before a duck will take the wing again after landing */
+	cool: { min: 20, max: 45 },
+	/** flying for bread, as opposed to a lap on the wing */
+	food: {
+		/** nothing closer than this is worth the wing */
+		near: 15,
+		/** the chance rises from nothing here... */
+		far: 42,
+		/** ...to this at most */
+		max: 0.55,
+		/** the bird lands this far short and paddles the last of the way */
+		gap: { min: 1.4, max: 2.6 },
+	},
+} as const;
+
+// ---------------------------------------------------------------- bread
+
+/** Bread, from the hand to the last crumb. */
+export const BREAD = {
+	/** bread left uneaten sinks away after this long, s */
+	lifetime: 100,
+	/** ducks beg for this long once it is gone, s */
+	begTime: { min: 25, max: 40 },
+	/** where it leaves the viewer's hand, in camera-space metres */
+	throw: { along: 1.2, drop: 1.4, floor: 1.0 },
+	/** flight time in seconds as a function of throw distance, m */
+	time: { base: 0.45, perMetre: 0.04, min: 0.6, max: 1.9 },
+	/** arc height in metres over the same distance */
+	arc: { base: 1.0, perMetre: 0.06, min: 1.2, max: 5.5 },
+	/** a piece is two or three bites */
+	bites: { chance: 0.5, many: 3, few: 2 },
+	/** a floating crust nudges the surface a little, now and then */
+	ring: {
+		/** first ring after the splash, s */
+		first: { min: 0.7, max: 2.3 },
+		/** then every few seconds */
+		every: { min: 1.4, max: 4.2 },
+		/** ring scale and strength, relative to a splash */
+		scale: { min: 0.2, max: 0.3 },
+		strength: { min: 0.3, max: 0.45 },
+	},
+	/** the small ring a duck's nibble makes */
+	nibbleRing: { scale: 0.4, strength: 0.4 },
+	/** how long one bite takes, s */
+	biteTime: { min: 0.5, max: 1.2 },
+	/** how long a spent piece takes to shrink away after `lifetime`, s */
+	fadeTime: 4,
+	/** a floating piece rides the swell and bobs a little of its own */
+	float: { lift: 0.03, bob: 0.012, rate: 1.8 },
+	/**
+	 * Repeated taps on one spot: a landing point this close to bread already
+	 * there counts as the same spot, and the throw is skewed off the cursor by
+	 * a random bearing and distance. A few taps then make a scatter of crusts
+	 * beside each other instead of a stack of pieces intersecting.
+	 */
+	scatter: {
+		/** a landing this close to bread already there counts as the same spot */
+		crowded: 0.55,
+		/** the skew's near and far distance, m */
+		near: 0.24,
+		far: 0.6,
+		/** no two crusts may land closer than this: a piece is a 0.1 m pebble */
+		gap: 0.24,
+	},
+	/**
+	 * What a duck makes of bread on its way down. Every duck hears the splash,
+	 * the nearest first; a duck that saw the throw is off while it is still in
+	 * the air, and the rest start late like everyone else.
+	 */
+	notice: {
+		/** jitter around the splash, s */
+		hearJitter: { min: -0.15, max: 1.0 },
+		/** how much later a duck this many metres away hears it, s/m */
+		hearPerMetre: 12,
+		/** the share of the throw time a watching duck needs to set off */
+		seeEarly: { min: 0.25, max: 0.7 },
+	},
+} as const;
+
+// ---------------------------------------------------------------- sight
+
+/**
+ * What a duck can make out. The eyes sit wide on the head, but a bird facing
+ * away sees nothing of what lands behind it, so bread must come down inside
+ * this cone.
+ */
+const SIGHT_HALF = 1.15;
+
+export const SIGHT = {
+	/** how far a duck can make out bread, m */
+	range: 55,
+	/** half-angle of the cone, rad */
+	half: SIGHT_HALF,
+	/** its cosine, derived once for the dot product */
+	cos: Math.cos(SIGHT_HALF),
+} as const;
+
+// -------------------------------------------------------------- curious
+
+/**
+ * Now and then a duck notices the visitor on the bank rather than the other way
+ * round, and paddles over to the water in front of them to stand about and beg
+ * at nothing in particular. One bird goes at most every few minutes.
+ */
+export const CURIOUS = {
+	/** chance per bout, when the flock is near enough */
+	chance: 0.08,
+	/** how long it hangs about after arriving, s */
+	time: { min: 25, max: 60 },
+	/** flock cooldown between curiosity visits, s */
+	every: { min: 150, max: 420 },
+	/** only the flock whose water is this near the stand ever looks up */
+	gate: 46,
+	/** allowance for the paddle over, on top of the mark, s */
+	travelPad: 6,
+} as const;
+
+// ---------------------------------------------------------------- space
+
+/**
+ * Personal space. Every duck is a hull the others have to clear: half a body
+ * long and a quarter wide, times its own body scale, plus a little water
+ * between hulls β€” a hair's breadth inside a family, more between strangers.
+ * Steering nudges a duck off another's space; the hulls themselves are held
+ * apart, so a scrum on one crust can shoulder but never intersect.
+ */
+export const SPACE = {
+	hullLen: 0.31,
+	hullBeam: 0.18,
+	familyGap: 0.03,
+	strangerGap: 0.1,
+	/** how far outside the hulls the soft nudging starts, and how hard it pushes */
+	reach: 1.3,
+	push: 0.28,
+	/** settle passes each frame, and how much a pinned duck (sleep/upend) yields */
+	passes: 2,
+	pinnedWeight: 0.25,
+} as const;
+
+// ----------------------------------------------------------------- duck
+
+/** The rig's dials: gaze, tipping up, the waddle, sitting, and breathing. */
+export const DUCK = {
+	/** the cursor gaze: how far a head swings and how eagerly it answers */
+	look: { yawMax: 1.55, pitchMax: 0.72, sweep: 0.9, tilt: 1.1 },
+	/** how far a bottom-feeding duck tips over its waterline pivot, and the
+	 *  depth it needs under it, m */
+	upend: { pitch: 1.62, depth: 0.8 },
+	/** one hinge per leg at the hip, and the solved step it walks */
+	legs: {
+		/** hip to sole, rig units */
+		length: 0.15,
+		/** the ankle rides this far forward of the shank */
+		ankle: 0.027,
+		/** half the foot's thickness: sole to ankle */
+		foot: 0.007,
+		/** the hips sit this far either side of the spine */
+		hip: 0.055,
+		/** radians the hinge swings either side of centre */
+		swing: 0.5,
+		/** share of a step cycle a foot spends planted */
+		stance: 0.62,
+		/** sole clearance at mid-swing */
+		lift: 0.022,
+	},
+	/** the body's lean onto the planted foot, and the stern's answer */
+	waddle: { roll: 0.13, wag: 0.035 },
+	/** asleep on solid ground the hips fold under and the body settles in */
+	sit: { tuck: 1.55, drop: 0.04 },
+	/** the body's slow swell, independent of head and wings */
+	breath: { rate: 3.2, amount: 0.014 },
+	/** the idle float on the swell */
+	bob: { rate: 1.7, amount: 0.012 },
+} as const;
+
+// --------------------------------------------------------------- effects
+
+/** Ripples, splashes and the year's weather. */
+export const EFFECTS = {
+	ripple: {
+		/** how far a ring floats above the water, where its edge reads */
+		lift: 0.035,
+		/** lifetime range, s */
+		life: { min: 1.3, max: 1.8 },
+		/** ring scale over its life, as `from + t * by` */
+		grow: { from: 0.4, by: 2.4 },
+		/** how strongly a ring at full strength reads */
+		alpha: 0.55,
+		/** how many rings the pond keeps in flight */
+		count: 24,
+	},
+	/** pools of weather and water particles */
+	capacity: { feather: 180, bit: 320, leaf: 220, snow: 300 },
+	weather: {
+		/** leaves shed per unit of the crowns' thinning, and the debt cap */
+		leafShed: 900,
+		leafDebt: 24,
+		/** snow only falls above this, and fills a shoal of this many flakes */
+		snowFrom: 0.12,
+		snowCount: 260,
+		/** how many flakes may be added in one frame */
+		snowAdd: 6,
+		/** how far from the camera a crown must be for a shed leaf */
+		crownReach: 90,
+		/** the shoal around the camera: its inner radius and spread */
+		flakeNear: 6,
+		flakeRadius: 42,
+		/** reclaim a flake below the camera by this much, or this far out */
+		flakeDrop: 16,
+		flakeRecycle: 52,
+	},
+} as const;
diff --git a/src/duck.ts b/src/duck.ts
new file mode 100644
index 0000000..793d550
--- /dev/null
+++ b/src/duck.ts
@@ -0,0 +1,1462 @@
+// Ducks: the pond's residents, and the rig that draws them β€” a cut-down port
+// of the bird rig from our own bird world. The same quaternion-only pivots
+// and damped pose fields carry a swimming duck, a loafing one and one
+// waddling ashore; flight, trees and the hawk are gone. What they DO with
+// their time lives in pond.ts (the duck-pond activity model); this file is
+// rig, pose and species.
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { clamp, lerp, damp, dampK, smoothstep, TAU, valueNoise, Rng, angleDelta } from './utils';
+import { CALLS, DUCK } from './config';
+import type { World } from './world';
+import type { Effects } from './effects';
+
+export type DuckKind = 'hen' | 'drake' | 'duckling';
+export type Activity = 'swim' | 'dabble' | 'rest' | 'preen' | 'stray' | 'feed' | 'beg' | 'fly';
+export type Band = 'near' | 'mid' | 'far';
+
+/** The calls a duck can make; the synth is still to come, so these are labels. */
+export type CallKind = 'quack' | 'series' | 'decrescendo' | 'chatter' | 'rhaeb' | 'peeps' | 'contact' | 'brood';
+
+/**
+ * One call, at the moment it starts. The silent head/bill gesture is the
+ * visual stand-in for the missing synth; the event carries what the audio
+ * core will need to make the same call later.
+ */
+export interface CallEvent {
+	kind: CallKind;
+	/** the label the Rust visualizer shows, e.g. "quack quack" */
+	label: string;
+	/** notes in the call, or 0 for a run whose count the synth decides */
+	notes: number;
+	/** natural call length, s */
+	duration: number;
+	/** this call may set others answering (a hen's series or decrescendo) */
+	social: boolean;
+	/** this is an answer, so it cannot start another chain */
+	answering: boolean;
+}
+
+/**
+ * A lap of the lake the duck is swimming: a wandering round of open water the
+ * pond draws fresh each time β€” a circle of a random size through the bird,
+ * swum whichever way round comes up, bowed in and out as it goes, so no two
+ * laps read the same. The pond walks the mark along the round; the bird only
+ * follows its bill.
+ */
+export interface Lap {
+	/** the middle of the round */
+	x: number;
+	z: number;
+	r: number;
+	/** the angle it has reached, and the way round it is going (1 or βˆ’1) */
+	a: number;
+	dir: number;
+	/** phase of the wander that keeps the round from being a true circle */
+	wob: number;
+	/** radians of the round still to swim */
+	left: number;
+}
+
+const _q1 = new THREE.Quaternion();
+const _q2 = new THREE.Quaternion();
+const _AXIS_X = new THREE.Vector3(1, 0, 0);
+const _AXIS_Y = new THREE.Vector3(0, 1, 0);
+const _AXIS_Z = new THREE.Vector3(0, 0, 1);
+
+// A duck's pivots are posed by quaternion only: dropping the euler↔quaternion
+// sync three.js keeps on every object saves a decomposition on every write,
+// and nothing reads their euler angles back.
+function detachEulerSync(obj: THREE.Object3D): void {
+	const q = obj.quaternion as unknown as { _onChange?: (cb: () => void) => void };
+	if (q._onChange) q._onChange(() => {});
+}
+
+// ---------------------------------------------------------------- pose trig
+
+// The rig parts are posed by euler triples every frame, and three.js answers
+// each of those with six Math.sin/cos calls. Every half-angle the pose reaches
+// stays well inside Β±1.6 rad, so a small interpolated table answers them an
+// order of magnitude faster; the error is on the order of a microradian.
+const POSE_RANGE = 1.6;
+const POSE_N = 2048;
+const POSE_STEP = POSE_N / (2 * POSE_RANGE);
+const POSE_BIAS = POSE_N / 2;
+const POSE_SIN = new Float64Array(POSE_N + 2);
+const POSE_COS = new Float64Array(POSE_N + 2);
+for (let i = 0; i <= POSE_N + 1; i++) {
+	const a = (i - POSE_BIAS) / POSE_STEP;
+	POSE_SIN[i] = Math.sin(a);
+	POSE_COS[i] = Math.cos(a);
+}
+
+function halfSin(h: number): number {
+	if (h < -POSE_RANGE || h > POSE_RANGE) return Math.sin(h);
+	const u = h * POSE_STEP + POSE_BIAS;
+	const i = u | 0;
+	const f = u - i;
+	return POSE_SIN[i] + (POSE_SIN[i + 1] - POSE_SIN[i]) * f;
+}
+
+function halfCos(h: number): number {
+	if (h < -POSE_RANGE || h > POSE_RANGE) return Math.cos(h);
+	const u = h * POSE_STEP + POSE_BIAS;
+	const i = u | 0;
+	const f = u - i;
+	return POSE_COS[i] + (POSE_COS[i + 1] - POSE_COS[i]) * f;
+}
+
+/**
+ * The XYZ euler β†’ quaternion composition three.js applies, on table trig.
+ * Positive x tips a +z-facing part (bill, tail tip, wing) DOWN.
+ */
+function poseFromEuler(quat: THREE.Quaternion, x: number, y: number, z: number): void {
+	const s1 = halfSin(x * 0.5);
+	const c1 = halfCos(x * 0.5);
+	const s2 = halfSin(y * 0.5);
+	const c2 = halfCos(y * 0.5);
+	const s3 = halfSin(z * 0.5);
+	const c3 = halfCos(z * 0.5);
+	quat.set(
+		s1 * c2 * c3 + c1 * s2 * s3,
+		c1 * s2 * c3 - s1 * c2 * s3,
+		c1 * c2 * s3 + s1 * s2 * c3,
+		c1 * c2 * c3 - s1 * s2 * s3,
+	);
+}
+
+// sin(phase βˆ’ 1.1) and sin(phase βˆ’ 2.2) by the angle sum, from the one
+// (sin, cos) pair animate already takes of the wing phase
+const LAG_COS = Math.cos(1.1);
+const LAG_SIN = Math.sin(1.1);
+const LAG2_COS = Math.cos(2.2);
+const LAG2_SIN = Math.sin(2.2);
+
+// ------------------------------------------------------------------ the waddle
+//
+// A duck on land hinges each leg at the hip and nothing else. That is enough
+// to walk properly if the step is solved: while a foot is planted its sole
+// holds the spot it landed on β€” the hinge angle is the arcsine of a linear
+// ramp, which is exactly what pins a foot of length L to the ground the body
+// travels over β€” and the body drops by the height that leg's shortening takes
+// out of the hip, so the weight shift reads as a step rather than a bounce.
+// The swing brings the foot back to the front on an ease, sole lifted clear.
+const LEG_SIN = Math.sin(DUCK.legs.swing);
+// A duck asleep on solid ground sits, it does not doze off standing: the hips
+// fold right up under the body and the body settles onto its belly, so the legs
+// vanish into the plumage the way a real bird's do. It settles a feather's
+// depth *into* the shore or ice rather than kissing it tangentially β€” a body
+// that only touches looks like it is hovering, and the ice would cut the
+// contact as a hard line.
+
+/**
+ * How far the sole hangs below the hip at hinge angle `p` β€” the leg's
+ * vertical reach, with the ankle's forward offset tilting into it. The step
+ * cycle keeps the *lowest* leg's reach constant, which is what plants a foot.
+ */
+function legReach(p: number): number {
+	return DUCK.legs.length * Math.cos(p) + DUCK.legs.ankle * Math.sin(p) + DUCK.legs.foot;
+}
+
+/** A leg's place in the step cycle: hinge angle, hip lift, sole tilt. */
+interface LegPose {
+	swing: number;
+	lift: number;
+	toe: number;
+}
+
+const _legL: LegPose = { swing: 0, lift: 0, toe: 0 };
+const _legR: LegPose = { swing: 0, lift: 0, toe: 0 };
+
+/**
+ * Fill `out` with the leg's pose at cycle position `p` (0..1) at step
+ * amplitude `amp` (which damps to 0 when the duck stands still).
+ */
+function aimLeg(p: number, amp: number, out: LegPose): void {
+	if (p < DUCK.legs.stance) {
+		// planted: the sole keeps its hold on the ground the body crosses
+		out.swing = amp * Math.asin(LEG_SIN * (2 * (p / DUCK.legs.stance) - 1));
+		out.lift = 0;
+		out.toe = 0;
+		return;
+	}
+	// swinging: forward again on a smooth ease, sole lifted and toe up
+	const u = (p - DUCK.legs.stance) / (1 - DUCK.legs.stance);
+	const ease = u * u * (3 - 2 * u);
+	const bell = Math.sin(Math.PI * u);
+	out.swing = amp * DUCK.legs.swing * (1 - 2 * ease);
+	out.lift = amp * DUCK.legs.lift * bell;
+	out.toe = amp * 0.35 * bell;
+}
+
+// ------------------------------------------------------------ palette paint
+
+type PaintFn = (x: number, y: number, z: number, c: THREE.Color) => void;
+
+function paint(geo: THREE.BufferGeometry, fn: PaintFn): THREE.BufferGeometry {
+	const pos = geo.attributes.position;
+	const colors = new Float32Array(pos.count * 3);
+	const c = new THREE.Color();
+	for (let i = 0; i < pos.count; i++) {
+		fn(pos.getX(i), pos.getY(i), pos.getZ(i), c);
+		colors[i * 3] = c.r;
+		colors[i * 3 + 1] = c.g;
+		colors[i * 3 + 2] = c.b;
+	}
+	geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+	return geo;
+}
+
+function color(hex: number): THREE.Color {
+	return new THREE.Color(hex);
+}
+
+// ------------------------------------------------------------------ species
+
+export interface DuckSpecies {
+	kind: DuckKind;
+	label: string;
+	scale: number;
+	// body: the waterfowl loaf β€” long, flat, short-necked
+	bodyWide: number;
+	bodyTall: number;
+	bodyLen: number;
+	bodyLift: number;
+	headSize: number;
+	headPivot: [number, number, number];
+	tailPivot: [number, number];
+	tailLen: number;
+	tailRest: number;
+	wingScale: number;
+	wingPivot: [number, number];
+	// the flat spatulate bill
+	beakLength: number;
+	beakRadius: number;
+	beakTipRadius: number;
+	beakWidth: number;
+	beakFlat: number;
+	eyeOffset: number;
+	flapFreq: number;
+	flapAmp: number;
+	draft: number;
+	pal: Record<string, THREE.Color>;
+}
+
+function mallardBody() {
+	return {
+		scale: 1.45,
+		bodyWide: 1.05,
+		bodyTall: 0.74,
+		bodyLen: 1.85,
+		bodyLift: 0.0,
+		headSize: 1.15,
+		headPivot: [0, 0.095, 0.2] as [number, number, number],
+		tailPivot: [0.026, -0.27] as [number, number],
+		tailLen: 0.7,
+		tailRest: 0.2,
+		wingScale: 0.82,
+		wingPivot: [0.04, 0.03] as [number, number],
+		beakLength: 0.16,
+		beakRadius: 0.036,
+		beakTipRadius: 0.052,
+		beakWidth: 2.2,
+		beakFlat: 0.5,
+		eyeOffset: 0.07,
+		flapFreq: 4.9,
+		flapAmp: 1.1,
+		draft: 0.045,
+	};
+}
+
+/** The drake: the mallard from our bird world, exactly as it swam there. */
+export const DRAKE: DuckSpecies = {
+	...mallardBody(),
+	kind: 'drake',
+	label: 'mallard drake',
+	pal: {
+		backColor: color(0xb4b0a0),
+		bellyColor: color(0xe2ddcc),
+		wingColor: color(0xa8a497),
+		underColor: color(0x8f8b7d),
+		tipColor: color(0xf4f2ea),
+		tailColor: color(0x2b2b26),
+		streakColor: color(0x8f8a78),
+		cheekColor: color(0x8f6b50),
+		breastColor: color(0x6e4530),
+		headColor: color(0x276b3e),
+		speculumColor: color(0x3a58aa),
+		beakColor: color(0xc9b13c),
+		eyeColor: color(0x101010),
+		footColor: color(0xc98a3a),
+	},
+};
+
+/** The hen: the same loaf in mottled brown, with the mallard's blue mirror. */
+export const HEN: DuckSpecies = {
+	...mallardBody(),
+	kind: 'hen',
+	label: 'mallard hen',
+	pal: {
+		backColor: color(0x6f5638),
+		bellyColor: color(0xcdbb96),
+		wingColor: color(0x816846),
+		underColor: color(0xa89474),
+		tipColor: color(0xe4d7b8),
+		tailColor: color(0x4a3a28),
+		streakColor: color(0x4e3d28),
+		cheekColor: color(0xcbb68e),
+		breastColor: color(0xc3af8c),
+		headColor: color(0x96784f),
+		eyeStripeColor: color(0x5c4a30),
+		speculumColor: color(0x3b5aa0),
+		beakColor: color(0xcf7f38),
+		eyeColor: color(0x101010),
+		footColor: color(0xc9762f),
+	},
+};
+
+/** The duckling: a rounder, fluffier loaf with a dark bill and eye-stripe. */
+export const DUCKLING: DuckSpecies = {
+	kind: 'duckling',
+	label: 'duckling',
+	scale: 0.5,
+	bodyWide: 1.06,
+	bodyTall: 0.9,
+	bodyLen: 1.35,
+	bodyLift: 0.015,
+	headSize: 1.35,
+	headPivot: [0, 0.1, 0.16],
+	tailPivot: [0.03, -0.24],
+	tailLen: 0.5,
+	tailRest: 0.16,
+	wingScale: 0.6,
+	wingPivot: [0.03, 0.02],
+	beakLength: 0.085,
+	beakRadius: 0.02,
+	beakTipRadius: 0.028,
+	beakWidth: 1.5,
+	beakFlat: 0.62,
+	eyeOffset: 0.075,
+	flapFreq: 6.0,
+	flapAmp: 1.0,
+	draft: 0.032,
+	pal: {
+		backColor: color(0x9d7a42),
+		bellyColor: color(0xe9d9a4),
+		wingColor: color(0xb08d50),
+		underColor: color(0xd8c384),
+		tipColor: color(0xf2e6b8),
+		tailColor: color(0x8a6c3c),
+		streakColor: color(0x6e5426),
+		cheekColor: color(0xe6d49c),
+		headColor: color(0xdcc37f),
+		eyeStripeColor: color(0x513c1c),
+		beakColor: color(0x2f2b26),
+		eyeColor: color(0x101010),
+		footColor: color(0xb5742c),
+	},
+};
+
+export const SPECIES: Record<DuckKind, DuckSpecies> = {
+	drake: DRAKE,
+	hen: HEN,
+	duckling: DUCKLING,
+};
+
+// ------------------------------------------------------------------ geometry
+
+type Pal = Record<string, THREE.Color>;
+type ShapeParams = Partial<DuckSpecies>;
+
+function makeTorsoGeometry(pal: Pal, s: ShapeParams): THREE.BufferGeometry {
+	const body = new THREE.SphereGeometry(0.17, 16, 12);
+	body.scale(s.bodyWide || 0.92, s.bodyTall || 0.86, s.bodyLen || 1.5);
+	body.translate(0, s.bodyLift === undefined ? 0.015 : s.bodyLift, 0);
+	return paint(body, (x, y, z, c) => {
+		const t = clamp((y + 0.15) / 0.3, 0, 1);
+		c.copy(pal.bellyColor).lerp(pal.backColor, Math.pow(t, 0.7));
+		const streak = valueNoise(x * 24, z * 24 + y * 6, 4);
+		if (streak > 0.56) c.lerp(pal.streakColor, (streak - 0.56) * 1.3 * t);
+		// the ducks wear their own colour on the breast
+		const hl = 0.17 * (s.bodyLen || 1.5);
+		if (pal.breastColor) {
+			const front = clamp((z - hl * 0.15) / (hl * 0.4), 0, 1);
+			const low = clamp((0.07 - y) / 0.09, 0, 1);
+			c.lerp(pal.breastColor, front * low * 0.9);
+		} else if (z > 0.1 && y < 0.02) {
+			c.lerp(pal.bellyColor, 0.4);
+		}
+	});
+}
+
+function makeTailGeometry(pal: Pal, s: ShapeParams): THREE.BufferGeometry {
+	const len = s.tailLen || 1;
+	const tail = new THREE.BoxGeometry(0.16, 0.026, 0.3 * len);
+	const p = tail.attributes.position;
+	for (let i = 0; i < p.count; i++) {
+		const t = clamp((p.getZ(i) + 0.15 * len) / (0.3 * len), 0, 1);
+		p.setX(i, p.getX(i) * lerp(0.42, 1.12, t));
+		p.setY(i, p.getY(i) * lerp(1.15, 0.75, t));
+	}
+	tail.computeVertexNormals();
+	return paint(tail, (x, y, z, c) => {
+		c.copy(pal.tailColor);
+		const t = clamp((-z + 0.15 * len) / (0.3 * len), 0, 1);
+		if (t > 0.45) c.lerp(pal.tipColor, (t - 0.45) * 1.3);
+		if (y > 0) c.lerp(pal.backColor, 0.35);
+	});
+}
+
+/** The white of the eye: warm rather than paper, so it sits in a dull day. */
+const EYE_WHITE = color(0xf3eddc);
+
+/**
+ * Skull and upper bill bake into one mesh; the lower bill comes back on its
+ * own so a duck can open it β€” the silent gesture that stands in for a call.
+ */
+function makeHeadParts(species: DuckSpecies): { head: THREE.BufferGeometry; jaw: THREE.BufferGeometry } {
+	const pal = species.pal;
+	const hs = species.headSize || 1;
+	const parts: THREE.BufferGeometry[] = [];
+
+	const head = new THREE.SphereGeometry(0.105 * hs, 14, 10);
+	head.scale(1, 0.96, 1.04);
+	head.translate(0, 0.03 * hs, 0.05 * hs);
+	parts.push(
+		paint(head, (x, y, z, c) => {
+			if (pal.headColor) {
+				c.copy(pal.headColor);
+				if (pal.cheekColor && z > 0.02 && y > -0.01 && y < 0.04) c.lerp(pal.cheekColor, 0.3);
+				if (pal.eyeStripeColor && z > 0 && y > 0.012 * hs && y < 0.075 * hs) {
+					c.lerp(pal.eyeStripeColor, 0.75);
+				}
+			} else {
+				const t = clamp((y + 0.04) / 0.2, 0, 1);
+				c.copy(pal.bellyColor).lerp(pal.backColor, t * 0.9);
+				if (z > 0.02 && y > -0.01 && y < 0.04) c.lerp(pal.cheekColor, 0.55);
+			}
+		}),
+	);
+
+	// the upper bill: half the slab, riding a touch high
+	const upper = new THREE.CylinderGeometry(species.beakTipRadius, species.beakRadius, species.beakLength, 10);
+	upper.scale(species.beakWidth, 1, species.beakFlat * 0.52);
+	upper.rotateX(Math.PI / 2);
+	upper.translate(0, 0.022 * hs + 0.004 * hs, (0.125 + species.beakLength / 2) * hs);
+	parts.push(paint(upper, (x, y, z, c) => c.copy(pal.beakColor)));
+
+	for (const sx of [-1, 1]) {
+		const eyeR = 0.022 * hs;
+		const ex = sx * species.eyeOffset * hs;
+		const ey = 0.052 * hs;
+		const ez = 0.128 * hs;
+		// the white is the eye itself; the pupil is a smaller ball set into it,
+		// its crown just proud of the sclera so a black disc reads inside the
+		// white from the side and from the camera's high shore. Both pupils
+		// look straight down the bill, not out to their own sides.
+		const eye = new THREE.SphereGeometry(eyeR, 12, 8);
+		eye.translate(ex, ey, ez);
+		parts.push(paint(eye, (x, y, z, c) => c.copy(EYE_WHITE)));
+		const pupilR = eyeR * 0.84;
+		const gx = 0;
+		const gy = 0.06;
+		const gz = 1;
+		const gl = Math.hypot(gx, gy, gz);
+		const off = eyeR - pupilR + eyeR * 0.25;
+		const pupil = new THREE.SphereGeometry(pupilR, 10, 8);
+		pupil.translate(ex + (gx / gl) * off, ey + (gy / gl) * off, ez + (gz / gl) * off);
+		parts.push(paint(pupil, (x, y, z, c) => c.copy(pal.eyeColor)));
+	}
+
+	// the lower bill: the other half-slab, pivoted at the base of the bill
+	const jaw = new THREE.CylinderGeometry(species.beakTipRadius * 0.88, species.beakRadius * 0.92, species.beakLength * 0.98, 10);
+	jaw.scale(species.beakWidth * 0.9, 1, species.beakFlat * 0.42);
+	jaw.rotateX(Math.PI / 2);
+	jaw.translate(0, -0.002 * hs, species.beakLength / 2);
+	paint(jaw, (x, y, z, c) => c.copy(pal.beakColor).multiplyScalar(0.93));
+
+	return { head: mergeGeometries(parts, false), jaw };
+}
+
+function shapeFrom(points: number[][]): THREE.Shape {
+	const shape = new THREE.Shape();
+	shape.moveTo(points[0][0], points[0][1]);
+	for (let i = 1; i < points.length; i++) shape.lineTo(points[i][0], points[i][1]);
+	shape.closePath();
+	return shape;
+}
+
+function makeWingGeometries(pal: Pal, s: ShapeParams): { inner: THREE.BufferGeometry; tip: THREE.BufferGeometry } {
+	const ws = s.wingScale || 1;
+	const innerShape = shapeFrom([
+		[0.0, 0.105],
+		[0.2, 0.088],
+		[0.4, 0.055],
+		[0.385, -0.085],
+		[0.2, -0.16],
+		[0.02, -0.175],
+	]);
+	const tipShape = shapeFrom([
+		[-0.015, 0.06],
+		[0.14, 0.035],
+		[0.31, -0.02],
+		[0.27, -0.085],
+		[0.1, -0.125],
+		[-0.015, -0.1],
+	]);
+	const depth = 0.014;
+
+	const inner = new THREE.ExtrudeGeometry(innerShape, { depth, bevelEnabled: false, curveSegments: 1 });
+	inner.scale(ws, 1, 1);
+	inner.rotateX(Math.PI / 2);
+	paint(inner, (x, y, z, c) => {
+		const span = clamp(x / 0.4, 0, 1);
+		const under = y < -depth * 0.5;
+		c.copy(under ? pal.underColor : pal.wingColor);
+		c.lerp(pal.tipColor, Math.max(0, span - 0.5) * 0.6);
+		// the speculum rides the middle of the wing's leading edge
+		if (!under && pal.speculumColor && x > 0.15 && x < 0.3) c.lerp(pal.speculumColor, 0.7);
+		if (!under && z > 0.02) c.lerp(pal.backColor, 0.4);
+	});
+
+	const tip = new THREE.ExtrudeGeometry(tipShape, { depth: depth * 0.8, bevelEnabled: false, curveSegments: 1 });
+	tip.scale(ws, 1, 1);
+	tip.rotateX(Math.PI / 2);
+	paint(tip, (x, y, z, c) => {
+		const span = clamp((x + 0.015) / 0.325, 0, 1);
+		const under = y < -depth * 0.4;
+		c.copy(under ? pal.underColor : pal.wingColor);
+		c.lerp(pal.tipColor, 0.3 + span * 0.65);
+		if (!under && z > 0.02) c.lerp(pal.backColor, 0.35);
+	});
+
+	return { inner, tip };
+}
+
+/**
+ * One leg, hinged at the hip: a shank and a webbed foot. Both sides share
+ * these geometries; the hinge groups are mirrored by their own position.
+ */
+function makeLegParts(pal: Pal): { shank: THREE.BufferGeometry; foot: THREE.BufferGeometry } {
+	const shank = new THREE.CylinderGeometry(0.015, 0.012, DUCK.legs.length, 5);
+	shank.translate(0, -DUCK.legs.length / 2, 0);
+	const foot = new THREE.BoxGeometry(0.06, 0.014, 0.08);
+	foot.translate(0, 0, 0.005);
+	return {
+		shank: paint(shank, (x, y, z, c) => c.copy(pal.footColor)),
+		foot: paint(foot, (x, y, z, c) => c.copy(pal.footColor)),
+	};
+}
+
+interface VariantParts {
+	torso: THREE.BufferGeometry;
+	head: THREE.BufferGeometry;
+	jaw: THREE.BufferGeometry;
+	tail: THREE.BufferGeometry;
+	inner: THREE.BufferGeometry;
+	tip: THREE.BufferGeometry;
+	legs: { shank: THREE.BufferGeometry; foot: THREE.BufferGeometry };
+}
+
+const VARIANT_CACHE = new Map<DuckKind, VariantParts>();
+let DUCK_MATERIAL: THREE.MeshStandardMaterial | null = null;
+
+function variantParts(species: DuckSpecies): VariantParts {
+	let parts = VARIANT_CACHE.get(species.kind);
+	if (!parts) {
+		const wings = makeWingGeometries(species.pal, species);
+		const headParts = makeHeadParts(species);
+		parts = {
+			torso: makeTorsoGeometry(species.pal, species),
+			head: headParts.head,
+			jaw: headParts.jaw,
+			tail: makeTailGeometry(species.pal, species),
+			inner: wings.inner,
+			tip: wings.tip,
+			legs: makeLegParts(species.pal),
+		};
+		VARIANT_CACHE.set(species.kind, parts);
+	}
+	return parts;
+}
+
+function duckMaterial(): THREE.MeshStandardMaterial {
+	if (!DUCK_MATERIAL) {
+		DUCK_MATERIAL = new THREE.MeshStandardMaterial({
+			vertexColors: true,
+			roughness: 0.78,
+			metalness: 0.03,
+			side: THREE.DoubleSide,
+		});
+	}
+	return DUCK_MATERIAL;
+}
+
+// --------------------------------------------------------------- the duck
+
+/** The damped fields of the folded-wing pose. */
+interface WingPose {
+	amp: number;
+	freq: number;
+	base: number;
+	sweep: number;
+	tip: number;
+	fold: number;
+	asym: number;
+	wrap: number;
+	phase: number;
+}
+
+export interface DuckDef {
+	name: string;
+	kind: DuckKind;
+	/** Hen: (f0, f2) of her quack; drake: (rhaeb, -); duckling: (peep top, -). Kept for a later pass at voices. */
+	voice: [number, number];
+	pos: { x: number; z: number };
+}
+
+export class Duck {
+	name: string;
+	kind: DuckKind;
+	voice: [number, number];
+	species: DuckSpecies;
+	world: World;
+	effects: Effects;
+	rng: Rng;
+	scale: number;
+	root: THREE.Group;
+
+	// sim state (read and written by pond.ts β€” this is the Duck record of
+	// duck-pond's scene.rs, in world x/z instead of the listener's frame)
+	pos: { x: number; z: number };
+	vel: { x: number; z: number };
+	facing: { x: number; z: number };
+	target: { x: number; z: number };
+	cruise: number;
+	activity: Activity = 'rest';
+	activityLeft: number;
+	leader: Duck | null = null;
+	rank = 0;
+	offset = { x: 0, z: 0 };
+	offsetLeft = 0;
+	band: Band = 'mid';
+	notice: number | null = null;
+	eating = 0;
+	lost = false;
+	/** countdown to the next call, s */
+	callTimer = 0;
+	/** the call being made, if any; held for its natural length */
+	call: CallEvent | null = null;
+	callLeft = 0;
+	/** this call is an answer, and may not set anyone else off */
+	answering = false;
+	/** a hen about to call her brood together, before a swim bout */
+	gathering = false;
+	begLeft = 0;
+	ate = false;
+	lastBite: { x: number; z: number } | null = null;
+	preFeed: { activity: Activity } | null = null;
+	/** where the food it is swimming to lies, for the head to lean on */
+	headAim: { x: number; z: number } | null = null;
+	/** how close the bill, not the chest, needs the centre to the food */
+	feedStop = 0;
+	/** the raw pan and tilt the pointer asks of this duck, if any */
+	headLook: { yaw: number; pitch: number } | null = null;
+	floating = true;
+	posY = 0;
+	/** on the wing: the pond flies it, the rig spreads the wings and pitches it */
+	flying = false;
+	/** the lap of the lake it is swimming, if it is on one */
+	lap: Lap | null = null;
+	/** seconds before it will consider flying again: a bird just down stays down */
+	flyCool = 0;
+	/** the pond's flight plan, from the moment it leaves the water */
+	flight: { fromX: number; fromZ: number; toX: number; toZ: number; t: number; dur: number; peak: number } | null = null;
+	/** height over the water, the body's attitude (positive tips the bill
+	 *  down) and how far through the flight it is β€” set by the pond each frame */
+	flyH = 0;
+	flyPitch = 0;
+	flyPhase = 0;
+
+	// rig
+	torso: THREE.Mesh;
+	headPivot: THREE.Group;
+	headMesh: THREE.Mesh;
+	jawPivot: THREE.Group;
+	jawMesh: THREE.Mesh;
+	tailPivot: THREE.Group;
+	tailMesh: THREE.Mesh;
+	wingR: THREE.Group;
+	wingL: THREE.Group;
+	tipR: THREE.Group;
+	tipL: THREE.Group;
+	hipL: THREE.Group;
+	hipR: THREE.Group;
+	ankleL: THREE.Group;
+	ankleR: THREE.Group;
+	footL: THREE.Mesh;
+	footR: THREE.Mesh;
+	/** both feet, for probes; 0 is the βˆ’x (left) foot, the one `cycleL` drives */
+	feet: THREE.Object3D[];
+	meshes: THREE.Mesh[];
+
+	// pose fields
+	state: 'swim' | 'loaf' | 'walk';
+	action: string | null = null;
+	actionTime = 0;
+	actionDuration = 0;
+	wing: WingPose = { amp: 0, freq: 1, base: 0, sweep: 0, tip: 0, fold: 0, asym: 0, wrap: 0, phase: 0 };
+	wingTarget: WingPose = { amp: 0, freq: 1, base: 0, sweep: 0, tip: 0, fold: 0, asym: 0, wrap: 0, phase: 0 };
+	headYaw = 0;
+	headPitch = 0;
+	headYawTarget = 0;
+	headPitchTarget = 0;
+	jawOpen = 0;
+	jawOpenTarget = 0.02;
+	tailPitch = 0;
+	tailPitchTarget = 0;
+	tailPose: number | null = null;
+	tailYaw = 0;
+	tailSpread = 1;
+	tailSpreadTarget = 1;
+	legTuck = 1.3;
+	legTuckTarget = 1.3;
+	/** how far into a bottom-feeding tip-up the body is, 0..1 */
+	upend = 0;
+	upendTarget = 0;
+	peckPhase = 0;
+	roll = 0;
+	walkPhase = 0;
+	/** how deep into the step cycle the legs are, 0..1: 0 standing, 1 walking */
+	stride = 0;
+	/** how far a sleeping duck has settled onto its belly, 0..1 */
+	sit = 0;
+	posOffsetY = 0;
+	breath = 1;
+	heading: number;
+	quat = new THREE.Quaternion();
+	wanderSeed: number;
+	wakeTimer = 0;
+	gestureTimer = 0;
+	onPeck: ((d: Duck) => void) | null = null;
+	/** emitted when a call starts, for effects and the future audio core */
+	onCall: ((d: Duck, call: CallEvent) => void) | null = null;
+	private _peckHit = false;
+	private _upendPhase = 0;
+	private _preenSide = 1;
+	private _sleepSide = 1;
+	private _gestureLeft = 0;
+	private _fxTimer = 0;
+	private _v = new THREE.Vector3();
+
+	constructor(def: DuckDef, world: World, effects: Effects, rng: Rng) {
+		this.name = def.name;
+		this.kind = def.kind;
+		this.voice = def.voice;
+		this.species = SPECIES[def.kind];
+		this.world = world;
+		this.effects = effects;
+		this.rng = new Rng((rng.next() * 0xffffffff) >>> 0);
+		this.scale = this.species.scale * rng.range(0.97, 1.03);
+
+		this.pos = { x: def.pos.x, z: def.pos.z };
+		this.vel = { x: 0, z: 0 };
+		this.facing = { x: 0, z: -1 };
+		this.target = { x: def.pos.x, z: def.pos.z };
+		this.cruise = def.kind === 'hen' ? 0.45 : def.kind === 'drake' ? 0.5 : 0.33;
+		this.activityLeft = rng.range(0, 4);
+		this.callTimer = this.rng.range(CALLS.first.min, CALLS.first.max);
+		this.wanderSeed = rng.range(0, 999);
+		this.heading = rng.range(0, TAU);
+		this.quat.setFromAxisAngle(_AXIS_Y, this.heading);
+
+		this.state = 'loaf';
+		this.root = new THREE.Group();
+		this.root.name = `duck-${def.name}`;
+		this.root.userData.duck = this;
+
+		const parts = variantParts(this.species);
+		const mat = duckMaterial();
+
+		this.torso = new THREE.Mesh(parts.torso, mat);
+		this.torso.castShadow = true;
+		this.root.add(this.torso);
+
+		const hp = this.species.headPivot;
+		this.headPivot = new THREE.Group();
+		this.headPivot.position.set(hp[0], hp[1], hp[2]);
+		this.headMesh = new THREE.Mesh(parts.head, mat);
+		this.headMesh.castShadow = true;
+		this.headPivot.add(this.headMesh);
+
+		this.jawPivot = new THREE.Group();
+		this.jawPivot.position.set(0, 0.02 * (this.species.headSize || 1), 0.125 * (this.species.headSize || 1));
+		this.jawMesh = new THREE.Mesh(parts.jaw, mat);
+		this.jawMesh.castShadow = true;
+		this.jawPivot.add(this.jawMesh);
+		this.headPivot.add(this.jawPivot);
+		this.root.add(this.headPivot);
+
+		const tp = this.species.tailPivot;
+		this.tailPivot = new THREE.Group();
+		this.tailPivot.position.set(0, tp[0], tp[1]);
+		this.tailMesh = new THREE.Mesh(parts.tail, mat);
+		this.tailMesh.castShadow = true;
+		this.tailPivot.add(this.tailMesh);
+		this.root.add(this.tailPivot);
+
+		const ws = this.species.wingScale || 1;
+		const wp = this.species.wingPivot;
+		const makeWing = (side: 1 | -1) => {
+			const shoulder = new THREE.Group();
+			shoulder.position.set(0.072 * side, wp[0], wp[1]);
+			shoulder.scale.set(side, 1, 1);
+			const hand = new THREE.Group();
+			hand.position.set(0.4 * ws, 0, 0);
+			const inner = new THREE.Mesh(parts.inner, mat);
+			inner.castShadow = true;
+			const handInner = new THREE.Mesh(parts.tip, mat);
+			handInner.castShadow = true;
+			hand.add(handInner);
+			shoulder.add(inner, hand);
+			this.root.add(shoulder);
+			return { shoulder, hand, inner, handInner };
+		};
+		const right = makeWing(1);
+		const left = makeWing(-1);
+		this.wingR = right.shoulder;
+		this.tipR = right.hand;
+		this.wingL = left.shoulder;
+		this.tipL = left.hand;
+
+		const makeLeg = (side: 1 | -1) => {
+			const hip = new THREE.Group();
+			hip.position.set(DUCK.legs.hip * side, 0, 0.005);
+			const shank = new THREE.Mesh(parts.legs.shank, mat);
+			shank.castShadow = true;
+			const ankle = new THREE.Group();
+			ankle.position.set(0, -DUCK.legs.length, DUCK.legs.ankle);
+			const foot = new THREE.Mesh(parts.legs.foot, mat);
+			foot.castShadow = true;
+			ankle.add(foot);
+			hip.add(shank, ankle);
+			this.root.add(hip);
+			return { hip, ankle, foot };
+		};
+		const legR = makeLeg(1);
+		const legL = makeLeg(-1);
+		this.hipR = legR.hip;
+		this.hipL = legL.hip;
+		this.ankleR = legR.ankle;
+		this.ankleL = legL.ankle;
+		this.footR = legR.foot;
+		this.footL = legL.foot;
+		this.feet = [legL.foot, legR.foot];
+
+		this.meshes = [this.torso, this.headMesh, this.jawMesh, this.tailMesh, right.inner, right.handInner, left.inner, left.handInner, this.footR, this.footL];
+		this.root.scale.setScalar(this.scale);
+		this.posY = this.world.waterLevel + this.species.draft * this.scale;
+		this.root.position.set(this.pos.x, this.posY, this.pos.z);
+
+		for (const part of [this.wingR, this.wingL, this.tipR, this.tipL, this.headPivot, this.jawPivot, this.tailPivot, this.hipR, this.hipL, this.ankleR, this.ankleL, this.root]) {
+			detachEulerSync(part);
+		}
+	}
+
+	// ------------------------------------------------------------- behaviour
+
+	/** The pond calls this when the activity model moves the duck on. */
+	setActivity(a: Activity): void {
+		if (this.activity === a) return;
+		this.activity = a;
+		this.gathering = false;
+		this.action = null;
+		this.actionTime = 0;
+		this.tailPose = null;
+		this._nextAction();
+	}
+
+	setState(name: 'swim' | 'loaf' | 'walk'): void {
+		if (name === this.state) return;
+		this.state = name;
+		// an action belongs to a state: nothing a swim was doing carries ashore
+		this.action = null;
+		this.actionTime = 0;
+		this.tailPose = null;
+		this._nextAction();
+	}
+
+	setStateFor(activity: Activity, floating: boolean): void {
+		this.floating = floating;
+		const s = activity === 'fly' ? 'swim'
+			: activity === 'rest' || activity === 'preen'
+				? (floating ? 'loaf' : 'walk')
+				: (floating ? 'swim' : 'walk');
+		this.setState(s);
+	}
+
+	startAction(name: string | null, duration = 0): void {
+		this.action = name;
+		this.actionTime = duration;
+		this.actionDuration = duration;
+		this._peckHit = false;
+		this._upendPhase = 0;
+		this.tailPose = null;
+		if (name === 'preen') this._preenSide = this.rng.sign();
+		if (name === 'sleep') this._sleepSide = this.rng.sign();
+	}
+
+	/** What this duck does next, given the activity it is on. */
+	private _nextAction():

[Diff truncated. Use the raw view or local clone for the full content.]