# Handover — Dynamo.js (2026-06-10; updated 2026-07-08, evening)

This is a **standalone git repo**, moved on 2026-07-08 from
`trocp/dynamo/dynamo-js/` (where the parent TROCP repo gitignored it) to
`~/Sites/jmckalex/software/dynamo-js/`.

> **2026-07-08 (evening) — EVERYTHING BELOW IS NOW COMMITTED.** Branch
> **`fix/audit-2026-07`** (off `fce43e2`) holds 27 commits: the three
> morning audit fixes (scale-relative eig3 + `classifyEigen` imEps;
> `Game.trilinear` matched to the 2x2x2 notebook's convention; outsideOption
> labels/Young blurb + catalogue pinning), then the **full commit plan below
> executed** (per-feature commits, each staged tree verified 13/13; steps 2+3
> merged into one arrowhead commit and steps 4+16 + the basin slider merged
> into one showcase commit — the shared code made cleaner splits fabrications),
> then the **remaining audit minors fixed** (oversize precomputed restPoints
> arrays drawn in full; `extraTrajectories: null` tolerated; nashStatus
> tolerance payoff-scale-relative; `flow()` negative-T; −Infinity poisoning;
> contourRes default + `contourGrid`/`basins`/`basinsRes` docs), and finally
> the regenerated artefacts. Suite: **13/13, 290 tests**. Jason reviewed the
> showcase and docs and **`main` was fast-forwarded to `9734da6`** on
> 2026-07-08; `fix/audit-2026-07` is now fully merged (safe to delete).
>
> Later that evening: Jason approved the Skyrms full-orbit drag (no
> `dragTMax` revert needed); the SVG id collision was fixed on `main`
> (`82736e7` — per-render sequence ids) and the 3-D arrow-length
> normalisation was matched to the 2-D renderer (`dd00303` — length and
> colour share t = √(speed/maxSpeed); payoff-scale-invariant field).
> Licence resolved 2026-07-08: **GPL-3.0-or-later**, chosen by Jason
> (the original was "free, open source" with a gnu.org link but named no
> licence; the port is an independent reimplementation, so the choice was
> free). Still open: NOTES.md's remaining open questions. The body below is the historical description of
> the work, kept for reference.

## TL;DR

**Nothing is committed** — Jason tests/batches before committing. `npm test` is green
(**13/13 suites, 237 tests**); `npm run build` and `npm run docs` have been run, so the
generated files are current. The working tree holds **four stacked batches** on top
of `fce43e2`:

1. **The 2026-06-07 batch** — five features (on-curve arrowheads, docs syntax
   highlighting, basins as smooth regions, `Random.simplex`/`stickBreak`,
   divide-the-dollar example). Verified, untouched except where noted below.
2. **The 2026-06-08 → 06-10 batch** — 3-D sampling density, **LaTeX arrowheads**
   (this *reworks* the 06-07 arrowhead glyph), **one-arrowhead-per-orbit**, and the
   **Skyrms chaos** showcase example. Verified.
3. **2026-06-10 (later): numerical audit + fixes** — a full audit of the
   hand-written solvers (integrators, dynamics, projection, eigensolvers, PRNG:
   all sound) found three real bugs in the *analysis* layer plus minor issues, all
   now fixed and regression-tested. See [Audit-fix batch](#audit-fix-batch-2026-06-10).
4. **2026-06-10/11 (latest): showcase audit + fixes** — a headless sweep of every
   explorer combination plus a real-browser (Playwright) pass found one rendering
   defect family and two performance cliffs, all fixed; plus UI polish. See
   [Showcase audit-fix batch](#showcase-audit-fix-batch-2026-06-1011).

See [Commit plan](#commit-plan) and the [file map](#uncommitted-file-map).

---

## Audit-fix batch (2026-06-10)

A full numerical audit (scripts preserved in `/tmp/dynamo-audit/`) verified the
core solvers — RK4 at true 4th order, every DP45 coefficient checked, conserved
quantities to ~1e-7 over t=200, all 15 dynamics exactly simplex-tangent and
forward-invariant under fuzz, the projection dynamic bit-identical to a
brute-force cone projection on 4000 cases, TemperedLogit/SelMut/Smith/SampleBR
verbatim against the Mathematica notebooks. Three real bugs (all in the analysis
layer) plus minor issues were found and are now fixed:

1. **`numericStability` called rotating attractors saddles** — the radial test
   reads the symmetric Jacobian part, so BNN/Smith at the RPS centroid (which
   *converge*, spiralling) drew a saddle glyph. Now a two-stage test: unanimous
   radial verdicts stand; mixed signs are adjudicated by flowing the probes
   (`flowT 100`) and judging each on the last fifth of its run. All canonical
   cases verified (BNN/Smith@RPS → sink, projection@RPS → centre, BNN@StagHunt
   mixed → still saddle, BR@MP → 'centre' = honest no-verdict for ~1/t
   convergence — a documented, deliberate limit). Known coarseness kept: escape
   + radially-contracting directions reads saddle, which can mislabel a strongly
   rotating *source* (e.g. BNN@badRPS) — unchanged from before, no cheap local
   probe separates them.
2. **`normaliseState` laundered NaN into the barycentre** — Maynard Smith on
   Matching Pennies (F̄ crosses 0) teleported to the centre and parked, a
   fabricated orbit; the rest-point finder kept NaN-residual candidates via the
   same `NaN >= tol === false` hole. Now: non-finite/non-positive population
   sums poison to all-NaN (renderers drop non-finite points — honest missing
   curves), and the residual test is NaN-rejecting. Decision: poison, don't
   throw (a throw would kill whole-SVG renders); MS stays selectable in the
   showcase (strict payoff positivity would disable it on every catalogue game).
3. **`maxLyapunovExponent` magnitude garbage at boundary attractors** (−119.8
   where truth ≈ −1): the companion's renormalised offset clamped onto the
   simplex face, collapsing the separation. Now Benettin with the *achieved*
   separation as each step's baseline (companion clamped at placement time),
   plus feasible initial perturbation and reseed-on-collapse. Coordination
   vertex sink now −0.96; goodRPS still −0.1673 (= Jacobian to 3 dp); Skyrms
   still +0.0064.
4. **`nashEquilibria`** now throws a clear message off `dims [2,2]` (used to be
   a bare TypeError on `[3]`, NaN-poisoned garbage "equilibria" on the cube).
5. **RK45 first step clamped to `tMax`** (used to overshoot when `tMax < dt`);
   `trajectory()` now returns a **`completed`** flag so the exactBR-under-rk45
   sliding-mode truncation is detectable (docs say: use rk4 for exactBR).
6. **`iLogit` sharp-η NaN at vertices** fixed two ways: shift by the max payoff
   over the *support* of x (cancels exactly; stops all-weight underflow) and an
   explicit 0 weight for absent strategies (0·e^{huge} = 0·∞ = NaN otherwise).
   Vertices now correctly give ẋ = 0.
7. **Docs**: `latexHead` JSDoc corrected in both renderers (concave sides +
   flat back, two cubics + a line); `_api.html` rows updated for all the above
   and a **new contour.js section** (the Lyapunov exponent was undocumented).

New regression tests in `state`, `integrator`, `rest-points`, `stability`,
`render`, `dynamics`, `contour` suites (suite went 196 → 225 tests). The
stability flow fallback costs ~35–77 ms per *ambiguous* rest point (unanimous
cases stay sub-ms); if 3-D drag ever feels heavier with BNN-style dynamics, memo
the classification per (game, dynamic, rest point) in the demo — deliberately
not pre-built into the library.

---

## Showcase audit-fix batch (2026-06-10/11)

A demo-page audit: every (shape × game × dynamic × toggle) combination driven
headlessly through the page's exact render paths (script preserved at
`/tmp/dynamo-audit/audit-demo.mjs`), plus a real-browser Playwright E2E
(`/tmp/dynamo-audit/browser/e2e.mjs` — Playwright installed *outside* the repo,
which stays zero-dependency). Findings, all fixed:

1. **NaN robustness (the one rendering defect).** Maynard Smith on zero-sum RPS
   (mean payoff ≡ 0, so the field is NaN/±∞ everywhere) leaked 224 `NaN`
   attributes into the default SVG, and crashed the whole portrait when the
   speed heat map was on (`heatColor(NaN)` → `stops[NaN].slice`). Fixes:
   `sampleScale` reads non-finite t as 0; the 2-D field guard checks *every*
   component; speed maxima accumulate NaN-safely in both renderers. MS on
   zero-sum now renders the frame with honestly-missing layers.
2. **sampleBR was O(seconds) on the cube** (~5.5 s per portrait at k = 4; k = 12
   would have been minutes). The empirical profiles are a fixed grid — only the
   weights depend on the state — so the BR at each profile is now cached per
   game (WeakMap), keyed by the head-counts packed into one integer, with the
   profile decoded only on a cache miss (hot loop allocates nothing). k = 4:
   5.5 s → ~0.35 s; k = 12: ~14 s → ~3.8 s (still a noticeable freeze at the
   slider maximum — accepted as an extreme-corner cost).
3. **3-D drag at ~5 fps with BNN/Smith** (the flow-based stability test, run per
   drag frame, on ambiguous rest points). Both renderers' `restPoints` option
   now also accepts a precomputed `{state, type}[]`; the showcase computes it
   once per selection (`restDataFor`, keyed game|dynamic|params) and hands it to
   every render — worst drag frame went 216 ms → 61 ms, all combos now ≥ 16 fps.
   The readout shares the cache (its duplicate classification is gone).
   ⚠ While adding this I briefly moved classification before the
   degenerate-manifold cap in the renderers — cyclic4 (28-point continua) went
   to 2.2 s/frame; caught by re-running the sweep. **Cap before classify** is
   load-bearing; both renderers and `restDataFor` honour it (30 in 2-D, 22 in
   3-D).
4. **UI polish:** legend's saddle swatch now has its inner dot (it was
   indistinguishable from source); basins show "Computing basins of
   attraction…" before the synchronous compute (deferred-render pattern,
   `render(now)`); heat-map/basins toggles grey out on 3-D shapes and their
   detail panels hide (they were inert but clickable); the Skyrms exhibit now
   honours user-added trajectories (`[ex.seed, ...state.seeds]`); drag ends on
   pointercancel/window-blur too (no more stuck rotation); the render-error
   fallback escapes the message; hero copy fixed ("the interval" removed —
   there is no interval geometry; "15 dynamics" matches the library registry).

E2E (24 checks, all passing in headless Chromium): load, gallery (14 cards),
click-to-drop on the square, 50 select-driven combinations, layer toggles,
basins busy-hint flow, tetra drag-rotate, disabled-toggle states, Skyrms
forced-replicator + typed trajectory, cube sampleBR at k = 4 and k = 12, SVG
download, zero console errors.

---

## Earlier session's work (2026-06-08 → 06-10, all verified, all uncommitted)

### 1 · Finer 3-D initial conditions + tetra vector field
The 3-D shapes shared one grid number, so at the old default the tetrahedron drew a
**single** trajectory (its `interior(4)` is just the centroid) and **4** field arrows.
Fix: **per-shape default densities** live on each 3-D geometry, and the 3-D renderer
reads `opts.trajGrid ?? geometry.trajGrid ?? 4` (and the same for `vectorGrid`, `?? 5`).
- `src/geometry.js`: tetra `{trajGrid:7, vectorGrid:8}` (1→20 seeds, 4→35 arrows),
  cube `{trajGrid:4, vectorGrid:5}` (27/64, unchanged), prism `{trajGrid:5, vectorGrid:5}`
  (9→24 seeds). 2-D geometries untouched (fall back to the renderer's 5/11).
- `src/render-3d.js`: wires the `??` chain. Explicit `opts` still win.
- `docs/_api.html`: the 3-D `trajGrid`/`vectorGrid` rows now say "per shape".

### 2 · LaTeX arrowheads (supersedes the 06-07 on-curve glyph)
Trajectory arrowheads are now a filled **`-{Latex}`** head (TikZ arrows.meta): sharp
tip, *gently concave* sides, **flat back**. New helper **`latexHead(tx,ty,ux,uy,len,halfW,color)`**
is duplicated in both renderers (dimension-agnostic, builds the path from `P(a,w)`).
- Control points are taken **verbatim from the PGF source**: along-axis `.877192/.337381`
  → `0.123/0.663` from the tip; across `.077922/.519480`.
- Size scales with the drawing box: `headLen = 0.021 * min(width,height) * arrowSize`,
  `halfW = 0.375 * headLen` (width = 0.75 × length, the `Latex` default).
- Centred on the `arrowAt` fraction (default `[0.5]`); `arrowSize` is a multiplier.
- The vector-**field** arrows still use the old small-triangle `arrow()`/`arrow2d()`
  helpers — unchanged. `docs/_api.html` has `arrowAt`/`arrowSize` rows describing this.

This replaced *three* earlier attempts (recorded under [Gotchas](#gotchas--lessons)):
3×-line-width chevron → convex-sided latex (too wide) → concave + deep notch (a "kite").

### 3 · One arrowhead per orbit (the "3 heads on RPS" bug)
Symmetric seed grids put several trajectories on the **same** orbit (RPS: three 120°
rotations tile one loop), each drawing its own head. Fix in both renderers' `drawTraj`
(new `dedup` arg, true for the auto-grid, false for user `extraTrajectories`): **union
trajectories whose drawn curves coincide and head only one per group.** Two match rules,
both needed:
- **Footprint overlap** > 0.85 (a loop traced in full from several seeds), and
- **endpoint chaining** (`end_i ≈ start_j` — arcs that tile one loop).

Three robustness choices (don't regress these):
- Compare in **WORLD coordinates** (`geometry.toPoint`), never the screen projection —
  a 3-D projection collapses distinct curves together (it was false-merging the cube).
- Overlap is **max-normalised** (`/ Math.max(|a|,|b|)`) so a short convergent tail
  *contained* in a long trajectory is **not** a match.
- High threshold (0.85): the measured gap is clean — same-orbit pairs ≥0.98, every
  convergent game ≤0.70. `eps = 0.03 × bounds-diagonal`.
- **Only heads are de-duplicated; all curves are still drawn**, so no orbit is lost.

Results: RPS 6→2 heads, Matching Pennies 16→3, cyclic4 20→8 (closed orbits, correct);
Hawk-Dove / Stag-Hunt / PD / coordination / prism unchanged; a few genuinely-coincident
3-D trajectories (cube, tetra) de-duplicated.

### 4 · Skyrms chaos example — **showcase only, NOT the library**
Jason's steer: a *demonstration* example belongs in the showcase, not `standardGames`.
Added to **`demo/template.html`** (→ baked into `showcase.html`); `src/game.js` untouched.
- The verbatim **ACT–Skyrms** payoff matrix (read off Fig 1A of arXiv:2504.00028,
  matching Skyrms 1992/1993, [philpapers SKYCAT](https://philpapers.org/rec/SKYCAT)):
  ```
          A      B      C      D
    A  [  0    -0.6    0      1    ]
    B  [  1     0      0     -0.5  ]
    C  [ -1.05 -0.2    0      1.75 ]
    D  [  0.5  -0.1    0.1    0    ]
  ```
- **Verified chaotic** with the repo's own tools: interior strange attractor (Shilnikov),
  all four strategies persist, recurrent, **max-Lyapunov ≈ +0.012 > 0**. Orbit starts at
  the centroid `(0.25,0.25,0.25,0.25)` — the published IC.
- Mechanism: a showcase-only `EXTRA_GAMES` map + `extraGamesFor()`; `buildGameSelect`,
  `currentGame`, the 3-D branch of `render()`, and the game `onchange` all understand
  inline games. One shared factory `skyrmsChaosGame` feeds **both** the gallery card and
  the explorer entry. Selecting it forces the replicator and draws a **single centroid
  orbit** (grid off).
- Horizon: explorer `tMax: 1000` (~20 000 steps — the integrator's `maxSteps` cap — for a
  clear, layered attractor); gallery card `tMax: 500`.
- **Drag performance:** initially used a short orbit while dragging (`dragTMax: 300`) and
  the long one when settled (with a settle-`render()` added on `pointerup`). Per Jason's
  request to stress-test his Mac, **`dragTMax` was removed** — rotation now re-integrates
  the full `tMax:1000` orbit every frame (~57 ms/frame in Node ⇒ ~18 fps ceiling; the
  browser adds the DOM swap). **To restore smooth drag, re-add `dragTMax: 300`** to the
  `skyrmsChaos` entry — the `render()` logic (`dragging && ex.dragTMax ? …`) and a comment
  are still in place. **Awaiting Jason's verdict on whether his Mac copes.**

A memory note `dynamo-examples-vs-library` was saved (demo examples → showcase gallery /
inline games, not `standardGames`).

---

## Earlier batch (2026-06-07) — still uncommitted, mostly unchanged

- **On-curve arrowheads** — *superseded* by §2 above (kept the on-curve/arc-length
  placement idea; replaced the glyph and sizing).
- **Docs syntax highlighting** — `make-docs.js` tags each `<pre><code>` and pulls
  highlight.js 11.9.0 (github theme) with a warm-panel CSS override.
- **Basins as smooth regions** — `basinRegions(game, dynamic, geometry, opts)` in
  `src/basins.js` (marching-squares + bisection + Chaikin, clipped to the shape);
  `basinOf` gained chunked early-stopping + a nearest mode. Demo basin slider.
- **`Random.simplex` / `Random.stickBreak`** — `src/prng.js`, documented in `docs/_api.html`.
- **Divide-the-dollar example** — `examples/divide-the-dollar/` (untracked): a Skyrms
  reproduction + a staged write-up page. Uniform sampling → ~61% fair division;
  stick-breaking → ~34%.

---

## Gotchas / lessons

- **TikZ `latex` arrowhead = concave sides + a FLAT back** (the arrows.meta `-{Latex}`).
  Convex sides read as "too wide/curved"; a deep back notch reads as a "kite"/stealth.
  Don't re-derive it — the control points in `latexHead` are the verbatim PGF values.
- **Skyrms's chaos matrix is *not* cleanly online** — only the paywalled paper and an
  illegible figure. It was read off **Fig 1A of arXiv:2504.00028**. Reconstructing it from
  the ACT *interaction* matrix via the standard LV→replicator embedding **fails** (a
  strategy goes extinct → motion collapses to a 3-strategy face → cannot be chaotic, by
  Zeeman). **Always verify a candidate numerically** (`maxLyapunovExponent > 0` *and* all
  strategies persist over a long, recurrent run) before trusting it.
- **Orbit-dedup pitfalls:** compare in world coords (projection collapse), max-normalise
  overlap (containment false-merges), use a high threshold (clean 0.98-vs-0.70 gap).
- **The integrator caps at `maxSteps = 20000`** (≈ `tMax 1000` at `dt 0.05`). The renderers
  don't pass `maxSteps` to `trajectory()`, so a longer/denser attractor needs a new
  `maxSteps` render option (see [Still open](#still-open)).
- **The 3-D explorer re-renders per drag frame** (RAF-throttled, handlers on `window`).
  Long trajectories re-integrate every frame — the cost is the chaos demo's open question.
- `??` and `||` can't be mixed without parens (used in the `trajGrid ?? … ?? 4` chains).

---

## Uncommitted file map
On top of `fce43e2`, branch `main`:

| File | Touched by |
|---|---|
| `src/geometry.js` | §1 (per-shape 3-D densities) — 06-08→10 batch |
| `src/render-3d.js` | §1, §2, §3 (densities + LaTeX heads + dedup) over the 06-07 arrowhead/basin edits; audit batch (latexHead JSDoc); showcase batch (NaN-safe maxima, restPoints array form, cap-before-classify) |
| `src/render-2d.js` | §2, §3 (LaTeX heads + dedup) over the 06-07 arrowhead/basin edits; audit batch (latexHead JSDoc); showcase batch (field guard, NaN-safe maxima, restPoints array form, cap-before-classify) |
| `docs/_api.html` | §1, §2 + 06-07 (highlighting, Random helpers) + audit batch (stability/Lyapunov/trajectory/nashEquilibria/MS/exactBR rows, new contour section) + showcase batch (restPoints array form, heatColor NaN note) |
| `demo/template.html` | §4 (Skyrms gallery + explorer) + 06-07 (basin slider) + showcase batch (rest cache, busy hint, toggle states, seed merge, drag end, copy) |
| `src/basins.js`, `src/index.js`, `src/prng.js`, `make-docs.js` | 06-07 batch |
| `src/state.js`, `src/stability.js`, `src/contour.js`, `src/integrator.js`, `src/rest-points.js` | **audit batch only** (clean for `git add` whole-file) |
| `src/dynamics.js` | audit batch (iLogit) + showcase batch (sampleBR cache) |
| `src/colormap.js` | **showcase batch only** (NaN-safe sampleScale) |
| `test/state.test.js`, `test/integrator.test.js`, `test/rest-points.test.js`, `test/stability.test.js`, `test/contour.test.js` | audit batch (new regression groups) |
| `test/render.test.js`, `test/dynamics.test.js`, `test/colormap.test.js` | audit + showcase batches (regression groups) |
| `NOTES.md` | 06-07 banner pointing here |
| `dist/dynamo.js`, `showcase.html`, `docs/index.html` | **generated** — reflect all of the above |
| `examples/` (untracked) | 06-07 divide-the-dollar |
| `HANDOVER.md` (untracked) | this file |

## Commit plan
Nothing committed (Jason tests first). `src/render-2d.js`, `src/render-3d.js`,
`docs/_api.html` and the generated bundles interleave both batches, so clean per-feature
commits need `git add -p`. Note the §2 arrowhead rework **supersedes** the 06-07 on-curve
glyph — treat them as one "LaTeX arrowheads (on-curve placement)" commit rather than two.

A practical sequence:
1. `feat(geometry): per-shape 3-D sampling densities` — `src/geometry.js` + the `render-3d.js`
   `??` wiring + the `docs/_api.html` density rows.
2. `feat(render): LaTeX-style arrowheads, centred & box-scaled` — the `latexHead` blocks in
   both renderers + the `docs/_api.html` arrow rows. (Folds in the 06-07 arrowhead feature.)
3. `fix(render): one arrowhead per orbit` — the `dedup` blocks in both renderers.
4. `docs(showcase): Skyrms chaos example (gallery + explorer)` — `demo/template.html`.
5. The 06-07 features (basins, highlighting, Random helpers, divide-the-dollar) as before.

Then the audit-fix batch (each with its test-file counterpart; the six `src/`
files in this batch are whole-file clean, only `_api.html` and the renderers'
JSDoc need `git add -p`):

6. `fix(state): propagate NaN instead of laundering to the barycentre` —
   `src/state.js` + the `rest-points.js` residual guard + `test/state.test.js`,
   `test/integrator.test.js` (MS-on-MP group), `test/rest-points.test.js` (NaN group).
7. `fix(stability): adjudicate mixed radial signs by forward flow` —
   `src/stability.js` + `test/stability.test.js` + `test/render.test.js` (glyph group).
8. `fix(contour): renormalise Lyapunov companion against achieved separation` —
   `src/contour.js` + `test/contour.test.js`.
9. `fix(integrator): clamp first RK45 step; report truncation via completed` —
   `src/integrator.js` + `test/integrator.test.js` (horizons group).
10. `fix(dynamics): iLogit support-max shift removes vertex underflow` —
    `src/dynamics.js` + `test/dynamics.test.js` (sharp-η group).
11. `fix(rest-points): explicit dims guard on nashEquilibria` —
    `src/rest-points.js` (guard hunk) + `test/rest-points.test.js` (throws group).
12. `docs: arrowhead JSDoc, stability/Lyapunov/integrator API rows` —
    renderers' JSDoc hunks + `docs/_api.html` audit hunks.

Then the showcase batch:

13. `fix(colormap,render): NaN-safe speed colours and field guards` —
    `src/colormap.js` + the maxima/guard hunks in both renderers +
    `test/colormap.test.js`, `test/render.test.js` (graceful-degradation group).
14. `perf(dynamics): cache sampleBR profile best responses` — the sampleBR
    block in `src/dynamics.js` + `test/dynamics.test.js` (cube expectation group).
15. `feat(render): restPoints accepts a precomputed array (cap before classify)` —
    the rest-point blocks in both renderers + `test/render.test.js` (array-form
    group) + the `docs/_api.html` restPoints rows.
16. `feat(showcase): cached rest points, basins busy hint, 3-D toggle states, polish` —
    `demo/template.html`.
17. Regenerate artifacts last: `npm run build && npm run docs`.

Run `npm test` (expect 13/13 suites, 237 tests) before committing. End commit
messages for batches 1–2 (the feature work) with:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```
and for the audit-fix and showcase batches (6–17) with:
```
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
```

## Verify / rebuild
- `npm test` → 13/13.
- `npm run build` → regenerates `dist/dynamo.js` + `showcase.html` (injects the bundle into
  `demo/template.html`).
- `npm run docs` → regenerates `docs/index.html` from `docs/_*.html`.
- Open `showcase.html` → choose **Tetrahedron → Skyrms's chaotic game**; drag to rotate.
- Quick chaos check: build `Game.symmetric` with the matrix above, integrate the replicator
  from the centroid, confirm `maxLyapunovExponent > 0` and all four coordinates stay > 0.

## Still open
- **Skyrms drag performance** — pending Jason's test of the full-orbit-during-rotation
  behaviour; one-line revert (`dragTMax: 300`) if it's too heavy.
- **Optional chaos polish** (offered, not done): a `maxSteps` render option to push the
  attractor past 20 000 steps; a thinner/more-translucent stroke for the orbit to expose
  more of the sheet structure (needs a per-trajectory style option).
- From `NOTES.md` (unchanged, non-blocking): **licence choice — do not invent one**; exact
  cube/prism preset payoffs; congestion/quadratic game constructors; the 3S spherical (Akin)
  view; per-dynamic `StableGameLyapunov` contour functions.
