fuzzyworld

nordic-skating / commit

feat: a Tilt button, and a screen that stays put while tilt steers

4efa2e9aa7fd0b601f4c2195a85839f494ba5d12 · Pavel Safronov · 2026-10-09 22:50:08 -0700 · parents 9f2e250988c2

Claude Code usage: this commit ~24.9K out / ~7.4M cached · cost unknown (no per-model trailer) · session 1 tally 954 in / 808.3K out / 111.8M cached · usage by commit and session

feat: a Tilt button, and a screen that stays put while tilt steers

Turning tilt on in Settings was cumbersome. There is now a button for
it at the bottom left, hidden, like the setting, where there is nothing
to tilt. Refused by the browser, it opens Settings, where that is said.

While tilt is on the screen is stopped from turning with the phone,
since leaning a phone over is what makes its screen flip. A browser
does that only for a page that fills the screen: an installed app
already does, and a tab is put into full screen for as long as tilt is
on. An iPhone will do neither for a web page.

Fixed: with tilt remembered as on, the first tap of the button after a
reload turned it back on instead of off, on a browser that gives its
leave for tilt only at a touch.

Usage: 954 in / 808.3K out / 111.8M cached (session tally)
rtk:   1.7M saved, 25.3% over 2679 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 index.html    |  4 +++-
 src/main.ts   | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/style.css | 26 ++++++++++++++++++++++++++
 3 files changed, 81 insertions(+), 3 deletions(-)

diff --git a/index.html b/index.html
index d32e685..09cbfba 100644
--- a/index.html
+++ b/index.html
@@ -24,6 +24,8 @@
   <button id="randomize" type="button">Randomize</button>
 </nav>
 <button id="sound" type="button"></button>
+<!-- Shown only on what can be tilted (main.ts). -->
+<button id="tilt-button" type="button" hidden></button>
 <dialog id="about-dialog" aria-labelledby="about-title">
   <h1 id="about-title" tabindex="-1" autofocus>Nordic Skating</h1>
   <p>An endless skate over thin black ice. The ice sings under every stride, and the thinner it is, the higher it sings. Leave it alone and it skates itself, for as long as you like.</p>
@@ -31,7 +33,7 @@
   <h2>Controls</h2>
   <dl>
     <dt>Steer</dt>
-    <dd>Drag left or right from wherever you touch, or use ← → or A D; or, on a phone, turn on Tilt to steer in Settings and lean it. Let go and the skater straightens up, then drifts back to its own line. Hold it over and the skater turns right round: there is room for a circle wherever the water is wide.</dd>
+    <dd>Drag left or right from wherever you touch, or use ← → or A D; or, on a phone, press Tilt at the bottom left and lean it. Let go and the skater straightens up, then drifts back to its own line. Hold it over and the skater turns right round: there is room for a circle wherever the water is wide.</dd>
     <dt>Choose a way at a fork</dt>
     <dd>A touch towards it on the way up to the fork is enough. Do nothing and the skater picks one.</dd>
     <dt>Islets</dt>
diff --git a/src/main.ts b/src/main.ts
index 2f02cc3..e4d000f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -182,21 +182,68 @@ for (const [button, box] of [[aboutButton, about], [settingsButton, settingsBox]
 const impossible = settingsBox.querySelector<HTMLInputElement>('#impossible')!
 const tilt = settingsBox.querySelector<HTMLInputElement>('#tilt')!
 const tiltRefused = settingsBox.querySelector<HTMLElement>('#tilt-refused')!
-// Steering by tilt is offered only on what can be tilted.
+const tiltButton = document.querySelector<HTMLButtonElement>('#tilt-button')!
+// Steering by tilt is offered only on what can be tilted: in the settings, and by a
+// button of its own at the foot of the screen, which is the quick way to it.
 settingsBox.querySelector<HTMLElement>('#steering')!.hidden = !Input.canTilt
+tiltButton.hidden = !Input.canTilt
+// A touch of the button is the touch an iPhone wants before it will give its leave.
+tiltButton.addEventListener('click', () => void setTilt(!settings.tilt))
+/** Whether the page was put into full screen in order to hold the screen's rotation. */
+let wentFullScreen = false
+/**
+ * Stop the screen turning with the phone while tilt steers, and let it turn again after.
+ * Leaning a phone over to steer is exactly what makes its screen flip to the other
+ * orientation, and the lake with it.
+ *
+ * A browser holds the rotation only for a page that fills the screen: an installed app
+ * already does, and in a browser tab the page is put into full screen to get it, and
+ * taken out again when tilt is turned off. Where the browser will do neither, as on an
+ * iPhone, nothing happens, and the phone's own rotation lock is the way.
+ */
+async function holdRotation(hold: boolean): Promise<void> {
+  const turning = screen.orientation as (ScreenOrientation & { lock?: (to: string) => Promise<void> }) | undefined
+  try {
+    if (!hold) {
+      turning?.unlock?.()
+      if (wentFullScreen && document.fullscreenElement) await document.exitFullscreen()
+      wentFullScreen = false
+      return
+    }
+    if (!turning?.lock) return
+    try {
+      await turning.lock(turning.type)
+    } catch {
+      if (!document.fullscreenElement) {
+        await document.documentElement.requestFullscreen()
+        wentFullScreen = true
+      }
+      await turning.lock(turning.type)
+    }
+  } catch {
+    // Not allowed here, or not without a touch: the screen turns as it always did.
+  }
+}
+
 /** Turn steering by tilt on or off, and say so if the browser will not have it. */
 async function setTilt(on: boolean): Promise<void> {
   const given = on && (await input.tiltOn())
   if (!given) input.tiltOff()
   tiltRefused.hidden = !on || given
+  // Refused from the button, there is nowhere to say so but the settings: open them there.
+  if (on && !given && !settingsBox.open) settingsButton.click()
   settings.tilt = given
   saveSettings(settings)
   showSettings()
+  void holdRotation(given)
 }
 // Remembered from last time. An iPhone gives its leave afresh each visit and only at a
 // touch, so there it is asked for at the first one.
 if (settings.tilt && Input.canTilt) {
-  if (Input.tiltNeedsTouch) window.addEventListener('pointerdown', () => void setTilt(true), { once: true })
+  // (Unless that touch is of the Tilt button, which is someone turning it off.)
+  if (Input.tiltNeedsTouch) {
+    window.addEventListener('pointerdown', (e) => void (e.target !== tiltButton && setTilt(true)), { once: true })
+  }
   else void setTilt(true)
 }
 const impossibleOnly = document.querySelectorAll<HTMLElement>('.impossible-only')
@@ -206,6 +253,9 @@ function showSettings(): void {
   }
   impossible.checked = settings.impossibleIce
   tilt.checked = settings.tilt
+  tiltButton.textContent = settings.tilt ? '📱 Tilt on' : '📱 Tilt off'
+  tiltButton.setAttribute('aria-pressed', String(settings.tilt))
+  tiltButton.classList.toggle('on', settings.tilt)
   hint.textContent = settings.tilt ? 'Tilt to steer — or just watch' : 'Drag left or right to steer — or just watch'
   // What the About box says of impossible ice is there only when the ice is.
   for (const part of impossibleOnly) part.hidden = !settings.impossibleIce
diff --git a/src/style.css b/src/style.css
index 63b1560..6a96409 100644
--- a/src/style.css
+++ b/src/style.css
@@ -242,3 +242,29 @@ dialog button.done {
     font-size: 0.9rem;
   }
 }
+
+/* Steering by tilt, on or off: the sound button's smaller cousin, at the foot of the screen. */
+#tilt-button {
+  position: fixed;
+  left: calc(0.75rem + env(safe-area-inset-left));
+  bottom: calc(0.75rem + env(safe-area-inset-bottom));
+  min-height: 2.75rem;
+  padding: 0 0.9rem;
+  border: 2px solid #fff;
+  border-radius: 2rem;
+  background: rgb(8 24 34 / 85%);
+  color: #fff;
+  font: 600 0.95rem/1 system-ui, sans-serif;
+  touch-action: manipulation;
+  cursor: pointer;
+}
+
+#tilt-button.on {
+  background: #fff;
+  color: #06202c;
+}
+
+/* The hint keeps clear of it. */
+#tilt-button:not([hidden]) ~ #hint {
+  bottom: calc(4.5rem + env(safe-area-inset-bottom));
+}