# Changelog

All notable changes to Physics Sandbox land here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added (wind & drag zones — engine upgrades P1)
- **New scene entity: zones.** `world.zones[]` (engine.js) holds plain data records — `{ type: 'wind', x, y, w, h, fx, fy }` or `{ type: 'drag', x, y, w, h, coeff }` — not Bodies, so they add zero solver changes. A single force-field pass at the top of `World._substep` (before gravity/force integration, composing with the optional `preSubstep` hook the same way the orbit preset's point-gravity does) applies: **wind** = a constant force scaled by the body's area inside the zone (`F = (fx, fy) · areaInside`); **drag** = linear drag opposing velocity, also area-scaled (`F = -coeff · v · areaInside`). Overlap is the body's AABB-vs-zone-rect overlap *fraction*, scaled by the body's real shape area — cheap, no new collision geometry, a fair approximation for a force field (P2's buoyancy zones will want exact clipped-area math; wind/drag doesn't need it). Because density still drives mass (`mass = area · density`), the drag force reduces to a density-only terminal velocity (`v_term = density·g/coeff`) — a light body visibly levels off while a denser one keeps accelerating past it in the same zone, without any special-casing.
- **`ZoneRegistry`** (engine.js) mirrors `ConstraintRegistry`'s shape — `serialize`/`deserialize` own each zone type's field mapping, so `app.js`'s scene (de)serializer stays type-agnostic and P2's water zones will slot in without touching `app.js`.
- **"Zones" tool section** (2D-only, new): **Fan** — drag a rect, then drag an arrow for wind direction + strength (arrow length sets magnitude); **Air** — drag a rect for an instant drag zone (default coefficient 1.2, tunable after placement). Zones render as translucent tinted rects — wind in tertiary blue with a direction arrow, drag in amber with a live `c=` readout — with a dashed in-progress preview while dragging (Fan's pending rect shows a "drag to set wind direction & strength" hint before the arrow stage).
- **Zone context menu** (long-press or right-click a zone): Delete for both types; drag zones get a live coefficient slider (0.05–5) that updates the zone in real time and commits one undo entry on release. Pin/Material are hidden for zones (they're body-only actions) — fixed a real CSS bug caught by a verify screenshot along the way: `.ctx-item{display:flex}` was tying the UA `[hidden]{display:none}` rule at equal specificity, so the Pin button stayed visually shown even with `hidden` set; added an explicit `.ctx-item[hidden]` override (same pattern the top-level `.ctx-menu[hidden]` rule already used).
- **Delete tool + Clear scene** remove zones (Clear's "only if non-empty" undo-snapshot guard now also checks `world.zones.length`, so a scene holding only zones is still undoable). **Undo/Redo**: zone create/delete join the existing position-free structural signature (`sceneSig()` now includes `world.zones.length`) so canvas-gesture creation/deletion auto-commit exactly like bodies; the coefficient-slider edit follows the same explicit `pushUndo()`-before-mutation pattern as the per-body Pin/Material actions (a ctx-menu action, not a canvas gesture).
- **Serialization**: scene-file `SCHEMA_VERSION` bumped 1 → 2 (adds a top-level `zones` array via `ZoneRegistry`). Older v1 files load unaffected — `zones` simply defaults to `[]`.
- **Education hook**: the pre-existing `drag` concept in `education.js` (previously hard to trigger) now fires as `{type:'drag'}` the first time any zone actually exerts force on a body (one-shot per session, from the engine's own zone-force pass) — makes it a first-class lesson instead of dead content.
- **Verification**: new `tests/zones.test.mjs` (13 cases — wind push/deflection, drag deceleration + the light/heavy terminal-velocity separation, out-of-zone no-op, `World.clear()` empties zones, `ZoneRegistry` round-trips + malformed-input handling) — unit suite now 42/42. New `verify-zones.mjs` drives the live app end-to-end with real mouse gestures (Fan's two-stage drag, Air's single drag, a real slider drag on the coefficient control — not `locator.fill()`, which skips the pointerdown a genuine drag always fires) plus direct engine stepping for the physics acceptance bar: a thrown ball visibly deflects crossing a wind zone, and a light/heavy body pair dropped in a drag zone separate (light body settles within 0.4 m/s of its computed terminal velocity while the heavy body's speed is 2×+ higher) — 33/33 checks, 0 console errors. `probe-physics.mjs` stays byte-identical across all 12 existing presets (zones never populated for any of them, so the force-field pass is a pure no-op guard there).

### Added (content pack — 3 more presets)
- **Catapult** — seesaw lever pinned to a static fulcrum with a real `RevoluteConstraint` hinge (first preset to use a joint), pre-tilted with a light payload on the low arm; a dense ball drops onto the raised arm and flings the payload at a 3-block tower. Torque, lever arms and momentum transfer in one beat; education layer surfaces Rotation & Angular Momentum.
- **Plinko** — Galton board: 33 static pegs in a widening triangle, 8 bin dividers, and 24 balls dropped from a compact cluster over the apex (deterministic sub-radius offsets — no `Math.random`; a naïve tall drop column would have spawned above the closed bounds ceiling and piled on top of it). Balls funnel through coin-flip peg strikes into a binomial pile — probability from pure collisions; education layer surfaces Collisions.
- **Bowling** — dense ball rolls in from the left with spin matched to its speed (rolling, not skidding) and scatters a rack of 5 light pins; education layer surfaces Momentum.
- All three join the `?preset=` deep-link whitelist and the preset grid (Catapult / Plinko / Bowling). Verified by new `verify-presets-pack.mjs`: catapult payload measurably flung down-range, ≥60% of plinko balls reach the bins, ≥2 pins topple, plus desktop + mobile-portrait renders.

### Added (basketball preset)
- **New "Hoops" preset** — basketball scene: pole + backboard + a two-nub rim whose clear opening is ~1.5× the ball diameter, one ball resting on the floor to shoot (grab-fling / impulse) and a second that spawns mid-flight on a solved projectile arc, so the preset swishes on load at any viewport span and any current gravity (zero-g degenerates to a straight-line shot). Ball tuned to a real basketball: restitution 0.8 (~64% rebound height, hardwood), hollow-light density 0.9, backspin on the demo shot. Deep-linkable (`/app/?preset=basketball` joins the whitelist); the education layer surfaces the Collisions / coefficient-of-restitution concept on load. Verified by new `verify-basketball.mjs` — demo arc passes through the rim (both balls end below rim height), floor ball spawns dead-still, desktop + mobile-portrait renders confirmed.

### Fixed (mobile UX pass 2026-07-02)
- **Topbar no longer clips a button behind Pro.** The mobile topbar tried to fit five controls (qc-toggle / pause / reset / clear / recenter) into a track that only holds four at 412px — the fifth (Recenter) scroll-clipped behind the Pro button and read as broken chrome. Clear screen is now mobile-hidden from that row (it stays one tap away in ⋯ → Clear all, and Reset covers the "run it again" loop); the remaining four fit without scrolling on 390px+ phones. Sub-375px devices that still scroll get a right-edge fade so a half-visible button reads as "more this way", not breakage.
- **Readings watermark no longer collides with its own collapse toggle.** The ▾ toggle (fixed at left:12) sat directly on top of the canvas-drawn PRESSURE label ("URE" was all that survived). The watermark block now draws at x=52, so the toggle sits beside the bottom row and reads as the block's control.
- **Mobile onboarding tour step 1 was anchored to a hidden element and pointed at UI that no longer exists.** It targeted `#lessonOverlay` — `display:none` on phones since Lesson moved into the ⋯ menu — so driver.js rendered the popover detached at the top of the screen, and the copy said "Tap the LESSON chip". Now anchored to the ⋯ button with copy that matches the real path (⋯ → Lesson → pick a level). The onboarding hint's "Tap a chip at the top (Tools / Lesson)" copy got the same correction.
- **Landscape phones get touch instructions.** The touch/mouse hint variant was chosen purely by width (≤768px), so a phone held sideways (e.g. 915×412) showed "Right-drag/scroll to pan/zoom". The hint now also flips on `(pointer: coarse)`, keeping the mouse copy on fine-pointer desktops.
- _Known-stale tests (pre-existing, reproduced on the untouched baseline):_ `verify-mobile-overlays.mjs` times out at line 122, `verify-spawn-mobile.mjs` fails its tap-to-spawn steps (the second-visit "What's new" coachmark overlay intercepts the synthetic tap — it doesn't pre-seed `ps.tourV2`), and `verify-mobile-framing.mjs` expects the retired home-camera desktop framing. Not regressions from this pass; suite needs a refresh pass of its own.

### Added (freeze-on-select)
- **Select can now freeze a body in place — an "alt form of select."** Normal Select still freezes a body *while* it's selected (for editing) and restores its dynamics on deselect, so a dynamic body falls when you deselect — which felt inconsistent. Now **Alt+click** a body in Select selects it **and** toggles "frozen in place": it stays static after you deselect instead of falling. A **lock handle on the gizmo** (top-right) does the same toggle without leaving Select, and the gizmo tints **amber** with a filled lock when a body is frozen-in-place. Both are undoable (`Ctrl+Z` unfreezes). Implemented purely via the gizmo's `wasStatic` (what the body restores to on deselect) — no engine change; normal Select is unchanged.
- **Undo/Redo snapshots are now gizmo-safe** (`snapshotScene`): a single helper captures the selected body in its real dynamics rather than the transient edit-freeze, so undoing a move/freeze never leaves a body stuck static. This also tightened the existing undo/redo paths.
- **Verification:** new `verify-freeze.mjs` drives the live app — confirms normal-select restores dynamics on deselect, Alt+click stays frozen after deselect, and `Ctrl+Z` reverses a freeze (9/9). Full suite stays green (verify 11/11 + freeze, unit 29/29, physics probe byte-identical). Help dialog updated.

### Added (desktop hotkeys & mouse combos)
- **Real multi-level Undo/Redo.** The old `Ctrl+Z` was single-level scene recovery (peel the last body / restore the last Clear). It's now a proper snapshot-based history (cap 50) built on the existing `serializeScene`/`deserializeScene`, capturing **user edits only** — spawn, delete, slice, material, springs/ropes/joints, gizmo move/rotate/resize, clear, reset, preset/scenario/file load, bounds + wall toggles. It deliberately never records a live physics frame (a position-free structural signature gates per-gesture commits), so `Ctrl+Z` restores your last *edit*, not a simulation step, even while the sim runs. `Ctrl+Z` = undo, `Ctrl+Y` **and** `Ctrl+Shift+Z` = redo; a new edit clears the redo stack. A Redo button joins the existing Undo control in the thumb dock (both enable/disable with stack depth). Undo/Redo restore scene content only — they don't touch the Reset baseline, pause state, gravity, level, view toggles, or camera (`deserializeScene(json, { forUndo })`).
- **`Shift`+Left-drag pans** the camera, alongside the existing `Space`+drag and middle-drag. Pan continuation/exit now key off the active pan drag rather than re-checking the modifier, so releasing Shift mid-pan can't strand you in pan mode.
- **`Delete` / `Backspace` removes the selected body** (undoable), so the gizmo selection has a keyboard delete that isn't the `X` delete-tool.
- **Smart right-click.** Replaces the old right-click→grab-tool behavior: (1) cancels an in-progress draw (mid spring/rope/joint/slice/bounds), else (2) opens the per-body context menu when over a body (previously long-press only — now on desktop too), else (3) reverts to the default Select tool on empty canvas. The native context menu stays suppressed.
- **Help cheat-sheet refreshed** (`?`) with the full, current hotkey/combo reference (the old sheet listed stale bindings like "right-click drag to pan").
- **Verification:** a new `verify-hotkeys.mjs` drives the live app and confirms undo/redo of spawns (incl. `Ctrl+Shift+Z` + redo-cleared-by-new-edit), Shift-drag pan (camera moves, no spawn), Delete + undo, clear + undo, and smart right-click (empty→Select, cancel-draw adds no constraint) — 17/17. No engine changes; the physics probe stays byte-identical and the existing suite (unit 29/29, verify 10/10) is green.

### Changed (mobile overlays — quieter graph + readings, no obstruction)
- **The measurement Graph no longer reserves a big empty plot.** Enabling Graph with nothing selected used to leave a full-size dead `<canvas>` floating mid-canvas-bottom (just a grid + "Select a body to plot"). `app.js` now stamps `#ov-graphWrap[data-graph-valid]` from the live tracked-body check, and a mobile-only rule collapses the canvas (and drops the now-pointless Clear button) when the graph is on but no body is selected — so the card shrinks to its toggle + quantity + a one-line prompt (~100px) until there's actually data to draw, then the plot and Clear return on selection. The card is also smaller overall (`62vw/226px → 50vw/184px`, tighter padding) and recedes to 0.78 idle opacity, lifting to full only for a live plot or a touch.
- **Both bottom overlays now share one baseline.** The graph card dropped from `bottom: 116px` to `64px` to sit level with the readings HUD — readings bottom-left, graph bottom-right, both hugging the thumb dock as a single instrument row instead of two scattered cards at different heights. Verified at 412px: graph card overlaps neither the readings HUD nor the dock, and the two bottoms align within 6px.
- **Readings HUD recedes further + wraps cleanly.** Idle opacity 0.55 → 0.45 (full on touch/`:focus-within`), and the System/Bodies/Density/Pressure totals row now wraps with a 1px row-gap (was a ragged 14px-gap 3-line stagger) so it stays a tight, low block in the corner. Desktop is untouched — every rule lives in the mobile `@media` and `data-graph-valid` is inert where no rule reads it; the existing 31-check mobile-overlay suite still passes 31/31, and a new `verify-overlays-declutter.mjs` drives the live app to confirm the empty-state collapse, the on-selection plot return, and zero overlap (13/13).

### Fixed (mobile Graph panel layout)
- **The Build-3 measurement Graph no longer overlaps the readings HUD on phones.** It was injected inside the 128px naked-text Live Readings HUD, which has `container-type: inline-size` + `overflow: hidden` + `pointer-events: none` — so on portrait it crammed the Graph/Velocity/Clear controls into a wrapping stack, clipped the System/Bodies row behind them, bled the canvas over the hint bar, and the controls weren't even tappable (they inherited the HUD's `pointer-events: none`). On mobile `layout.js` now re-homes `#ov-graphWrap` to `<body>` (same DOM-move pattern as the HUD and Recenter), and a mobile-only `@media` block styles it as a standalone anchored card top-right under the topbar — its own background/border/z-order, `pointer-events: auto`, a clean wrapping control row with 30px touch targets, and a collapse-when-off rule (only the controls show until you enable it). The System/Bodies readout stays in the bottom-left HUD, fully visible and unobstructed; the graph canvas (when on) renders in its own card, clear of the readout and the hint bar. **Desktop is untouched** — the relocation is gated to mobile and the styles live in the phone `@media`, so the desktop graph keeps its in-panel layout (probe still 8/8 byte-identical). Verified at 390×844: readout readable, toggle + dropdown + Clear all tappable without overlap, canvas in its own space.

### Fixed (mobile portrait framing)
- **The sim fills the screen on tall phones.** On portrait phones (~390px wide) the world filled the canvas but presets size their geometry to `0.7·min(Wm, Hm)` — width-limited on a tall screen — so the scene landed in a short band at the bottom under a large dead zone (e.g. the tower spawned centred at 81% of the canvas height). A new **mobile-portrait-only** camera reframe (`frameContent`) now fits every non-wall body into the viewport with a margin and centres it on both axes, so the scene fills the width and sits in the middle (tower now centred at 50%, ~86% width-fill, zoomed in vs the home 1× framing; tall scenes like the pendulum fill the height too). It's a **pure camera transform** — never touches world/physics state — gated behind `isMobilePortrait()` (tall && width < 700), so **desktop framing is byte-identical** (the 1440×900 probe stays at the home 50/0/0 camera and 8/8 byte-identical). Applied on preset/scenario/challenge load, on resize, and via Recenter; desktop keeps the existing floor-anchored home framing untouched.

### Fixed (mobile topbar + HUD polish)
- **Recenter surfaced at top level on mobile.** The camera escape hatch lived under the Free/Wide/Track segment inside the Tools chip — two taps deep, exactly when the camera was lost. layout.js now relocates the button into the topbar group next to Pause and Reset (icon-only); desktop placement unchanged.
- **Quick Reset sized for touch.** Explicit 36×32 sizing parity with Pause in the mobile topbar (same defensive-display pattern); the armed "Reset?" confirm stays readable at 11px.
- **HUD empty-state collapse on mobile.** With nothing selected the always-on HUD now collapses to just the live System/Bodies row (16px). The "Tap a body to inspect" line is desktop's instruction — on phones it duplicated the onboarding hint and the two overlapped at bottom-left. Grid restores on selection as before.
- **● HUD cycler de-chipped.** The filled+bordered 30px circle read as stray debris floating over the canvas (it sat right on top of the hint text). Now a bare dim dot glyph with the same 30px hit box; tap-to-cycle (full → minimal → hidden) and drag-to-move unchanged.
- **Cradle portrait framing: accepted as-is.** Anchors at 15% with bobs at vertical center leaves headroom that grabbed bobs actually use (a thrown bob swings to and above pivot height), and the new visible world boundary makes the upper region read as part of the scene. Tightening home framing would clip those arcs and special-case share-link framing.
### Added (exploration scaffolding — graph, challenges, scenarios)
- **Live measurement graph in the Readings HUD.** Toggle "Graph" in the Live Readings panel to plot a tracked quantity — velocity, momentum, kinetic energy, or height — for the selected body over time, on a small auto-scaling canvas (~12 s scrolling window at the 15 fps overlay cadence). The trace resets when you select a different body, and a footer shows the current value + window max. It samples through a new **pure, read-only `bodyMeasurement(body, quantity, {floorY})`** helper in `engine.js` that now also backs the HUD's own speed/KE/momentum/PE readouts — one source of truth, so the readout and the plotted curve can never disagree. The helper is never called during a physics step; the probe is byte-identical across all 8 presets (same SHA256 as the prior build).
- **What-if challenge cards.** A "Challenges" tool section with four open-ended prompts that set up a starting scene but **never grade**: *Make it swing* (a fixed anchor to hang from), *Lift the block* (a cargo block + a high goal platform), *Stack 5 high* (six loose blocks), *Chain reaction* (a row of dominoes). Loading one frames a dismissable prompt banner; you solve it however you like with the existing tools.
- **Save-as-scenario.** A "My Scenarios" tool section lets you save the current contraption under a name and reload it later to tinker — a local (per-device) library layered directly on the existing `serializeScene`/`deserializeScene`, so saved scenarios carry bodies *and* constraints (joints included) and round-trip exactly. Distinct from the Pro file-export: this is a free, in-browser starting-point library.
- **Verifications:** unit tests `tests/measurement.test.mjs` (8 cases for the measurement helper, incl. read-only invariant) and an end-to-end `verify-build3.mjs` that drives the live app — confirms the graph accumulates a varying curve and actually draws a line (pixel-counted), a challenge card loads its scene + prompt, and a saved scenario round-trips bodies + constraints through the real save/load path. No physics-step changes; existing 8/8 probe presets remain byte-identical.

### Added (real joints + constraint-serialization registry)
- **Four new joint constraint types**, layered additively on the existing sequential-impulse solver with zero change to body/contact/`DistanceConstraint` behaviour (the physics probe is byte-identical across all 8 presets, same SHA256). Each implements the same `apply(dt, iter)` contract the solver loop already drives, so they slot into `world.constraints` alongside the existing distance/spring constraint. A "world anchor" is just a static body B, matching the spring/rope tools.
  - **Hinge** (`RevoluteConstraint`) — pins a shared pivot (2-DOF point-to-point) while leaving rotation free; a bar pinned at one end swings under gravity.
  - **Motor** (`RevoluteConstraint` with `motorEnabled`) — a hinge that drives relative angular velocity toward a target speed, capped by `maxMotorTorque` (per-substep impulse clamp); reaches and holds the target rate.
  - **Weld** (`WeldConstraint`) — rigid axle: point-to-point **plus** an angular lock to the creation-time relative angle, so the body neither falls nor spins.
  - **Slider** (`PrismaticConstraint`) — Box2D-style perpendicular-translation + angular block; the body may only translate along one axis (drag direction sets it). On a vertical axis it slides straight down; on a horizontal axis it resists gravity entirely.
  - All three solve as velocity constraints with the same soft Baumgarte bias (`0.2/dt · C`) the rigid distance constraint uses, so tuning stays consistent.
- **Constraint-serialization registry** (`ConstraintRegistry` in `engine.js`). The scene save/load path previously hardcoded a single `type: 'distance'` shape in both `serializeConstraint` and `deserializeScene`; the registry now owns each type's field mapping (type + anchors + per-type params), and `app.js` delegates to it. Adding a constraint type touches one place (the registry) instead of two app-level functions — net less surface, not more. Pre-registry scenes (no `type` field) still load as `distance` for backward compatibility.
- **Minimal joint UI** — a new "Joints" tool section (Hinge / Motor / Weld / Slider). Drag from a body to a second body, or to empty space for a world anchor; both anchors map to the shared pivot so rigid joints create with zero initial error (no snap). For the slider the drag direction is the axis. Each type gets its own canvas glyph (amber hinge ring, green motor ring with spin arrow, pink weld diamond, blue slider track) plus a live drag preview.
- **Verifications:** unit tests `tests/joints.test.mjs` (hinge swing, motor drive, weld hold, slider on both axes, registry round-trips — 8 cases) and an end-to-end `verify-joints.mjs` that builds all four joints in the live app, runs them, and round-trips through the real `PSandboxScene.serialize/deserialize` path — confirming the joints survive save/load with correct types/params and reproduce identical behaviour. Existing 8/8 physics-probe presets remain byte-identical.

### Fixed (stack/orbit correctness + gravity carryover)
- **The Stack preset stands dead-still.** It spawned eight square blocks with a 4% vertical gap (`i*bs*1.04`) and random lateral jitter, floating 0.2 m above the floor on an 8:1-aspect single column — so on load it dropped ~5 m and immediately toppled (measured spawn-drop 5.39 m, centroid drift 5.39 m). Now four square blocks on the floor in sub-slop (0.002 m) resting contact, perfectly aligned. Height is capped at 4 deliberately: a free-standing column is metastable (as in reality), and the physics probe shows N=4 settles to **true zero** (peak |momentum| 0, no rocking) while N=5–6 develop a slow rock that never sleeps and N≥7 topple on spawn. Flat-wide bricks were tried and rejected — their two contact points sit far apart, exciting a rock whose contact-count flips 2↔1 each step, dropping the warm-start cache and pumping +4% energy that sustains the wobble; square blocks rest cleanly. Verified: spawn-drop 0.0009 m, peak |momentum| 0.
- **Orbits stay bounded instead of spiralling into the sun.** The orbit preset used `GM = G·sunMass = 12000`, which at viewport-scaled radii put the inner planet at ~72 m/s — a period of only ~12 steps/orbit. Semi-implicit Euler then injected ~27% energy in 10 s and the inner planet spiralled in, pinned at the 80 m/s velocity clamp. `GM` now scales with `span³` (K=0.08), which makes every orbital *period* viewport-independent and sets the inner period to ~2 s (~120 steps/orbit). Verified: energy gain bounded to a +13% oscillation (was +27% secular), all three orbits hold with 9–12% radius drift, none escape or fall in, peak speed 8 m/s (well off the clamp).
- **Leaving the Orbit preset restores gravity.** Orbit forces gravity to 0 and syncs the slider so the UI doesn't read "9.81" over a zero-g world — but it left the slider pinned at 0, and `loadPreset` reads the slider, so loading **any** preset afterwards inherited zero gravity (stacks floated, the cradle never swung) until the user manually nudged the slider. The pre-orbit gravity is now stashed and restored whenever a gravity preset loads.
- **Added a headless physics-correctness probe** (`probe-physics.mjs`) that loads each preset, pauses the rAF loop, and deterministically steps `world.step(1/60)` to measure spawn-drop, settling, rest momentum, energy injection, and orbit stability — the regression harness behind the verifications above. Gated behind a `?probe=1` test hook in `app.js` (inert in normal use).

### Fixed (deep-stack true rest + KE readout)
- **28-body pyramid now settles to true zero.** The spawn fix + warm-starting stopped the gross collapse, but the 7-row pyramid still *crept* — a "resting" block carried real momentum (~0.3 kg·m/s) while the HUD rounded KE to 0. Two root causes: (a) the restitution cutoff was `|gravity|·dt ≈ 0.16 m/s`, so slow contacts deep in the stack kept getting micro-bounces that the velocity iterations *pumped* into a sustained jostle (more iterations made it worse, not better) — now restitution applies only to genuine impacts (fixed 1 m/s threshold, Box2D-style; the cradle's ~4 m/s hits still bounce as tuned); (b) an iterative solver always leaves residual velocity, so even a converged stack creeps forever — added a sustained-rest freeze that zeroes a body's velocity once its motion stays under threshold for 0.4 s, with contacts still solved every substep so a moving body wakes a frozen one. Solver iterations raised 5 → 12 to propagate support up the 28-body stack. Verified: peak |momentum| across all 28 bodies is **0** from t≈1 s through 10 s idle; cradle still conserves (transfer ratio 0.93, ≤ 1, decaying), wrecker still demolishes, bouncy balls still bounce.
- **KE readout no longer disagrees with momentum.** KE used `.toFixed(1)`; since KE ∝ v² it rounded small real motion (0.015 J at v = 0.1 m/s) to "0.0" while momentum ∝ v showed "0.30". KE now uses the adaptive `fmtNum` formatter (2 significant decimals / scientific for extremes), so a moving body never reads KE 0 while momentum is nonzero, and a truly-at-rest body reads 0 across the board.

### Fixed (dominoes spawn)
- **Every domino spawns upright.** The leftmost domino (index 0) was spawned pre-tilted (−0.35 rad) with spin (−1.5 rad/s) to auto-start the cascade, so it immediately fell flat while the other 11 stood upright. All 12 now spawn upright (angle 0, tall axis vertical) at zero velocity and evenly spaced, resting on the floor via a sub-slop overlap (no spawn drop), so the row stays at rest until the user knocks one over. Verified: 12/12 upright at spawn and still 12/12 upright after 10 s idle.

### Fixed (tower spawn stability)
- **Pyramid spawns dead-still.** The Tower pyramid (28 blocks) spawned ~0.15 m above the floor with 5% gaps, so it dropped and settled on every load. Blocks now spawn in resting contact — bottom row on the floor, each row above set by a sub-slop (0.002 m < 0.005 m slop) vertical overlap, with a 4% horizontal gap — giving zero lateral penetration and no spawn drop. Combined with contact warm-starting it stands dead-still (3 px centroid drift, 0 px top/height drift over 10 s idle). The Wrecker tower already used this spawn pattern and holds (4 px over 10 s with the ball removed).

### Fixed (stacking stability)
- **Contact warm-starting.** Contacts were rebuilt cold every substep (no impulse carried over), so a resting stack was re-supported from a zero guess each frame, under-converged, and slowly drifted/toppled — the Wrecker tower collapsed on its own before the ball arrived. The solver now caches each contact's accumulated normal/tangent impulses by body-pair and re-applies them as the next substep's seed, so resting stacks converge to steady support and stand at rest indefinitely. The Tower pyramid is now pristine; the cradle's momentum is unchanged (first-transfer ratio 0.96, still ≤ 1 and decaying — no energy added).
- **Wrecker preset reworked.** The target is now a tower of stacked wide blocks (support base ≫ height) spawned in sub-slop resting contact (no spawn drop or cascade), and the pendulum is anchored so the ball strikes at the bottom of its arc (max speed) instead of the slow far end where damping had killed the swing. Verified: tower holds 10 s idle (4 px drift, 0 J kinetic) and demolishes on impact (footprint spreads 5×, kinetic-energy spike).

### Changed (preset framing)
- **Every preset now scales to the viewport.** Stack/Pendulum/Cradle/Ramp/Orbits/Dominoes/Tower/Wrecker size their geometry to ~70% of the smaller canvas axis and centre horizontally, instead of spawning at fixed world coordinates. The cradle's ball radius was hard-clamped to ≤0.65 m so it looked tiny on wide desktops (now ~1 m, pack ~69% of width); the pendulum hung a fixed 3.2 m bob that flew off-screen on narrow phones (now length-scaled, with the release angle easing back where width is tight so the bob stays in frame). Swing-reach presets (cradle, pendulum, wrecker) clamp the pulled/released body so it can't overflow the side walls. Verified cradle + pendulum frame correctly with no overflow at 1440-wide desktop and 412-wide mobile. Floor-resting presets (stack/tower/dominoes/wrecker stack) keep their floor baseline; the ramp's ball now spawns on the raised end so it rolls down toward the wall.

### Fixed (physics — momentum conservation)
- **Split-impulse position correction.** The contact solver folded Baumgarte penetration recovery into the velocity impulse, so every penetrating impact gained real kinetic energy (~+17% on a typical cradle hit — the "snap"). Penetration is now resolved by a mass-weighted positional projection after integration; the velocity impulse carries restitution only. Stacks/resting contacts verified unchanged.
- **Cradle preset retuned.** Restitution 0.88 → 0.97 (hardened steel), linearDamping 0.10 → 0.01 (the heavy damping was masking the solver's energy injection and killed the cradle in seconds — "instant-stopping"). Measured: ball 5's first swing-out is 0.91 of ball 1's input (analytical for a gapped e=0.97 chain ≈ 0.94; never >1), decaying steadily past 40 s. Physics dt evaluated at 1/120 and deliberately kept at 1/60 — the rigid-constraint bias (0.2/dt) is tuned for it, and halving dt doubled the constraint's spawn-snap kick.

### Added (transform gizmo)
- **Select tool (V) — post-placement editing.** Click any body (walls included) to get a transform gizmo: drag the body to move, drag the dot above it to rotate about its center, drag the four oriented edge handles to resize (balls skip resize). While selected the body is frozen static so physics can't fight the edit; deselect (Esc, empty-canvas click, or tool switch) restores its dynamics with the new transform committed. Numerical readout beside the gizmo shows position, rotation in degrees, and w×h (or radius). Shift snaps rotation to 15° increments and sizes to 0.5 m steps. Also hardened the canvas pointer-capture call (unguarded `setPointerCapture` could kill the whole pointerdown handler on synthetic/stale pointers).

### Changed (camera UX)
- **Track mode collapsed into Free's lock-follow.** The sidebar segment is now just Free | Wide. Double-clicking a body in Free (which always lock-followed) now surfaces a "Following: <body>" pill with an unlock ✕ under the segment — the previously invisible state made explicit. Entering Wide stashes an active follow and returning to Free restores it if the body still exists; Recenter clears both. `setCameraMode('track')` survives as a legacy alias that maps to Free + lock-follow on the old Track subject (selected dynamic body, else first dynamic). Free's tooltip updated to "Pan/zoom · double-click body to follow". (Camera mode was never persisted to storage or scene files, so no data migration exists beyond the alias.)

### Added (display)
- **Visible world boundary.** The invisible four-wall bounding box now renders as a thin 1.5px rect in the grid tint (higher alpha) with corner ticks, drawn in world space so it pans/zooms with every camera mode. Edges flash a faint accent-green pulse (~220ms) when a body hits them. Toggleable as DISPLAY → Bounds; defaults ON at Curious/Student and OFF at University/Expert, persists in scene-file view blocks like the other display toggles.

### Added (toolbar)
- **Quick Reset Scene button** in the topbar between Pause and ⋯, same refresh glyph as the menu entry (which stays). Shares one `resetScene()` handler with the ⋯ → Scene → Reset scene item. Custom 200ms-delay tooltip. Scenes with >5 dynamic bodies arm an inline "Reset?" confirm that auto-dismisses after 2s; smaller scenes reset immediately.

### Fixed (camera modes)
- **Track follows a subject instead of AABB-fitting everything.** It used to frame the bounding box of every dynamic body, so one stray fragment stretched the frame until the scene shrank to a few pixels of dead space. Track now centers the selected dynamic body, falls back to the first dynamic body, then the scene centroid; home zoom, smooth follow.
- **Mode transitions are dt-correct (~300ms ease) and Track→Free freezes the camera in place** — context preserved instead of the old snap-to-home. The new **Recenter** button under the Free/Wide/Track segment is the explicit way back to the home framing.
- **Velocity arrows no longer float in dead space** — skipped for bodies that are off-canvas or render below 2px, so a long arrow can't hang on an invisible body at extreme zoom-out.

### Fixed (FPS readout)
- **FPS overlay no longer flickers 119.9↔120.0 at vsync.** The canvas HUD was painting the EMA with one decimal every frame. Now it shows a whole number re-sampled at 4 Hz (the existing α=0.08 EMA still smooths underneath), so the readout sits at "120" and only steps on real dips. Bodies line unchanged. Verified: 30 consecutive captures over 2.4s, zero repaints.

### Fixed (grab feel)
- **Desktop grab no longer launches bodies.** On precise pointers (`pointer: fine`) the mouse-joint spring drops ~45% stiffness and its force cap falls from 50g·mass to 8g·mass — turning the linear Hooke drive into capped-linear (proportional under ~0.4m of stretch, flat beyond), with damping left at full coefficient so tracking is slightly overdamped. A 24 m/s chase ceiling while held stops whipped drags from compounding (release throws unaffected). Touch keeps the legacy full-strength spring — fat pointers overshoot and need the pull. Measured: short drag peaks ~3 m/s and settles dead; whip bounded at 24 m/s on desktop vs 77 m/s on touch.

### Changed (desktop readings HUD)
- **True-translucent polish + empty-state collapse.** Background alpha 0.32 → 0.18 (one tinted surface — header/cells/footer all transparent); close ✕ stripped to a bare 14px glyph at 50% opacity (full on hover) instead of the green-bordered chip; "LIVE READINGS" label dims to 0.6 as a passive label. Empty state now collapses the panel to instruction + footer (height:auto overrides the float rect's fixed height — no more blank slab where the grid was) and inverts emphasis: "Tap a body to inspect" goes brightest while the System/Bodies values dim to 50%.
- **Live Readings restyled as an ambient HUD overlay** (MSI Afterburner / net_graph vibe). The floating card recedes into a single translucent strip: rgba-black 0.32 + blur(6px) backdrop, hairline border, no shadow; metric cells lose their solid boxes (bare 9px tracked labels over 13px values, 2-col); 75% opacity lifting to full on hover/drag/resize; default z-index drops below the educator card. Default rect is now top-right 290×232 (was 280×360) — persisted rects from the old solid-card era reset once via a rect schema version (`v:2`); drag/resize/dock all still work and persist. All 8 readings + System/Bodies footer retained.
- **Phantom pill above the Live Readings header.** The mobile HUD's ● view-cycler button is styled and wired only inside the mobile breakpoint, so on desktop it rendered as an inert, unstyled UA-default button — a blank full-width bar above the panel header (docked and floating). Now hidden at ≥769px; mobile HUD cycling/dragging untouched.

### Added (educator live gauges)
- **Momentum/energy lesson tags are now live mini-gauges.** The static `momentum` and `energy` topic pills at the bottom of tagged lessons (Collisions, Kinetic/Potential Energy, Conservation, Springs) read as broken empty gauges. They now carry a live value plus a proportional fill bar — p = m·v and KE = ½·m·v² of the selected body, falling back to system totals so they keep moving while watching collisions hands-off. Updates ride the existing 15fps overlay tick; fill uses a saturating v/(v+k) curve (half-full at 5 kg·m/s / 20 J). Other tags stay plain.

### Added (mobile launch polish)
- **Level toggle on mobile.** The chip strip hides the topbar level segment on phones, which left no way to change knowledge level at all — the educator panel body now carries its own synced Curious/Student/University/Expert segment (mobile-only). The tour got responsive steps (mobile targets the LESSON chip instead of the hidden segment) plus a safe-area clamp so popovers clear the notch. Bonus: the persisted level (`ps.educator.level`) is respected on boot again — `init()` had been hard-resetting to Curious since 1.0.1.
- **HUD empty state.** With nothing selected, the readings HUD shows a one-line "Tap a body to inspect" instead of six rows of zeros (System/Bodies stay live); HUD footprint at rest drops 126→54px.

### Fixed (scene render polish)
- Orientation marker (the white radius tick) draws only on rotating or selected bodies — resting cradle bobs no longer wear a permanent bisecting line.
- Cradle pack settles dead-even (gaps ±0.2px, was ±4px): friction 0→0.03 and damping 0.06→0.10 let lateral creep dissipate instead of micro-swinging for minutes.
- System energy readout smoothed (0.5s EMA, whole joules) — solver micro-dynamics kept the raw 0.1J display flickering. Display-side only.

### Fixed (mobile scene polish)
- **HUD re-homed to body on mobile** — it rode into the topbar with the chip strip, where the bar's `translateZ(0)` containing block resolved its bottom anchor against the 49px bar instead of the viewport (`top: -90px`, bleeding under the status bar through the chips at half opacity). Same containing-block fix as the ⋯ menu.
- **World now fills the canvas** — the `WORLD_H_PX = 600` clamp pinned a fixed band to the canvas bottom, leaving ~175px of dead space above the ceiling on portrait (and ~215px on desktop) and defeating the cradle preset's anchor-at-15% framing. Floor/walls/PE reference all track the new height. Old share-links still load paused at their exact saved positions; on play, floor-resting bodies settle to the (now lower) floor — same drift share-links always had across devices.
- **Cradle bobs no longer interpenetrate** — rest gap widened 0.04→0.13m (the old 2px gap sat below the solver's penetration slop) plus a darker 1px rim on the bobs (`b.outline` cosmetic render support).

### Added (onboarding)
- **First-visit tour** (driver.js 1.4, vendored). Four steps spotlighting the level toggle (the wedge), drag-to-spawn, the educator panel, and the ⋯ menu. Runs once (localStorage `ps.tourDone`), never in `?embed=1`; replay via Help → "Replay tour" or `?tour=1`. Dark-theme popover styling included.

### Fixed (interactions)
- **Micro-interactions un-frozen.** The anti-flicker rule froze `transition`/`animation` on every `.topbar` descendant (and a dead `.stage` selector), silently disabling the May-18 polish layer (hover-lift, tap-scale, springs) for all topbar controls — and for the new ⋯ menu. The freeze now covers only the `.topbar` container and `#canvas`; resize flicker protection remains via `body.resizing-dock` and `.panel.dragging`. `prefers-reduced-motion` support was already in place and still applies.

### Added
- **Lucide icon set.** All mixed text glyphs (❚❚ ⊞ ⋯ ↺ ‹ ⌄ × ⤢ 📌 🗑) replaced with inline Lucide SVGs (ISC license) — consistent stroke icons across topbar, ⋯ menu items, panel headers, context menu, hint, and zen toggle. Also fixes a latent bug: the pause-glyph string in app.js contained corrupted bytes (U+FFFD) from an old encoding mangle, so toggling pause rendered garbage characters.
- **Topbar progressive disclosure.** The topbar now holds five controls — Mode, Level, Pause, ⋯, Pro. Gravity/time-scale sliders, panel toggles, and all scene actions moved into a sectioned ⋯ dropdown (Simulation / Panels / Scene / View) that works on every viewport. Mobile gains gravity and time-scale control for the first time (they were hidden on phones). The bar-toggle collapse button and `.topbar.collapsed` machinery are gone.
- **KaTeX-typeset math.** Educator formulas now render as real typeset equations (vendored KaTeX 0.17, self-hosted — works offline and in the Android wrapper). Eight core concepts gained full multi-step **derivations** (work–energy theorem, momentum conservation, elastic collisions, SHM, pendulum period, g from universal gravitation, PE, energy conservation), shown at University/Expert levels. The Pro derivations export typesets the same content via CDN KaTeX with plain-text fallback.

### Fixed
- `android-app/sync.mjs` never copied `lib/` — the wrapped app 404'd on `stacklisPro.js` / `stacklisTouchFx.js`. Now copies `lib/` recursively.
- CSP `font-src` lacked `'self'`, which would have blocked self-hosted KaTeX fonts in production.

### Changed
- CI: GitHub Actions test workflow restored to `main` — tests run remotely again on push/PR (the workflow had only survived on a local backup branch after a workflow-scope push failure).
- `android-app/www/` is no longer tracked in git — it is a generated artifact of `sync.mjs` and had drifted 8 days stale. Run `npm run sync-web` before any Android build.
- `watchdog-physics-sandbox.ps1` quarantined as never-run (hard exit guard at top): port 8081 is supervised externally, and the script's blind respawn pattern is disallowed by the local persistence policy.

### Removed
- Dead `STRIPE_PAYMENT_LINK` alias in `app.js` — only `STRIPE_CHECKOUT_URL` was ever referenced. (`Pro.deactivate` was also audit-flagged as dead but is retained: the Pro sign-out button now calls it.)
- Tracked artifacts: `diag2-output.json` / `diag4-output.json` (stale Playwright diagnostics), `.superpowers/` brainstorm session state (now gitignored), and the empty `pnpm-lock.yaml` (npm's `package-lock.json` is the lockfile, refreshed to pin both devDeps).

## [1.0.3] — 2026-05-14

### Added
- Global `:focus-visible` keyboard-focus ring across all interactive elements (WCAG 2.4.7).
- Knowledge-level segment now has `role="tab"` + dynamic `aria-selected` on each button.
- Visible accent ground (teal glow line + faded band) so the world floor reads as a floor instead of empty canvas.

### Changed
- Boundary walls thickened from 0.4 m to 4.0 m (`maxLinearVelocity × fixedDt × substepCap`) — walls sit outside the visible canvas so this is invisible but eliminates tunneling.
- `endGrab` throw velocity now clamped to `world.maxLinearVelocity` at release (was uncapped — fast flicks could inject 100+ m/s in one frame and tunnel through walls).
- `DESTRUCTION_THRESHOLD` reduced from 100 m/s to 40 m/s so destruction stays reachable under the velocity cap.
- `enforceBodyCap` eviction loop rewritten — old form could null-deref on the first iteration when `oldest` was unset.
- Share-link `r1` (uncompressed) path now also enforces the 1 MiB scene cap.
- Space-bar tap vs hold disambiguated — tap toggles pause on keyup, hold-and-drag pans without toggling pause.
- Pop-out panel button label now reflects the actual action (`⊞` "Pop out" when docked, `✕` "Dock" when floating).
- Pan tool removed from 2D toolbar — right-click drag, Space+drag, and two-finger touch already cover pan; the dedicated button was redundant.

### Fixed
- Triangle bodies now display "Triangle" in the readings panel (was reading nonexistent `b.vertices`; corrected to `b.localVertices`).

### Tests
- Added 8 tests for the 2D engine (`tests/engine2d.test.mjs`) — was previously zero coverage on `engine.js`. New cases: Vec2 length/copy, falling-body integration, static immobility, factory shape kinds, `World.remove` constraint cleanup, `maxLinearVelocity` clamp, **thick-wall containment under high velocity (regression test for the boundary tunneling bug)**, and `DistanceConstraint` pendulum settling. Loader shim `tests/_load-engine2d.mjs` runs `engine.js` in a Node vm context with a synthetic `window`.

## [1.0.2] — 2026-05-14

### Added
- Share-link size caps (`SHARE_INPUT_MAX = 32 KiB`, `SHARE_DECOMPRESSED_MAX = 1 MiB`) — zip-bomb / oversized-input DoS defense on `#scene=` fragments.

### Changed
- Android `versionCode` 1→2, `versionName` "1.0"→"1.0.1" (Play Store upload prerequisite).
- Android FileProvider `cache-path` narrowed from `"."` to `"images/"`.
- Asset `?v=` query strings synced to v=85 (styles) / v=80 (app.js) across root, `app/`, `landing.html`, `privacy.html`, `terms.html`, and `android-app/www/index.html`.
- Repo hygiene: untracked the cached screenshot/diag artifacts that were committed before the matching `.gitignore` rule (already-ignored files only).

### Fixed
- `shadowBlur` on collision-contact visualization now skipped on touch devices (was the last hot-path shadowBlur left unguarded; springs and anchors were already gated in v1.0.1).
- Empty `catch {}` blocks around `localStorage.setItem(MODE_KEY, ...)` and the 3D `teardown` dynamic import now log to `console.debug` / `console.warn` so failures aren't silent.

## [1.0.1] — 2026-05-13

### Added
- `ps.educator.level` localStorage key — educator level now persists across reload.
- CSP + X-Frame-Options headers in `vercel.json`.
- `README.md`, `android-app/README.md`, `CHANGELOG.md`.
- GitHub Actions test workflow (`.github/workflows/test.yml`).

### Changed
- `package.json` scripts now bind `0.0.0.0:8081` (matches `start-server.bat` and the Stacklis ecosystem norm).
- `privacy.html` localStorage disclosure is now complete; the Pro flag honor-tier is documented explicitly.
- Android FileProvider `external-path` tightened from `"."` to `"Pictures/"`.

### Fixed
- `shadowBlur` on springs no longer drawn on touch (2–3 ms/frame mobile savings).
- Three.js `antialias` disabled on touch + low-DPI devices (5–8 ms/frame mobile savings).
- `JSON.parse` in `layout.js` panel state/rect now `try`/`catch`-wrapped (corrupted state no longer crashes init).

## [1.0.0] — 2026-05-10

### Added
- 3D mode (free, Three.js + Rapier3D).
- Save / load scenes (Pro).
- Export derivations (Pro).
- 4-level educator content (Curious / Beginner / Intermediate / Expert).
- Capacitor Android wrapper.
