fuzzyworld

basin / commit

Move controls to a sidebar and refine the config UI

bfd86d6e59efbb37b6560a2e791ca357b47cd761 ยท Pavel Safronov ยท 2026-07-01 07:54:59 -0700 ยท parents 7b368f91bd58

Move controls to a sidebar and refine the config UI

Layout: the controls moved from stacked on top of the board into a sidebar
beside it, with a responsive fallback to the stacked layout on narrow screens.
The renderer now sizes the board to its container (not the whole window) so it
never underlaps the sidebar, and body/#app use flex + min-width:0 to keep the
board from overflowing the viewport.

Config polish: the Walls checkboxes sit on one horizontal row; the Terrain seed
is a free-text field (paste a number or reroll) instead of a number spinner,
validated to accept only non-negative integers; and the win text is pluralized
("Solved! The spring found" for one, "All N springs found" otherwise). Adds a
GitLab source link to the footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
 index.html             | 101 +++++++++++++++++++++++++++----------------------
 src/game.ts            |   5 ++-
 src/main.ts            |   9 +++--
 src/render/renderer.ts |   7 +++-
 src/style.css          |  68 +++++++++++++++++++++++++++++----
 5 files changed, 131 insertions(+), 59 deletions(-)

diff --git a/index.html b/index.html
index 99a30f5..63d3ba2 100644
--- a/index.html
+++ b/index.html
@@ -7,51 +7,62 @@
   </head>
   <body>
     <div id="app">
-      <header>
-        <img id="logo" class="logo" alt="basin logo" />
-        <h1>basin</h1>
-        <button id="randomize">๐ŸŽฒ Random</button>
-      </header>
-      <section id="config" class="config">
-        <label class="ctl">
-          Size <span id="cfg-size-val" class="val"></span>
-          <input type="range" id="cfg-size" min="5" max="20" step="1" />
-        </label>
-        <label class="ctl">
-          Springs
-          <input type="number" id="cfg-springs" min="1" max="6" step="1" />
-        </label>
-        <fieldset class="ctl edges">
-          <legend>Walls</legend>
-          <label><input type="checkbox" id="cfg-wall-n" /> N</label>
-          <label><input type="checkbox" id="cfg-wall-e" /> E</label>
-          <label><input type="checkbox" id="cfg-wall-s" /> S</label>
-          <label><input type="checkbox" id="cfg-wall-w" /> W</label>
-        </fieldset>
-        <label class="ctl">
-          Terrain
-          <span class="seedrow">
-            <input type="number" id="cfg-seed" min="0" step="1" />
-            <button id="cfg-seed-roll" title="Randomize terrain">๐ŸŽฒ</button>
-          </span>
-        </label>
-        <label class="ctl code">
-          Code
-          <input type="text" id="cfg-code" spellcheck="false" autocomplete="off" />
-        </label>
-      </section>
-      <canvas id="board"></canvas>
-      <p id="status" class="status"></p>
-      <p class="hint">
-        Find the hidden springs that make water exit exactly at the gold edges.
-        Water leaves only at ocean (blue) sides โ€” mountain walls block it. Tap to
-        explore the terrain; land on a spring and it locks in.
-      </p>
-      <footer class="credit">
-        Logo by
-        <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">icongeek26</a>
-        on Flaticon
-      </footer>
+      <aside class="panel">
+        <header>
+          <img id="logo" class="logo" alt="basin logo" />
+          <h1>basin</h1>
+          <button id="randomize">๐ŸŽฒ Random</button>
+        </header>
+        <section id="config" class="config">
+          <label class="ctl">
+            Size <span id="cfg-size-val" class="val"></span>
+            <input type="range" id="cfg-size" min="5" max="20" step="1" />
+          </label>
+          <label class="ctl">
+            Springs
+            <input type="number" id="cfg-springs" min="1" max="6" step="1" />
+          </label>
+          <fieldset class="ctl edges">
+            <legend>Walls</legend>
+            <label><input type="checkbox" id="cfg-wall-n" /> N</label>
+            <label><input type="checkbox" id="cfg-wall-e" /> E</label>
+            <label><input type="checkbox" id="cfg-wall-s" /> S</label>
+            <label><input type="checkbox" id="cfg-wall-w" /> W</label>
+          </fieldset>
+          <label class="ctl">
+            Terrain
+            <span class="seedrow">
+              <input
+                type="text"
+                id="cfg-seed"
+                inputmode="numeric"
+                spellcheck="false"
+                autocomplete="off"
+              />
+              <button id="cfg-seed-roll" title="Randomize terrain">๐ŸŽฒ</button>
+            </span>
+          </label>
+          <label class="ctl code">
+            Code
+            <input type="text" id="cfg-code" spellcheck="false" autocomplete="off" />
+          </label>
+        </section>
+        <p id="status" class="status"></p>
+        <p class="hint">
+          Find the hidden springs that make water exit exactly at the gold edges.
+          Water leaves only at ocean (blue) sides โ€” mountain walls block it. Tap to
+          explore the terrain; land on a spring and it locks in.
+        </p>
+        <footer class="credit">
+          <a href="https://gitlab.com/PavelSafronov/basin" target="_blank" rel="noopener">Source on GitLab</a>
+          ยท Logo by
+          <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">icongeek26</a>
+          on Flaticon
+        </footer>
+      </aside>
+      <main class="board-area">
+        <canvas id="board"></canvas>
+      </main>
     </div>
     <script type="module" src="/src/main.ts"></script>
   </body>
diff --git a/src/game.ts b/src/game.ts
index 492c15d..5e15310 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -62,9 +62,10 @@ export class Game {
 
   private updateStatus(): void {
     const n = this.puzzle.solution.length;
+    const springs = n === 1 ? 'spring' : 'springs';
     this.statusEl.textContent = this.solved
-      ? `Solved! All ${n} springs found ๐ŸŽ‰`
-      : `${this.found.length} / ${n} springs found`;
+      ? `Solved! ${n === 1 ? 'The spring' : `All ${n} springs`} found ๐ŸŽ‰`
+      : `${this.found.length} / ${n} ${springs} found`;
   }
 
   private draw(): void {
diff --git a/src/main.ts b/src/main.ts
index 6af3ea9..5d8d330 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -76,9 +76,12 @@ for (const side of ['n', 'e', 's', 'w'] as const) {
     apply({ ...config, edges: { ...config.edges, [side]: edgeOf(wall[side].checked) } }),
   );
 }
-seedInput.addEventListener('change', () =>
-  apply({ ...config, terrainSeed: Number(seedInput.value) >>> 0 }),
-);
+seedInput.addEventListener('change', () => {
+  // Free-text field: accept a non-negative integer, otherwise snap back.
+  const raw = seedInput.value.trim();
+  if (/^\d+$/.test(raw)) apply({ ...config, terrainSeed: Number(raw) >>> 0 });
+  else syncControls();
+});
 $<HTMLButtonElement>('#cfg-seed-roll').addEventListener('click', () =>
   apply({ ...config, terrainSeed: (Math.random() * 2 ** 32) >>> 0 }),
 );
diff --git a/src/render/renderer.ts b/src/render/renderer.ts
index 10736ac..63271fd 100644
--- a/src/render/renderer.ts
+++ b/src/render/renderer.ts
@@ -38,7 +38,12 @@ export class Renderer {
 
   render(hm: Heightmap, flow: FlowResult | null, view?: PuzzleView | null): void {
     const dpr = window.devicePixelRatio || 1;
-    const maxPx = Math.min(window.innerWidth - 32, 560);
+    // Fit the board to its container (the board area beside the sidebar), not the
+    // whole window, so it never underlaps the controls. Fall back to the window
+    // width if the canvas isn't in the layout yet.
+    const parent = this.canvas.parentElement;
+    const avail = parent && parent.clientWidth > 0 ? parent.clientWidth : window.innerWidth - 32;
+    const maxPx = Math.min(Math.max(avail, 120), 640);
     this.cell = Math.max(1, Math.floor(maxPx / hm.width));
     const w = this.cell * hm.width;
     const h = this.cell * hm.height;
diff --git a/src/style.css b/src/style.css
index 126914c..3735dd7 100644
--- a/src/style.css
+++ b/src/style.css
@@ -5,8 +5,9 @@
 
 body {
   margin: 0;
-  display: grid;
-  place-items: start center;
+  display: flex;
+  justify-content: center;
+  align-items: flex-start;
   min-height: 100vh;
   background: #11151c;
   color: #e7ecf3;
@@ -15,9 +16,32 @@ body {
 #app {
   padding: 16px;
   display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  gap: 24px;
+  width: 100%;
+  min-width: 0;
+  max-width: 1100px;
+  box-sizing: border-box;
+}
+
+/* Controls live in a sidebar beside the board rather than stacked on top of it. */
+.panel {
+  flex: 0 0 300px;
+  width: 300px;
+  display: flex;
   flex-direction: column;
-  align-items: center;
   gap: 12px;
+  position: sticky;
+  top: 16px;
+}
+
+.board-area {
+  flex: 1 1 auto;
+  min-width: 0;
+  display: flex;
+  justify-content: center;
+  align-items: flex-start;
 }
 
 header {
@@ -59,8 +83,8 @@ header button {
 
 .config {
   display: flex;
-  flex-wrap: wrap;
-  align-items: flex-end;
+  flex-direction: column;
+  align-items: stretch;
   gap: 10px 16px;
   width: 100%;
   padding: 10px 12px;
@@ -112,11 +136,20 @@ header button {
   gap: 4px;
 }
 
+/* The seed is a free-text field (paste a number or reroll) rather than a spinner,
+   so let it grow to fit a full 32-bit value beside the reroll button. */
+.seedrow input {
+  flex: 1;
+  min-width: 0;
+}
+
 .seedrow button {
   padding: 4px 8px;
 }
 
 .edges {
+  /* Override the column layout `.ctl` applies, so the four sides sit on one row. */
+  display: block;
   border: 1px solid rgba(255, 255, 255, 0.15);
   border-radius: 6px;
   padding: 2px 8px 6px;
@@ -132,7 +165,7 @@ header button {
   display: inline-flex;
   align-items: center;
   gap: 2px;
-  margin-right: 6px;
+  margin-right: 10px;
 }
 
 canvas {
@@ -152,8 +185,7 @@ canvas {
   margin: 0;
   font-size: 0.85rem;
   opacity: 0.65;
-  max-width: 36ch;
-  text-align: center;
+  line-height: 1.4;
 }
 
 .credit {
@@ -165,3 +197,23 @@ canvas {
 .credit a {
   color: inherit;
 }
+
+/* Narrow screens: fall back to the stacked layout, controls above the board. */
+@media (max-width: 720px) {
+  #app {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .panel {
+    flex-basis: auto;
+    width: 100%;
+    position: static;
+  }
+
+  .config {
+    flex-direction: row;
+    flex-wrap: wrap;
+    align-items: flex-end;
+  }
+}