fuzzyworld

tower / commit

feat(interior): eye-height gauge up to the beam, trackpad moves (D-082..D-084)

3e898b5156e7835af895c92bc3f6502eb14b960b · Pavel Safronov · 2026-09-28 15:06:59 -0700 · parents ab6ae94f1a98

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 4 tally 500 in / 220.0K out / 42.1M cached · usage by commit and session

feat(interior): eye-height gauge up to the beam, trackpad moves (D-082..D-084)

Eye height leaves the panel for a vertical gauge up the view's right
side, log scaled from 1.7 m to the beam's edge at 48.4 km, marked with
things of known height and saying which you are near ("~ the Eiffel
Tower"). The standing readout folds to its title. The field of view is
fixed at 60 deg, and the wheel - a trackpad's two-finger swipe - now
walks you over the ground instead of zooming. Stale docs that still
described rain as drawn are gone.

Usage: 0.5K in / 220.0K out / 42.1M cached (session tally)
rtk:   1.0M saved, 16.4% over 2947 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md              |  26 +++++---
 decisions.md           |  94 +++++++++++++++++++++++++++
 design.md              |  16 ++---
 index.html             |  16 +++--
 src/canon/motion.ts    |   2 +-
 src/style.css          |  64 +++++++++++++++++++
 src/ui/app.ts          |  66 +++++++++++--------
 src/ui/eye-gauge.ts    | 170 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/ui/scales.ts       |  12 +++-
 src/views/interior.ts  |  97 ++++++++++++++++++++--------
 test/eye-gauge.test.ts |  37 +++++++++++
 world-generation.md    |   2 +-
 12 files changed, 520 insertions(+), 82 deletions(-)

diff --git a/README.md b/README.md
index fbb6347..59f5d34 100644
--- a/README.md
+++ b/README.md
@@ -312,10 +312,11 @@ geography here is best seen by looking up.
 **One finger looks and two fingers move** (D-059). Dragging with two takes hold of
 the ground under them and keeps it under them, so the distance covered comes out
 right by itself and the tilt of your head is the gearing: level, a screen of drag
-walks you 1.9 km, and tipped 23° down it steps 26 m. The same two fingers pinch the
-field of view like a pair of binoculars. A
-mouse has only ever one pointer, so shift-drag or the middle button stands in for
-the second finger, and the wheel zooms. Selecting a place — in the list or by its
+walks you 1.9 km, and tipped 23° down it steps 26 m. On a trackpad the two-finger
+swipe moves you too, and so does a mouse wheel: a screen's swipe walks about 70 m from
+standing and some 40 km from a kilometre up, the ground following your fingers. A
+mouse has only ever one pointer, so shift-drag or the middle button stands in for the
+second finger. There is no zoom: the field of view is fixed at 60° (D-083). Selecting a place — in the list or by its
 caption — still **walks you there**, which is the only way to cross the world in
 one move: most of it is beyond the haze horizon from any given spot, so travelling
 is more use than turning.
@@ -420,12 +421,17 @@ its day. It says when the sun rises and sets and where it is now, and the map ma
 no sun, no airlight, no rod, and nothing left in the world but the sky. Twilight, Temperate
 and Full set how bright the day is; night comes by itself, with the clock.
 
-The eye-height slider runs from 1.7 m to 45 km with the local gravity beside it, and
-**Rain** leans 1.5° antispinward. Nothing *inside* rotates, because you, the ground
-and the air all turn together — so for 539 of the 540 km that 1.5° is the only
-evidence Tower turns at all. The exception is the window: the sky out there does not
-co-rotate, and wheels once every 7 min 29 s about the one motionless star on the
-axis. It is by a wide margin the plainest sign of the spin anywhere in the view, and
+**Eye height** is a gauge up the right-hand side of the view, from 1.7 m to the beam, 48.4 km up, on a
+log scale: drag it, or click it and use the arrow keys. It is marked with things of known
+height — an adult, a two-storey house, the Eiffel Tower, Everest, an airliner cruising, a
+weather balloon, and at the top the beam itself — which light up as you pass them, and the readout by the thumb gives the
+height, the local gravity, and what it compares with ("≈ the Eiffel Tower", "2.0× the
+Burj Khalifa"). The **Where you stand** readout in the bottom-left corner folds down to its
+title with a click, and stays as you left it; on a phone it starts folded.
+
+Nothing *inside* rotates, because you, the ground and the air all turn together. The
+exception is the window: the sky out there does not co-rotate, and wheels once every 7 min 29 s about the one motionless star on the
+axis. It is the one sign of the spin anywhere in the view, and
 it is only available at night, at the deepward end, looking the one direction that
 has ever had anything in it but Tower.
 
diff --git a/decisions.md b/decisions.md
index af9e3b5..7892a8e 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1828,6 +1828,8 @@ still catches the frames on both sides of the change.
 
 ## D-059 — Inside, the finger count is the mode
 
+*Amended by D-083: the pinch no longer zooms and the wheel moves rather than zooms; the field of view is fixed at 60°.*
+
 Until now the interior could not be moved through at all. Dragging looked around,
 the wheel zoomed, and the only way to change where you stood was to select a place
 and be walked there (D-038). That is fine for the places that exist and useless for
@@ -2732,3 +2734,95 @@ reasoning is applied to the two other regular patterns in D-077's plating: the p
 fade out once a panel is a few pixels across, and the hard-edged grime stripes are
 averaged away as they near a pixel's width. Close up, nothing changes. This is D-077's
 plating, so the Piping reading gets the fix too.
+
+## D-082 — Eye height is a gauge in the view, marked with things of known height
+
+**Asked for** (author, 2026-09-28): "please make the bottom-left panel collapsible", and
+"move the eye height selector out of the right panel, and put it instead as a vertical
+height, and indicate comparable heights when we reach those heights: average adult,
+average home, eiffel tower, airplane cruising altitude, etc."
+
+**The gauge** (`src/ui/eye-gauge.ts`) runs up the right-hand side of the interior view,
+from 1.7 m at the bottom to 45 km at the top (48.4 km, the beam, since D-084), on the slider's own logarithmic scale — so a
+person and a weather balloon both get room. Eleven marks, all real-world, so none needs
+explaining: an adult (1.7 m), a two-storey house (8 m), a ten-storey block (30 m), the
+Statue of Liberty (93 m), the Eiffel Tower (330 m), the Burj Khalifa (828 m), Mount Fuji
+(3.8 km), Everest (8.8 km), an airliner cruising (11 km), Concorde cruising (18 km) and a
+weather balloon (35 km). The gaps between the author's examples were filled so no stretch
+of the scale is empty. Marks brighten as you pass them and turn accent-coloured when you
+are level with one — within a tenth of its height. The readout by the thumb gives the
+height, the local gravity (which the old slider's label carried), and the comparison:
+"≈ the Eiffel Tower" when level, otherwise a multiple of the highest mark passed,
+"2.0× the Eiffel Tower".
+
+**A widget of its own**, not a rotated `<input type=range>`: the marks must sit exactly
+level with the thumb, and a native range insets its thumb by an amount each browser picks.
+It is still a slider to the keyboard (arrows, Page Up/Down, Home/End) and to screen
+readers (`role=slider`, with the comparison as its value text). On a short view, a mark
+too close to the one below it keeps its tick and drops its name — Everest and the airliner
+are the pair that collide first. On a phone the names go altogether, and the readout by the
+thumb says which mark you are near. It follows the view as the slider did: flying with E
+and Q moves it, except while it is being dragged.
+
+**The standing readout folds** to its title, "Where you stand", with a click on it; the
+rest of it stays click-through, as every overlay is. The fold is remembered. On a phone it
+starts folded, since open it covers a third of the view, and it now stops short of the
+gauge.
+
+## D-083 — No zoom inside; the trackpad swipe moves
+
+**Asked for** (author, 2026-09-28): "the current interior docs state that two fingers
+move, but i'm seeing that two fingers changes the zoom. can the zoom be hard-coded
+internally, and can we get the motion working instead?"
+
+**Why it zoomed.** D-059 made two *touches* move the observer and pinch the field of view,
+and the wheel zoom. On a Mac trackpad, a two-finger swipe is not two touches: the browser
+delivers it as the wheel. So the gesture the panel described went to the wheel handler and
+zoomed, and the move D-059 built was reachable only on a touchscreen or with shift-drag.
+
+**The field of view is fixed at 60°** (`FIELD_DEG` in `src/views/interior.ts`). Pinch no
+longer zooms, the wheel no longer zooms, a saved field is no longer restored, and the
+readout's `field` line is gone, since it could only ever say 60°. The scenic tour still
+frames its own shots at its own fields; stopping it puts 60° back. A trackpad pinch
+arrives as the wheel with ctrl held, and is swallowed rather than zooming the page.
+
+**The wheel moves.** Not as a grab of the ground under the cursor, which was the first
+try: it matched the touch drag exactly, but a trackpad's cursor sits wherever it was left.
+Measured from standing, a 300 px swipe moved 2 m with the cursor near the bottom of the
+screen and 99 m just under the horizon; and above the horizon every ray is held to the
+same capped reach, so a swipe there barely moves at all. The travel depended on where the
+cursor happened to be, which is no way to walk. So a pixel of swipe is a fixed fraction of a second of keyboard flight, which
+already scales with height: 1/25 s, so a screen's swipe walks about 70 m from standing and
+some 40 km from a kilometre up, wherever the cursor is and however the head is tilted. It
+walks along the heading, in the same direction as the touch drag — the ground follows the
+fingers, so swiping down goes ahead, as the page does under natural scrolling. No shift
+boost, because browsers turn shift-wheel sideways.
+
+**Captions pass the wheel on.** They take the pointer so they can be clicked, and one sits
+on the skyline more often than not; a swipe over one did nothing until the caption layer
+was given the same wheel handler.
+
+**Touch is unchanged apart from the pinch**: two fingers still grab the ground and drag
+it. Their centroid is what moves, so a pinch — fingers apart about a still centre — now
+does nothing at all.
+
+## D-084 — The top of the eye-height gauge is the beam
+
+**Asked for** (author, 2026-09-28): "add 'sun beam' or whatever the technical name is to
+the chart, since that's the top point we can go to." Canon's name is **the beam** (§1.3):
+the column of concentrated sunlight on the axis, 3.18 km across, glowing where it runs
+through the air.
+
+**It was not the top, so it is now.** The ceiling was 45 km above the ground, 3.4 km short
+of the beam's edge, which is 48.4 km above the datum (the 50 km radius less the beam's
+1.59 km). The ceiling is now that edge: `EYE_MAX_M = RADIUS_M - BEAM_RADIUS_M` in
+`src/ui/scales.ts`, derived from canon rather than typed in, and the gauge's last mark,
+"the beam", sits at the top of the scale. The interior stops the eye 300 m short of the
+edge over whatever ground is underfoot, so on high ground the ceiling comes lower and the
+observer can never be inside the beam. From there it is 80° wide overhead and looking up
+is white — a brilliantly sunlit cloud, 300 m off, as §1.3 describes it.
+
+**The gauge's gravity now counts the ground**, as the standing readout's always did;
+the old slider's label measured from the ground up and read 0.044 g at the top where the
+readout said 0.038 g.
+
diff --git a/design.md b/design.md
index 6ac3322..4144937 100644
--- a/design.md
+++ b/design.md
@@ -766,11 +766,12 @@ src/
     exterior.ts     Three.js: hull, collector, beam, spin, point picker
     interior-model.ts  ground tiles, caps, bore, beam and haze, shared by both 3D views
     map.ts          Canvas 2D: unrolled surface, slats, terminator, point picker
-    interior.ts     Three.js: shell geometry, the haze shader of §1.5, beam, rain
+    interior.ts     Three.js: shell geometry, the haze shader of §1.5, beam
   ui/
     app.ts          the whole panel: list, detail card, weather board, readouts, view switching
+    eye-gauge.ts    the interior's eye-height gauge and its landmarks of known height
     scales.ts       slider mappings — kept out of views/ so the panel can label
-                    the eye-height slider without pulling in Three.js
+                    the eye-height gauge without pulling in Three.js
   main.ts           loads world.json and starts the app
 vite.config.ts      also makes world.json from content/world.json (§3.1)
 
@@ -947,8 +948,7 @@ carry it.
 
 **The one thing that does turn is outside.** The sky through the caps does not
 co-rotate, so it wheels once every 7 min 29 s about the motionless star on the axis
-(D-045). It is by a wide margin the plainest evidence of the spin in the view, and
-it is the only evidence left now that the rain is gone. Both caps are windows
+(D-045). It is the only evidence of the spin in the view. Both caps are windows
 (D-050), so it can be had looking either way along the axis — deepward into empty
 space, or sunward past the black disc of the collector.
 
@@ -959,9 +959,9 @@ built from, at full detail. D-035's readback of a raster is gone with the raster
 descend, shift is five times faster. Speed is proportional to height above the ground,
 so the ground passes at the same apparent pace from a metre up or ten kilometres.
 
-Instruments in the view: eye height from 1.7 m to 45 km with the local gravity
-beside it, the five canon illumination settings of §1.3, the field of view as a
-pair of binoculars, and captions on every place still visible through the haze.
+Instruments in the view: eye height from 1.7 m to the edge of the beam, 48.4 km up (D-084), with the local gravity
+beside it — a vertical gauge up the view's right side, marked with real-world things of
+known height so the number means something (D-082) — the five canon illumination settings of §1.3, a field of view fixed at 60° (D-083), and captions on every place still visible through the haze.
 Selecting a place walks the observer to it (D-038) — the haze horizon closes at
 100–150 km, so turning to face something usually just points at haze.
 
@@ -1142,7 +1142,7 @@ gets too.
 2. **Scaffold** — Vite/TS/Three, CI to Pages, `canon/` implemented and unit-tested.
 3. **Exterior** — ✅ hull, caps, collector, beam, spin, and hull point picking.
 4. **Map** — unrolled surface, slat rendering, picking, sync with exterior.
-5. **Interior** — ✅ shell geometry, the haze shader, the beam, rain, eye height,
+5. **Interior** — ✅ shell geometry, the haze shader, the beam, eye height,
    and the five illumination settings. Stars, but only through the far cap window.
 6. **Writer mode** — ✅ *built ahead of 3–5, at the author's request, because the
    world needs developing before there is anything to render.* Map view, place
diff --git a/index.html b/index.html
index ed807ef..dce8eee 100644
--- a/index.html
+++ b/index.html
@@ -20,7 +20,12 @@
     <div id="ext-labels" class="labels3d hidden"></div>
     <div id="int-labels" class="labels3d hidden"></div>
     <div id="spin" class="overlay hidden"></div>
-    <div id="standing" class="overlay hidden"></div>
+    <div id="standing" class="overlay collapsible hidden">
+      <button id="standing-toggle" class="overlay-head" type="button" aria-expanded="true"
+        aria-controls="standing-body">Where you stand</button>
+      <div id="standing-body"></div>
+    </div>
+    <div id="eye-gauge" class="gauge hidden"></div>
     <div id="cursor" class="overlay hidden"></div>
     <div id="sightline" class="overlay hidden"></div>
     <div id="status" class="status" role="status" aria-live="polite" hidden>
@@ -73,15 +78,16 @@
           zoom, click the hull to drop a point.</p>
       </div>
       <div id="interior-options" class="hidden">
-        <label>Eye height <span id="eye-value" class="dim"></span>
-          <input id="f-eye" type="range" min="0" max="1000" step="1" value="0" /></label>
+        <p class="hint">Eye height is the gauge up the right-hand side of the view: drag it,
+          or click it and use the arrow keys. The marks are things of known height.</p>
         <button id="f-tour" type="button">Scenic tour</button>
         <p class="hint">A new sight every thirty seconds — flying down a river, circling a
           waterfall or a summit, following a ship through weather. <b>N</b> skips ahead;
           <b>Esc</b>, a drag or a flight key stops it where it is.</p>
         <p class="hint"><b>One finger looks. Two fingers move</b> — drag with two
-          and the ground follows them, pinch to zoom in like a pair of binoculars.
-          With a mouse: drag to look, shift-drag to move, scroll to zoom.
+          and the ground follows them. On a trackpad, a two-finger swipe moves you the
+          same way; so does a mouse wheel. With a mouse: drag to look, shift-drag or
+          scroll to move.
           <b>Fly with the keyboard:</b> W A S D or the arrows to move, E to climb,
           Q to descend, shift to go five times faster. Speed follows your height,
           so the ground passes at the same apparent pace from a metre up or ten
diff --git a/src/canon/motion.ts b/src/canon/motion.ts
index a0059e6..65b8d02 100644
--- a/src/canon/motion.ts
+++ b/src/canon/motion.ts
@@ -46,7 +46,7 @@ export const freeFallPathLeanRad = (heightM: number): number =>
 
 /**
  * Lean of the *velocity* at the bottom of the same fall — the direction a streak
- * of it is moving as it arrives, and so the angle rain and spray are drawn at.
+ * of it is moving as it arrives, and so the angle falling water and spray streak at.
  *
  * It reduces to atan(ωt), independent of g, and it is exactly half again the path
  * lean in tangent: the drift integrates to ωgt³/3 over a drop of gt²/2, giving
diff --git a/src/style.css b/src/style.css
index f7cace8..8008d82 100644
--- a/src/style.css
+++ b/src/style.css
@@ -45,6 +45,63 @@ canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block
   pointer-events: none; white-space: pre;
 }
 #sightline { left: auto; right: 12px; }
+
+/* A readout that can be folded down to its title (D-082). The box itself stays
+   click-through, as every overlay is; only the title takes the click. */
+.overlay.collapsible { padding: 0; white-space: normal; }
+.overlay.collapsible > div { white-space: pre; }
+.overlay-head {
+  display: flex; align-items: center; gap: 8px; width: 100%; margin: 0;
+  padding: 6px 10px; border: none; border-radius: 6px; background: none;
+  font: inherit; color: var(--dim); text-align: left; pointer-events: auto;
+  text-transform: uppercase; letter-spacing: 0.08em; font-size: 10px;
+}
+.overlay-head:hover { background: none; color: var(--fg); }
+.overlay-head::after {
+  content: ''; margin-left: auto; width: 5px; height: 5px;
+  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
+  transform: rotate(-135deg) translate(-1px, -1px);
+}
+.overlay-head[aria-expanded=false]::after { transform: rotate(45deg) translate(-2px, -2px); }
+.overlay.collapsible > div { padding: 0 10px 8px; }
+.overlay.collapsed > div { display: none; }
+
+/* The eye-height gauge (D-082): an altimeter up the right of the interior view, log
+   scaled, with things of known height marked on it. */
+.gauge {
+  position: absolute; right: 18px; top: 64px; bottom: 40px; width: 10px;
+  font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.gauge-track {
+  position: absolute; inset: 0; border-radius: 5px; cursor: ns-resize; touch-action: none;
+  background: rgba(10,13,20,0.7); border: 1px solid var(--line); outline: none;
+}
+.gauge-track:focus-visible { border-color: var(--accent); }
+.gauge-mark {
+  position: absolute; right: 12px; transform: translateY(50%);
+  display: flex; align-items: center; gap: 6px; white-space: nowrap; pointer-events: none;
+  color: rgba(190,200,216,0.8);
+  text-shadow: 0 0 2px #05070d, 0 0 4px #05070d, 0 0 8px #05070d;
+}
+.gauge-mark i { font-style: normal; opacity: 0.75; }
+/* Too close to the mark below it at this height of screen: tick only. */
+.gauge-mark.crowded span, .gauge-mark.crowded i { display: none; }
+.gauge-mark::after { content: ''; width: 8px; border-top: 1px solid currentColor; }
+.gauge-mark.passed { color: #eef2f8; }
+.gauge-mark.level { color: var(--accent); }
+.gauge-thumb {
+  position: absolute; left: 50%; width: 16px; height: 16px; margin-left: -8px;
+  transform: translateY(50%); border-radius: 50%; pointer-events: none;
+  background: var(--accent); box-shadow: 0 0 0 3px rgba(10,13,20,0.9);
+}
+/* The height itself, beside the thumb and clear of the marks, on the view's side. */
+.gauge-readout {
+  position: absolute; right: 22px; top: 50%; transform: translateY(-50%);
+  white-space: nowrap; text-align: right; padding: 4px 8px; border-radius: 5px;
+  background: rgba(10,13,20,0.92); border: 1px solid var(--accent); color: var(--fg);
+  font-size: 11px;
+}
+.gauge-readout span { color: var(--accent); }
 .hidden { display: none !important; }
 
 /* The status dialog (world-generation.md §10.1). Over the world, never over the panel:
@@ -186,6 +243,13 @@ input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); }
      390 px screen at 11 px. Shrink the type rather than wrap it: these are
      aligned columns, and wrapping them destroys the thing they are for. */
   .overlay { font-size: 9px; padding: 6px 8px; }
+  /* Clear of the gauge; its longest lines were already past the screen's edge. */
+  .overlay.collapsible { padding: 0; max-width: calc(100% - 60px); overflow: hidden; }
+
+  /* No room for the landmark names beside the gauge on a phone: ticks only, and the
+     readout by the thumb still says which one you are near. */
+  .gauge { top: 96px; right: 14px; }
+  .gauge-mark span, .gauge-mark i { display: none; }
 }
 
 /* The scenic tour (interior.ts): a black fade between scenes, and the scene's title. */
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 838b745..01f531c 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -19,12 +19,14 @@ import type { InteriorView } from '../views/interior.js'
 import { clock } from '../world/clock.js'
 import { clockText, dateLabel, hoursAt, isDial, skyAt } from '../world/sky.js'
 import { CIRCUMFERENCE_M, G0, LENGTH_M, RADIUS_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js'
-import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js'
+import { EyeGauge } from './eye-gauge.js'
 
 type ViewName = 'map' | 'exterior' | 'interior'
 const VIEW_KEY = 'tower.view'
 /** The exterior's hull reading, remembered across reloads like the view itself (D-061). */
 const HULL_KEY = 'tower.exteriorHull'
+/** Whether the standing readout is folded down to its title (D-082). */
+const STANDING_KEY = 'tower.standingReadout'
 /** Whether labels are shown, in every view, remembered the same way. */
 const LABELS_KEY = 'tower.labels'
 const isView = (v: string | null): v is ViewName =>
@@ -99,6 +101,8 @@ export class App {
   private map: TowerMap
   private exterior: ExteriorView | null = null
   private interior: InteriorView | null = null
+  /** Built with the panel, before the view: it has to show a height before there is one. */
+  private eye!: EyeGauge
   /**
    * The deferred imports in flight. Opening on a `#view=` link asks for a view twice
    * before the first import has resolved — once reading the hash, once choosing the
@@ -266,14 +270,34 @@ export class App {
    * then talks to the renderer.
    */
   private buildInteriorControls(): void {
-    const eye = $<HTMLInputElement>('f-eye')
-    eye.oninput = () => {
-      const m = eyeHeightFromSlider(Number(eye.value) / 1000)
-      this.showEye(m)
+    this.eye = new EyeGauge($('eye-gauge'), (m) => {
       this.interior?.setEyeHeight(m)
       this.renderStanding()
+    }, (m) =>
+      // Gravity goes by height above the datum, so the ground underfoot counts too, as in
+      // the standing readout; the old slider's label left it out and disagreed with it.
+      `${(gravityAtHeight(m + (this.interior?.elevationM ?? 0)) / G0).toFixed(3)} g`)
+
+    // The standing readout folds down to its title, and stays as it was left.
+    const standing = $('standing')
+    const toggle = $('standing-toggle')
+    const fold = (collapsed: boolean) => {
+      standing.classList.toggle('collapsed', collapsed)
+      toggle.setAttribute('aria-expanded', String(!collapsed))
+    }
+    // Folded by default on a phone, where open it covers a third of the view.
+    let stored: string | null = null
+    try {
+      stored = localStorage.getItem(STANDING_KEY)
+    } catch { /* unreadable storage just means the default */ }
+    fold(stored ? stored === 'collapsed' : this.narrow.matches)
+    toggle.onclick = () => {
+      const collapsed = !standing.classList.contains('collapsed')
+      fold(collapsed)
+      try {
+        localStorage.setItem(STANDING_KEY, collapsed ? 'collapsed' : 'open')
+      } catch { /* not remembering the fold is survivable */ }
     }
-    this.showEye(eyeHeightFromSlider(Number(eye.value) / 1000))
 
     $('f-tour').onclick = () => {
       const v = this.interior
@@ -402,6 +426,7 @@ export class App {
     $('spin').classList.toggle('hidden', !isExterior)
     $('ext-labels').classList.toggle('hidden', !isExterior)
     $('standing').classList.toggle('hidden', !isInterior)
+    $('eye-gauge').classList.toggle('hidden', !isInterior)
     $('int-labels').classList.toggle('hidden', !isInterior)
     for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
       b.classList.toggle('active', b.dataset['view'] === view)
@@ -450,10 +475,8 @@ export class App {
         },
         onLook: () => this.renderStanding(),
       }, $('int-labels'))
-      // Adopt whatever was restored from storage, so the panel is not lying.
-      const eye = $<HTMLInputElement>('f-eye')
-      eye.value = String(Math.round(sliderFromEyeHeight(this.interior.eyeM) * 1000))
-      eye.dispatchEvent(new Event('input'))
+      // Adopt whatever was restored from storage, so the gauge is not lying.
+      this.eye.set(this.interior.eyeM)
     }
     this.interior.resize()
     this.interior.setPlaces(this.world.features.filter((p) => p.geometry.type !== 'band'))
@@ -917,25 +940,14 @@ export class App {
   }
 
   /**
-   * The eye-height slider follows the view, as the clock does: keyboard flight changes
-   * the height from inside the view, and a slider left where it was would be a second
-   * height in the panel disagreeing with the first. Same rules as `syncTime`.
+   * The eye-height gauge follows the view, as the clock does: keyboard flight changes
+   * the height from inside the view, and a gauge left where it was would be a second
+   * height disagreeing with the first. Not while it is being dragged.
    */
   private syncEye(): void {
     const v = this.interior
-    if (!v) return
-    const eye = $<HTMLInputElement>('f-eye')
-    if (document.activeElement === eye) return
-    const pos = String(Math.round(sliderFromEyeHeight(v.eyeM) * 1000))
-    if (eye.value === pos) return
-    eye.value = pos
-    this.showEye(v.eyeM)
-  }
-
-  private showEye(m: number): void {
-    $('eye-value').textContent =
-      `— ${m < 1000 ? `${m.toFixed(1)} m` : `${(m / 1000).toFixed(1)} km`}, ` +
-      `${(gravityAtHeight(m) / G0).toFixed(3)} g`
+    if (!v || this.eye.active || Math.abs(this.eye.value - v.eyeM) < 0.01) return
+    this.eye.set(v.eyeM)
   }
 
   /** The tour button says what pressing it will do. */
@@ -972,7 +984,7 @@ export class App {
       p.geometry.type === 'point'
       && Math.abs(p.geometry.z - here.z) < 2000
       && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name
-    $('standing').textContent = [
+    $('standing-body').textContent = [
       `standing   ${name ?? rep.standing}`,
       ...(name ? [`           ${rep.standing}`] : []),
       ...rep.lines,
diff --git a/src/ui/eye-gauge.ts b/src/ui/eye-gauge.ts
new file mode 100644
index 0000000..a3a3076
--- /dev/null
+++ b/src/ui/eye-gauge.ts
@@ -0,0 +1,170 @@
+/**
+ * The eye-height gauge: a vertical altimeter up the side of the interior view (D-082).
+ *
+ * The author asked for the height as a height — "a vertical height" rather than a slider in
+ * the panel — marked with things of known size, so a number like 412 m arrives as "a bit
+ * above the Eiffel Tower". The scale is the slider's own logarithm (scales.ts), so the
+ * marks spread evenly from a person to a weather balloon.
+ *
+ * A widget of its own rather than a rotated <input type=range>: the marks have to sit
+ * exactly level with the thumb, and a native range keeps its thumb inset by an amount
+ * each browser chooses. It is still a slider to assistive tech and the keyboard.
+ */
+import { EYE_MAX_M, EYE_MIN_M, eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js'
+
+/** Things of known height, low to high. Real-world, so they need no explaining. */
+export const LANDMARKS: readonly { m: number; name: string }[] = [
+  { m: 1.7, name: 'an adult' },
+  { m: 8, name: 'a two-storey house' },
+  { m: 30, name: 'a ten-storey block' },
+  { m: 93, name: 'the Statue of Liberty' },
+  { m: 330, name: 'the Eiffel Tower' },
+  { m: 828, name: 'the Burj Khalifa' },
+  { m: 3_776, name: 'Mount Fuji' },
+  { m: 8_849, name: 'Mount Everest' },
+  { m: 11_000, name: 'an airliner cruising' },
+  { m: 18_000, name: 'Concorde cruising' },
+  { m: 35_000, name: 'a weather balloon' },
+  // Tower's own, and the top of the scale: nobody goes higher (D-084).
+  { m: EYE_MAX_M, name: 'the beam' },
+]
+
+/**
+ * What the height is like, in words: level with a landmark when within a tenth of it,
+ * otherwise a multiple of the highest one it has passed.
+ */
+export function compareHeight(m: number): string {
+  const near = LANDMARKS.find((l) => Math.abs(m / l.m - 1) <= 0.1)
+  if (near) return `≈ ${near.name}`
+  const below = [...LANDMARKS].reverse().find((l) => l.m < m)
+  if (!below) return LANDMARKS[0]!.name
+  const x = m / below.m
+  return `${x < 10 ? x.toFixed(1) : Math.round(x)}× ${below.name}`
+}
+
+export class EyeGauge {
+  /** True while a drag or key is changing it, so the view does not fight the hand. */
+  active = false
+  private track: HTMLDivElement
+  private thumb: HTMLDivElement
+  private readout: HTMLDivElement
+  private marks: HTMLDivElement[] = []
+  private m = EYE_MIN_M
+
+  /**
+   * `onInput` is called with the new height whenever the reader moves it; `describe`
+   * gives the readout's second line (the local gravity) for a height.
+   */
+  constructor(
+    host: HTMLElement,
+    private onInput: (m: number) => void,
+    private describe: (m: number) => string,
+  ) {
+    host.innerHTML = ''
+    this.track = el('div', 'gauge-track')
+    this.track.tabIndex = 0
+    this.track.setAttribute('role', 'slider')
+    this.track.setAttribute('aria-label', 'Eye height')
+    this.track.setAttribute('aria-orientation', 'vertical')
+    this.track.setAttribute('aria-valuemin', String(EYE_MIN_M))
+    this.track.setAttribute('aria-valuemax', String(EYE_MAX_M))
+    for (const l of LANDMARKS) {
+      const mark = el('div', 'gauge-mark')
+      mark.style.bottom = `${sliderFromEyeHeight(l.m) * 100}%`
+      mark.innerHTML = `<span>${l.name}</span><i>${fmtM(l.m)}</i>`
+      this.track.append(mark)
+      this.marks.push(mark)
+    }
+    this.thumb = el('div', 'gauge-thumb')
+    this.readout = el('div', 'gauge-readout')
+    this.thumb.append(this.readout)
+    this.track.append(this.thumb)
+    host.append(this.track)
+    new ResizeObserver(() => this.uncrowd()).observe(this.track)
+    this.attach()
+    this.set(this.m)
+  }
+
+  get value(): number {
+    return this.m
+  }
+
+  /** Move to a height without reporting it: for the view telling the gauge. */
+  set(m: number): void {
+    this.m = Math.min(EYE_MAX_M, Math.max(EYE_MIN_M, m))
+    const t = sliderFromEyeHeight(this.m)
+    this.thumb.style.bottom = `${t * 100}%`
+    this.readout.innerHTML =
+      `<b>${fmtM(this.m)}</b> · ${this.describe(this.m)}<br><span>${compareHeight(this.m)}</span>`
+    this.track.setAttribute('aria-valuenow', this.m.toFixed(1))
+    this.track.setAttribute('aria-valuetext', `${fmtM(this.m)}, ${compareHeight(this.m)}`)
+    // Passed marks light up, and the one it is level with most of all.
+    LANDMARKS.forEach((l, i) => {
+      this.marks[i]!.classList.toggle('passed', l.m <= this.m * 1.1)
+      this.marks[i]!.classList.toggle('level', Math.abs(this.m / l.m - 1) <= 0.1)
+    })
+  }
+
+  /**
+   * On a short view some marks land on top of each other (Everest and the airliner are a
+   * quarter of the scale's e-fold apart). Walking up from the ground, a mark too close to
+   * the last one named keeps its tick and loses its name.
+   */
+  private uncrowd(): void {
+    const h = this.track.clientHeight
+    let last = -Infinity
+    LANDMARKS.forEach((l, i) => {
+      const y = sliderFromEyeHeight(l.m) * h
+      const crowded = y - last < 15
+      this.marks[i]!.classList.toggle('crowded', crowded)
+      if (!crowded) last = y
+    })
+  }
+
+  private input(m: number): void {
+    this.set(m)
+    this.onInput(this.m)
+  }
+
+  private attach(): void {
+    const fromPointer = (e: PointerEvent) => {
+      const r = this.track.getBoundingClientRect()
+      this.input(eyeHeightFromSlider(1 - (e.clientY - r.top) / r.height))
+    }
+    this.track.addEventListener('pointerdown', (e) => {
+      this.active = true
+      this.track.setPointerCapture(e.pointerId)
+      this.track.focus()
+      fromPointer(e)
+      e.preventDefault()
+    })
+    this.track.addEventListener('pointermove', (e) => {
+      if (this.active && this.track.hasPointerCapture(e.pointerId)) fromPointer(e)
+    })
+    const end = () => { this.active = false }
+    this.track.addEventListener('pointerup', end)
+    this.track.addEventListener('pointercancel', end)
+    // Keys: a step is 1% of the scale, a page 10%; Home and End go to the ends.
+    this.track.addEventListener('keydown', (e) => {
+      const t = sliderFromEyeHeight(this.m)
+      const step = { ArrowUp: 0.01, ArrowRight: 0.01, ArrowDown: -0.01, ArrowLeft: -0.01,
+        PageUp: 0.1, PageDown: -0.1 }[e.key]
+      if (step !== undefined) this.input(eyeHeightFromSlider(t + step))
+      else if (e.key === 'Home') this.input(EYE_MIN_M)
+      else if (e.key === 'End') this.input(EYE_MAX_M)
+      else return
+      // The view flies on arrow keys too; this one is the gauge's.
+      e.preventDefault()
+      e.stopPropagation()
+    })
+  }
+}
+
+export const fmtM = (m: number): string =>
+  m < 10 ? `${m.toFixed(1)} m` : m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`
+
+function el(tag: 'div', cls: string): HTMLDivElement {
+  const d = document.createElement(tag)
+  d.className = cls
+  return d
+}
diff --git a/src/ui/scales.ts b/src/ui/scales.ts
index 2e4aa3e..0afc36c 100644
--- a/src/ui/scales.ts
+++ b/src/ui/scales.ts
@@ -9,8 +9,16 @@
 
 /** Standing eye height. */
 export const EYE_MIN_M = 1.7
-/** Most of the way to the axis: the whole world seen as a tube from inside its air (world-generation.md §6.4). */
-export const EYE_MAX_M = 45_000
+import { BEAM_RADIUS_M, RADIUS_M } from '../canon/constants.js'
+
+/**
+ * The edge of the beam, above the datum: as high as anyone can go (D-084). The whole world
+ * seen as a tube from inside its air (world-generation.md §6.4), with the beam filling the
+ * sky overhead. It was 45 km, 3.4 km short of it, until the author asked for the beam to be
+ * the top of the eye-height gauge. The interior stops you a little short of it, over
+ * whatever ground you are above.
+ */
+export const EYE_MAX_M = RADIUS_M - BEAM_RADIUS_M
 
 /** Eye height from a 0..1 slider position. Logarithmic, so 1.7 m is reachable. */
 export const eyeHeightFromSlider = (v: number): number =>
diff --git a/src/views/interior.ts b/src/views/interior.ts
index c0282e0..be2e3a5 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -64,6 +64,23 @@ const fmtSpin = (): string => {
 const GRAB_REACH = 1200
 const GRAB_MAX_KM = 60
 
+/**
+ * The field of view, fixed (D-083). It used to zoom, from pinch and the wheel, but on a
+ * Mac trackpad a two-finger swipe *is* the wheel, so the gesture the panel promised would
+ * move you zoomed instead. The scenic tour still frames its own shots; handing the view
+ * back puts this back.
+ */
+const FIELD_DEG = 60
+
+/**
+ * How far short of the beam's edge the eye is stopped, metres (D-084). The gauge's top is
+ * the beam's edge above the datum, so over high ground the ceiling comes lower.
+ */
+const BEAM_CLEARANCE_M = 300
+
+/** Seconds of keyboard flight a pixel of wheel or trackpad swipe is worth (D-083). */
+const WHEEL_SECONDS_PER_PX = 1 / 25
+
 const STATE_KEY = 'tower.interior'
 const STATE_SAVE_MS = 400
 
@@ -165,7 +182,7 @@ export class InteriorView {
    * Every pointer currently down on the canvas, in client coordinates.
    *
    * A count rather than a mode is what decides the gesture (D-059): one pointer
-   * looks, two move the observer over the ground and pinch the field of view. So
+   * looks, two move the observer over the ground. So
    * the whole set has to be tracked, not just the last one to move.
    */
   private pointers = new Map<number, { x: number; y: number }>()
@@ -199,7 +216,7 @@ export class InteriorView {
   yaw = 0
   /** Elevation angle, radians. Clamped just short of straight up and down. */
   pitch = 0
-  fovDeg = 60
+  fovDeg = FIELD_DEG
   /** Seconds this view has run, for the water. */
   private time = 0
   /** Phase of the star pinwheel, radians. The only thing in the view that turns. */
@@ -337,6 +354,8 @@ export class InteriorView {
 
   /** Put the camera where the observer is, pointing where they are looking. */
   private place(): void {
+    // Never into the beam, however high the ground underfoot (D-084).
+    this.eyeM = Math.max(EYE_MIN_M, Math.min(this.eyeM, EYE_MAX_M - BEAM_CLEARANCE_M - this.elevationM))
     const { up, spinward, deepward, eye } = this.basis()
     const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw))
       .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw))
@@ -476,14 +495,13 @@ export class InteriorView {
 
   // --------------------------------------------------------------- interaction
 
-  /** The mean of the live pointers, and the span between the first two. */
-  private gesture(): { x: number; y: number; span: number } {
+  /** The mean of the live pointers. */
+  private gesture(): { x: number; y: number } {
     let x = 0
     let y = 0
     for (const p of this.pointers.values()) { x += p.x; y += p.y }
     const n = Math.max(1, this.pointers.size)
-    const [a, b] = [...this.pointers.values()]
-    return { x: x / n, y: y / n, span: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 }
+    return { x: x / n, y: y / n }
   }
 
   /**
@@ -548,15 +566,12 @@ export class InteriorView {
     }
   }
 
-  private zoom(factor: number): void {
-    this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * factor))
-  }
-
   /**
    * One pointer looks, two move — the count is the mode, so there is no switch to
    * find and nothing on screen to get in the way of the view (D-059). A mouse has
    * only ever one pointer, so shift or the middle button stands in for the second
-   * finger.
+   * finger — and the wheel moves too, because on a trackpad the two-finger swipe
+   * arrives as the wheel (D-083).
    */
   private attach(): void {
     this.canvas.addEventListener('pointerdown', (e) => {
@@ -581,7 +596,6 @@ export class InteriorView {
         this.look(after.x - before.x, after.y - before.y)
       } else {
         this.moveOver(before, after)
-        if (before.span > 0 && after.span > 0) this.zoom(before.span / after.span)
       }
       this.place()
       this.cb.onLook()
@@ -593,14 +607,33 @@ export class InteriorView {
     }
     this.canvas.addEventListener('pointerup', end)
     this.canvas.addEventListener('pointercancel', end)
-    this.canvas.addEventListener('wheel', (e) => {
+    /**
+     * The wheel moves, as two fingers do (D-083). On a trackpad it *is* two fingers: the
+     * swipe arrives as wheel deltas in pixels. It is not a grab of the ground under the
+     * cursor, as a touch drag is — the cursor sits wherever it was left, often in the
+     * sky, where there is no ground to grab and a swipe did nothing (measured: zero
+     * metres at the horizon and above). So a pixel of swipe is a fixed fraction of a
+     * second of keyboard flight, which already scales with height: a screen's swipe
+     * walks about 70 m from standing and some 40 km from a kilometre up. Directions
+     * match the touch drag, so the ground follows the fingers: swipe down to go ahead.
+     * No shift boost: browsers turn shift-wheel sideways. A trackpad pinch arrives as the wheel with
+     * ctrl held; there is no zoom now, so it does nothing, rather than zoom the page.
+     */
+    const onWheel = (e: WheelEvent) => {
       e.preventDefault()
+      if (e.ctrlKey) return
       if (this.tour) this.stopTour()
-      this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * (e.deltaY > 0 ? 1.08 : 1 / 1.08)))
+      const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? this.canvas.clientHeight : 1
+      const perPx = this.flightSpeed(false) * WHEEL_SECONDS_PER_PX * unit
+      this.walk(-e.deltaY * perPx, e.deltaX * perPx)
       this.place()
       this.cb.onLook()
       this.queueSave()
-    }, { passive: false })
+    }
+    this.canvas.addEventListener('wheel', onWheel, { passive: false })
+    // The captions take the pointer so they can be clicked, and one sits on the skyline
+    // more often than not; a swipe over one is still a swipe over the view.
+    this.labelHost?.addEventListener('wheel', onWheel, { passive: false })
     window.addEventListener('pagehide', () => this.save())
 
     /**
@@ -647,18 +680,10 @@ export class InteriorView {
       if (!k) continue
       forward += k[0]; right += k[1]; up += k[2]
     }
-    const boost = this.boost ? 5 : 1
-    const speed = Math.max(2, this.eyeM * 1.2) * boost
-    const move = speed * dt
+    const move = this.flightSpeed(this.boost) * dt
     if (forward || right) {
       const len = Math.hypot(forward, right)
-      const along = ((forward * Math.cos(this.yaw) - right * Math.sin(this.yaw)) / len) * move
-      const around = ((forward * Math.sin(this.yaw) + right * Math.cos(this.yaw)) / len) * move
-      const radius = Math.max(1, RADIUS_M - this.elevationM)
-      this.at = {
-        theta: normalizeTheta(this.at.theta + around / radius),
-        z: Math.min(LENGTH_M, Math.max(0, this.at.z + along)),
-      }
+      this.walk((forward / len) * move, (right / len) * move)
     }
     if (up) {
       // Doubling height about every second, or every third of one with shift. The
@@ -671,6 +696,22 @@ export class InteriorView {
     this.queueSave()
   }
 
+  /** Metres a second over the ground: 1.7 m up is a brisk walk, and it doubles with height. */
+  private flightSpeed(boost: boolean): number {
+    return Math.max(2, this.eyeM * 1.2) * (boost ? 5 : 1)
+  }
+
+  /** Move over the ground by metres ahead and to the right, along the heading. */
+  private walk(forward: number, right: number): void {
+    const along = forward * Math.cos(this.yaw) - right * Math.sin(this.yaw)
+    const around = forward * Math.sin(this.yaw) + right * Math.cos(this.yaw)
+    const radius = Math.max(1, RADIUS_M - this.elevationM)
+    this.at = {
+      theta: normalizeTheta(this.at.theta + around / radius),
+      z: Math.min(LENGTH_M, Math.max(0, this.at.z + along)),
+    }
+  }
+
   setEyeHeight(m: number): void {
     this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, m))
     this.place()
@@ -690,7 +731,7 @@ export class InteriorView {
     try {
       localStorage.setItem(STATE_KEY, JSON.stringify({
         theta: this.at.theta, z: this.at.z, eye: this.eyeM,
-        yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg,
+        yaw: this.yaw, pitch: this.pitch,
       }))
     } catch {
       // Not persisting where you stood is survivable.
@@ -715,7 +756,6 @@ export class InteriorView {
       const yaw = num(s['yaw'], -Infinity, Infinity)
       if (yaw !== null) this.yaw = normalizeTheta(yaw)
       this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
-      this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
       // The time lived here once; the shared clock picks up whatever it was (clock.ts).
     } catch {
       // Unreadable storage just means standing in the capital again.
@@ -742,7 +782,6 @@ export class InteriorView {
         `ground     ${elevation.toFixed(0)} m above the datum`,
         `eye        ${eyeText} · ${(gravityAtHeight(elevation + this.eyeM) / G0).toFixed(4)} g`,
         `looking    ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`,
-        `field      ${this.fovDeg.toFixed(0)}°`,
         `beam       ${deg(beamAngle).toFixed(2)}° wide overhead`,
         `time       ${this.clock}${this.sunNote(this.sky)}`,
         // The pinwheel replaced the rain as the spin's evidence here (D-049), so
@@ -1012,6 +1051,8 @@ export class InteriorView {
     this.tour = null
     this.tourFade.hidden = true
     this.tourCaption.hidden = true
+    // The tour frames its own shots; the reader's field is the fixed one.
+    this.fovDeg = FIELD_DEG
     // The clock goes back to where it was; the observer stays where the tour left them.
     clock.set(tour.saved.hours)
     clock.setRunning(tour.saved.running)
diff --git a/test/eye-gauge.test.ts b/test/eye-gauge.test.ts
new file mode 100644
index 0000000..7a9f81e
--- /dev/null
+++ b/test/eye-gauge.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { LANDMARKS, compareHeight, fmtM } from '../src/ui/eye-gauge.js'
+import { EYE_MAX_M, EYE_MIN_M } from '../src/ui/scales.js'
+
+describe('the eye-height gauge (D-082)', () => {
+  it('marks things of known height, low to high, all on the scale', () => {
+    for (let i = 1; i < LANDMARKS.length; i++) expect(LANDMARKS[i]!.m).toBeGreaterThan(LANDMARKS[i - 1]!.m)
+    for (const l of LANDMARKS) {
+      expect(l.m).toBeGreaterThanOrEqual(EYE_MIN_M)
+      expect(l.m).toBeLessThanOrEqual(EYE_MAX_M)
+    }
+  })
+
+  it('says a height is level with a landmark within a tenth of it', () => {
+    expect(compareHeight(330)).toBe('≈ the Eiffel Tower')
+    expect(compareHeight(355)).toBe('≈ the Eiffel Tower')
+    expect(compareHeight(1.7)).toBe('≈ an adult')
+  })
+
+  it('otherwise gives a multiple of the highest landmark passed', () => {
+    expect(compareHeight(660)).toBe('2.0× the Eiffel Tower')
+    expect(compareHeight(4)).toBe('2.4× an adult')
+    expect(compareHeight(40_000)).toBe('1.1× a weather balloon')
+  })
+
+  it('tops out at the beam, as high as anyone can go (D-084)', () => {
+    expect(LANDMARKS.at(-1)).toEqual({ m: EYE_MAX_M, name: 'the beam' })
+    expect(EYE_MAX_M).toBeCloseTo(48_408, 0)
+    expect(compareHeight(48_000)).toBe('≈ the beam')
+  })
+
+  it('prints metres and kilometres as the readouts do', () => {
+    expect(fmtM(1.7)).toBe('1.7 m')
+    expect(fmtM(330.4)).toBe('330 m')
+    expect(fmtM(8849)).toBe('8.8 km')
+  })
+})
diff --git a/world-generation.md b/world-generation.md
index 377599e..236c212 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -771,7 +771,7 @@ The interior already has eye height to 25 km and the drag-the-ground gesture
   shift — since the same ×5 there would take 100 m to 40 km in a single second.
 - Terrain clearance: the eye never goes below ground + 1.7 m.
 - **Scenic tour** (§19): a scene every thirty seconds, flown by the view itself.
-- Eye height ceiling raised toward the axis (~45 km) so the whole world can be seen
+- Eye height ceiling raised toward the axis (~45 km; since D-084, the beam's edge at 48.4 km) so the whole world can be seen
   from the middle of the air, if wanted.
 
 ---