fuzzyworld

nordic-skating / commit

feat(ui): a sound button that says which way it is set, and links to the README and source

95f8bb948ce102e67f6ecb7f1497228cb30bd0ce · Pavel Safronov · 2026-10-09 08:29:34 -0700 · parents 1cb1fefce258

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 1 tally 470 in / 402.0K out / 30.6M cached · usage by commit and session

feat(ui): a sound button that says which way it is set, and links to the README and source

The button is large, at the top right, and reads Tap for sound, Sound on
or Sound off; M does the same. Muted, a touch of the ice no longer
starts the sound, and the choice is kept for the next visit. Nothing is
asked of the sound while it is off, or it would all play on its return.

About and Source at the top left open the README and the repository in
a new tab.

Screenshots retaken with both.

Usage: 470 in / 402.0K out / 30.6M cached (session tally)
rtk:   1.8M saved, 25.6% over 2830 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                      |  11 +++++--
 docs/screenshots/autopilot.png | Bin 187810 -> 211085 bytes
 docs/screenshots/far.png       | Bin 227583 -> 224452 bytes
 docs/screenshots/landscape.png | Bin 319389 -> 245957 bytes
 docs/screenshots/steering.png  | Bin 314805 -> 253068 bytes
 docs/screenshots/thin-ice.png  | Bin 289903 -> 267775 bytes
 index.html                     |   7 ++++-
 src/main.ts                    |  64 +++++++++++++++++++++++++++++++++++------
 src/style.css                  |  44 ++++++++++++++++++++++++++++
 9 files changed, 114 insertions(+), 12 deletions(-)

diff --git a/README.md b/README.md
index 926e862..a45d681 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,11 @@ and at its thinnest gives way.
 
 ## Controls
 
-Tap or press a key once to turn the sound on: a browser lets a page make sound only
-after it has been touched. On an iPhone the ring/silent switch also silences it.
+The button at the top right turns the sound on and off, as does the M key, and says
+which it is. Sound cannot start by itself: a browser lets a page make sound only after
+it has been touched, so until then the button reads "Tap for sound", and the first tap
+or key anywhere starts it. Turning it off is remembered for next time. On an iPhone the
+ring/silent switch also silences it.
 
 Do nothing and the skater follows the middle of the lake, round every bend and down
 one side of every fork, for as long as you leave it.
@@ -40,6 +43,10 @@ one side of every fork, for as long as you leave it.
 |---|---|---|
 | Steer left or right | Drag sideways from wherever you touch | ← → or A D |
 | Choose a way at a fork | Steer towards it before the island begins | the same |
+| Sound on or off | The button at the top right | M |
+
+The links at the top left, About and Source, open this README and the repository in a
+new tab, so the skate is still going when you come back.
 
 Steering moves the skater across the lake, never into a shore. Let go and the skater
 holds that line for a second and a half, then eases back to the middle.
diff --git a/docs/screenshots/autopilot.png b/docs/screenshots/autopilot.png
index e33572f..b02260e 100644
Binary files a/docs/screenshots/autopilot.png and b/docs/screenshots/autopilot.png differ
diff --git a/docs/screenshots/far.png b/docs/screenshots/far.png
index 45ea391..b54ee97 100644
Binary files a/docs/screenshots/far.png and b/docs/screenshots/far.png differ
diff --git a/docs/screenshots/landscape.png b/docs/screenshots/landscape.png
index 403eb55..77988ec 100644
Binary files a/docs/screenshots/landscape.png and b/docs/screenshots/landscape.png differ
diff --git a/docs/screenshots/steering.png b/docs/screenshots/steering.png
index 002dcf3..c296417 100644
Binary files a/docs/screenshots/steering.png and b/docs/screenshots/steering.png differ
diff --git a/docs/screenshots/thin-ice.png b/docs/screenshots/thin-ice.png
index 9ebf776..a0b7b66 100644
Binary files a/docs/screenshots/thin-ice.png and b/docs/screenshots/thin-ice.png differ
diff --git a/index.html b/index.html
index aa4a089..5e282fe 100644
--- a/index.html
+++ b/index.html
@@ -8,8 +8,13 @@
 </head>
 <body>
 <canvas id="scene"></canvas>
+<nav id="links">
+  <a href="https://gitlab.com/PavelSafronov/nordic-skating/-/blob/main/README.md" target="_blank" rel="noopener">About</a>
+  <a href="https://gitlab.com/PavelSafronov/nordic-skating" target="_blank" rel="noopener">Source</a>
+</nav>
+<button id="sound" type="button"></button>
 <div id="water"><p>The ice gave way</p></div>
-<p id="hint">Tap for sound · drag left or right to steer — or just watch</p>
+<p id="hint">Drag left or right to steer — or just watch</p>
 <script type="module" src="/src/main.ts"></script>
 </body>
 </html>
diff --git a/src/main.ts b/src/main.ts
index 0c3a96c..57596a6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -16,6 +16,8 @@ const SEED = 1
 const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
 const hint = document.querySelector<HTMLElement>('#hint')!
 const water = document.querySelector<HTMLElement>('#water')!
+const soundButton = document.querySelector<HTMLButtonElement>('#sound')!
+const links = document.querySelector<HTMLElement>('#links')!
 const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
 // Phones report a ratio of 3 or more, and the fill cost grows with its square.
 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
@@ -68,19 +70,62 @@ const clock = new THREE.Clock()
 setTimeout(() => hint.classList.add('gone'), 8000)
 
 // A browser lets a page make sound only once it has been touched, so the sound starts
-// at the first tap or key rather than with the page.
+// at the first tap or key rather than with the page — unless it has been muted, which
+// is remembered from one visit to the next.
 let audio: AudioContext | null = null
 let sound: IceSound | null = null
+let muted = false
+try {
+  muted = localStorage.getItem('muted') === '1'
+} catch {
+  // No storage, in a private window for one: the choice lasts for this visit only.
+}
+/** Whether the ice can be heard at this moment. */
+const sounding = (): boolean => audio?.state === 'running'
+
+function showSound(): void {
+  const on = audio !== null && !muted
+  soundButton.textContent = on ? '🔊 Sound on' : muted ? '🔇 Sound off' : '🔇 Tap for sound'
+  soundButton.setAttribute('aria-pressed', String(on))
+  soundButton.classList.toggle('on', on)
+}
 function startSound(): void {
-  if (audio) return void audio.resume()
-  audio = new AudioContext()
-  sound = new IceSound(audio)
+  if (muted) return
+  if (audio) void audio.resume()
+  else {
+    audio = new AudioContext()
+    sound = new IceSound(audio)
+    // The button reads "on" from the first touch; this is for when the browser disagrees.
+    audio.addEventListener('statechange', showSound)
+  }
+  showSound()
 }
-window.addEventListener('pointerdown', startSound)
-window.addEventListener('keydown', startSound)
+function setMuted(to: boolean): void {
+  muted = to
+  try {
+    localStorage.setItem('muted', to ? '1' : '0')
+  } catch {
+    // As above.
+  }
+  if (muted) void audio?.suspend()
+  else startSound()
+  showSound()
+}
+showSound()
+// A touch of the button must not also start the sound, since it has just been asked to
+// do the opposite; nor a touch of a link, which is someone on their way out.
+const aside = (e: Event): boolean => e.target === soundButton || links.contains(e.target as Node)
+soundButton.addEventListener('click', () => setMuted(audio !== null && !muted))
+window.addEventListener('pointerdown', (e) => {
+  if (!aside(e)) startSound()
+})
+window.addEventListener('keydown', (e) => {
+  if (e.code === 'KeyM') setMuted(audio !== null && !muted)
+  else if (!aside(e)) startSound()
+})
 // Frames stop while the page is hidden, but sound would not: the hum would drone on.
 document.addEventListener('visibilitychange', () => {
-  if (audio) void (document.hidden ? audio.suspend() : audio.resume())
+  if (audio && !muted) void (document.hidden ? audio.suspend() : audio.resume())
 })
 /** Seconds until the ice next cracks somewhere off across the lake. */
 let untilCrack = 3
@@ -95,11 +140,12 @@ renderer.setAnimationLoop(() => {
   stepSkater(skater, lake, steer, dt)
   const h = lake.thickness(skaterX(skater), skater.s, skater.channel)
   const fell = skater.under > 0 && !wasUnder
-  if (fell) sound?.fall(h)
+  // Nothing is asked of the sound while it is off: it would all play at once on its return.
+  if (fell && sounding()) sound?.fall(h)
   // In the water for all but the last second, which is the climb back out.
   water.classList.toggle('under', skater.under > 1)
 
-  if (sound && skater.under === 0) {
+  if (sound && sounding() && skater.under === 0) {
     // One strike of a skate at the start of each stride, no two quite alike.
     if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, 0.75 + 0.5 * Math.random())
     // The push is over half a second into a stride; the rest of it is glide.
diff --git a/src/style.css b/src/style.css
index 6901933..5aff98c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -54,3 +54,47 @@ body {
   opacity: 1;
   transition: opacity 0.25s 0.15s;
 }
+
+/* Big, in a corner, and saying in words which way it is set. */
+#sound {
+  position: fixed;
+  top: calc(0.75rem + env(safe-area-inset-top));
+  right: calc(0.75rem + env(safe-area-inset-right));
+  min-height: 3.25rem;
+  padding: 0 1.1rem;
+  border: 2px solid #fff;
+  border-radius: 2rem;
+  background: rgb(8 24 34 / 85%);
+  color: #fff;
+  font: 600 1.05rem/1 system-ui, sans-serif;
+  /* A tap on it is a tap, not the start of a drag or a double-tap zoom. */
+  touch-action: manipulation;
+  cursor: pointer;
+}
+
+#sound.on {
+  background: #fff;
+  color: #06202c;
+}
+
+/* The way to the README and the source: there, but smaller than the sound button. */
+#links {
+  position: fixed;
+  top: calc(0.75rem + env(safe-area-inset-top));
+  left: calc(0.75rem + env(safe-area-inset-left));
+  display: flex;
+  gap: 0.5rem;
+}
+
+#links a {
+  display: grid;
+  place-items: center;
+  min-height: 2.75rem;
+  padding: 0 0.9rem;
+  border-radius: 2rem;
+  background: rgb(8 24 34 / 70%);
+  color: #fff;
+  font: 500 0.95rem/1 system-ui, sans-serif;
+  text-decoration: none;
+  touch-action: manipulation;
+}