fuzzyworld

tower / commit

feat(tour): the caption's border fills in as a leg runs (D-094)

ec1a60755dc060b36959e1f177d6f89c8305ee0c · Pavel Safronov · 2026-09-29 23:09:08 -0700 · parents e18dad04207a

Claude Code usage: this commit ~25.8K out / ~18.1M cached · cost unknown (no per-model trailer) · session 5 tally 854 in / 450.2K out / 120.9M cached · usage by commit and session

feat(tour): the caption's border fills in as a leg runs (D-094)

The scenic tour's caption border fills in clockwise from its top-left
corner over the course of each leg, and closes as the leg fades to the
next; the next caption starts empty. A progress ring read at a glance,
without numbers, in both tours.

Usage: 854 in / 450.2K out / 120.9M cached (session tally)
rtk:   1.3M saved, 19.8% over 3155 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                |  3 ++-
 decisions.md             | 13 +++++++++++++
 index.html               |  5 +++--
 src/style.css            | 10 ++++++++++
 src/views/tour-player.ts | 23 ++++++++++++++++++++++-
 world-generation.md      |  3 ++-
 6 files changed, 52 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index bfeb24c..1fbc6f8 100644
--- a/README.md
+++ b/README.md
@@ -376,7 +376,8 @@ airship climbing out of the Capital until the world closes overhead, another cir
 sun, outside the beam as every aircraft must be, trains on the two railways, and ships in
 fair weather and in a squall. The camera rides in the craft or chases it, banks with it,
 and turns to whatever can be seen from there — never at something the haze has eaten. The
-slow legs are time-lapses and the caption says so. **N** skips ahead; **Esc**, a drag or a
+slow legs are time-lapses and the caption says so. The caption's border fills in clockwise
+from its top-left corner as the leg runs, and the next leg comes when it closes. **N** skips ahead; **Esc**, a drag or a
 flight key stops it and leaves you where it was.
 
 **Or fly.** W A S D or the arrow keys move you, E climbs and Q descends, and shift
diff --git a/decisions.md b/decisions.md
index 31574ff..45595db 100644
--- a/decisions.md
+++ b/decisions.md
@@ -3195,3 +3195,16 @@ feature, a region band from 528.5 km to the cap, carries its name.
 at the waterline and its mantle thickening to 350 m; and on the compiled world, all round the
 ring, dry ground 1.5–4 km high at the cap that varies, fast ice at 526 km, open water at
 505. `test/terrain.test.ts` counts the new feature and probes the Deepward Sea short of it.
+
+## D-094 — The tour shows how long a leg has left: its caption's border fills in
+
+**Asked for** (author, 2026-09-29): "for the tour, it would be nice to have an indicator of
+'we've been here 11 second, 3 more until transition', but preferrably in some intuitive way,
+like the border of the tour text filling out or collapsing or something."
+
+**What it is** (`views/tour-player.ts`, `style.css .tour-ring`). The caption's border fills in
+clockwise from its top-left corner as the leg runs, in the accent colour over the caption's own
+faint border, and closes as the leg starts to fade to the next. The next leg's caption starts
+empty. No numbers: a clock read at a glance, the way a progress ring is. It is drawn as an SVG
+rectangle over the caption's border, its dash measured in pixels of the border's own length,
+which follows the caption's size as its text changes. Both tours, inside and out, have it.
diff --git a/index.html b/index.html
index 0ef87d5..7ffeec0 100644
--- a/index.html
+++ b/index.html
@@ -80,7 +80,7 @@
         <p class="hint">Spacecraft, a new one every twelve to eighteen seconds: skimming the
           hull, docking on it, looking in through the far cap, over the collector's rim, and
           Tower, the collector and the sun in one frame. Every flight keeps out of the beam.
-          <b>N</b> skips ahead; <b>Esc</b> or a drag stops it.</p>
+          The caption's border fills in as each one runs. <b>N</b> skips ahead; <b>Esc</b> or a drag stops it.</p>
       </div>
       <div id="interior-options" class="hidden">
         <p class="hint">Eye height is the gauge up the right-hand side of the view: drag it,
@@ -90,7 +90,8 @@
           paragliders on the ridges, gliders in thermals, light aircraft and seaplanes, an
           airliner round the Girdle, airships — and the giants that fly near the axis, round
           the sun — trains on the two railways, and ships in weather. The camera turns to
-          whatever can be seen from there. <b>N</b> skips ahead; <b>Esc</b>, a drag or a
+          whatever can be seen from there. The caption's border fills in as each one runs.
+          <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. On a trackpad, a two-finger swipe moves you the
diff --git a/src/style.css b/src/style.css
index 8008d82..50a4f25 100644
--- a/src/style.css
+++ b/src/style.css
@@ -260,4 +260,14 @@ input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); }
   background: rgba(10,13,20,0.72); border: 1px solid var(--line); border-radius: 6px;
   padding: 8px 14px; font-size: 13px; line-height: 1.5; pointer-events: none;
 }
+/* The leg's progress: the caption's border filling in clockwise until the next leg (D-094). */
+.tour-ring {
+  position: absolute; inset: -1px; width: calc(100% + 2px); height: calc(100% + 2px);
+  overflow: visible; pointer-events: none;
+}
+.tour-ring rect {
+  x: 1px; y: 1px; width: calc(100% - 2px); height: calc(100% - 2px);
+  fill: none; stroke: var(--accent); stroke-width: 2px; stroke-opacity: 0.85;
+  stroke-dasharray: 0 10000; stroke-linecap: round;
+}
 @media (max-width: 720px) { .tour-caption { top: 56px; font-size: 11px; } }
diff --git a/src/views/tour-player.ts b/src/views/tour-player.ts
index 97c7878..e64ef2f 100644
--- a/src/views/tour-player.ts
+++ b/src/views/tour-player.ts
@@ -21,6 +21,7 @@ export interface PlayableLeg {
 }
 
 const theme = (l: PlayableLeg): string => l.subject?.id ?? l.id
+const SVG = 'http://www.w3.org/2000/svg'
 
 /** Seconds a change of leg takes to fade out, and in again once the ground has arrived. */
 const FADE_S = 0.6
@@ -43,12 +44,25 @@ export class TourPlayer<L extends PlayableLeg> {
   private rng: () => number = Math.random
   private fadeEl = document.createElement('div')
   private captionEl = document.createElement('div')
+  private textEl = document.createElement('span')
+  /**
+   * How much of the leg has been flown, drawn as the caption's border filling in clockwise
+   * from its top-left corner: when it closes, the leg fades to the next. A clock you read
+   * without reading anything.
+   */
+  private ringEl = document.createElementNS(SVG, 'rect')
 
   constructor(host: HTMLElement | null | undefined) {
     this.fadeEl.className = 'tour-fade'
     this.fadeEl.hidden = true
     this.captionEl.className = 'tour-caption'
     this.captionEl.hidden = true
+    const ring = document.createElementNS(SVG, 'svg')
+    ring.classList.add('tour-ring')
+    ring.setAttribute('aria-hidden', 'true')
+    this.ringEl.setAttribute('rx', '6')
+    ring.append(this.ringEl)
+    this.captionEl.append(this.textEl, ring)
     host?.append(this.fadeEl, this.captionEl)
   }
 
@@ -145,7 +159,14 @@ export class TourPlayer<L extends PlayableLeg> {
     const lapse = now.rate > 1 ? ` · time ×${now.rate}` : ''
     const caption = `${now.title}\n${VEHICLE_NAME[now.vehicle]}${lapse}${looking}\n`
       + `leg ${this.flown + 1} · N next · Esc stops`
-    if (this.captionEl.textContent !== caption) this.captionEl.textContent = caption
+    if (this.textEl.textContent !== caption) this.textEl.textContent = caption
+    // Full through the fade out and the wait for ground; empty as the next leg fades in.
+    const flying = now.durationS - FADE_S
+    const done = this.phase === 'out' ? (this.t > 0 ? 1 : 0)
+      : this.phase === 'wait' ? 0 : Math.min(1, Math.max(0, t / flying))
+    // In pixels of the border's own length, which changes with the caption's size.
+    const len = this.ringEl.getTotalLength()
+    this.ringEl.style.strokeDasharray = `${(done * len).toFixed(1)} ${len.toFixed(1)}`
     return this.phase !== 'out' || this.t > 0
   }
 
diff --git a/world-generation.md b/world-generation.md
index 28a05a9..b714cb0 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -1266,7 +1266,8 @@ draws in about 1.2 s.
 D-068 added a tour of nine thirty-second scenes, once the world had particular things in it
 worth finding. D-085 replaced it, at the author's request, with the world's own flyers:
 automatic flight, inside and out, a new craft every twelve to eighteen seconds, in an order
-dealt at random, with the camera pointed at whatever is worth seeing.
+dealt at random, with the camera pointed at whatever is worth seeing. The caption's border
+fills in clockwise as a leg runs and closes as it fades to the next (D-094).
 
 - **A leg is a craft on a path, and a camera on the craft** (`tour/interior-legs.ts`,
   `tour/exterior-legs.ts`). Twenty-six legs inside and nine outside, each finding its