fuzzyworld

nuts / commit

ui changes, moved most controls to the landing page

48f320c19ff52eaf322046c31b85907d86501bc5 · Pavel Safronov · 2026-04-12 10:54:43 -0700 · parents b008eb4c45d9

ui changes, moved most controls to the landing page
 index.html         |  33 +++++++++++-----
 src/main.ts        |  27 ++++++++------
 src/persistence.ts |   1 -
 src/style.css      | 108 +++++++++++++++++++++++++++++++++++++++++++++--------
 src/ui.ts          |  91 +++++++++++++++++++++++++++++++++-----------
 5 files changed, 201 insertions(+), 59 deletions(-)

diff --git a/index.html b/index.html
index d7bbb7c..9ae4a9d 100644
--- a/index.html
+++ b/index.html
@@ -47,8 +47,29 @@
         </div>
     </div>
 
-    <!-- Settings button -->
-    <button id="settings-btn" aria-label="Settings" hidden>⚙</button>
+    <!-- Top toolbar (hidden until game starts) -->
+    <div id="toolbar" hidden>
+        <!-- Kind cycle: random → nut → gem → balloon → random -->
+        <button id="kind-btn" class="toolbar-btn" aria-label="Item type">?</button>
+
+        <!-- Size: tap to reveal slider pill -->
+        <button id="size-btn" class="toolbar-btn" aria-label="Size">●</button>
+
+        <!-- Freeze toggle -->
+        <button id="freeze-btn" class="toolbar-btn" aria-label="Freeze">⏸</button>
+
+        <!-- Clear -->
+        <button id="clear-top-btn" class="toolbar-btn" aria-label="Clear">✕</button>
+
+        <!-- Settings -->
+        <button id="settings-btn" class="toolbar-btn" aria-label="Settings">⚙</button>
+    </div>
+
+    <!-- Size slider pill (slides down from toolbar) -->
+    <div id="size-pill" hidden>
+        <input type="range" id="nut-size" min="10" max="180" value="130" step="1" />
+        <span id="nut-size-display" class="size-pill-value">130</span>
+    </div>
 
     <!-- Settings panel -->
     <div id="settings-panel" hidden>
@@ -57,14 +78,6 @@
             <div class="panel-handle"></div>
             <h2>Settings</h2>
 
-            <label class="setting-row">
-                <span>Size</span>
-                <span id="nut-size-display" class="setting-value">130</span>
-            </label>
-            <input type="range" id="nut-size" min="10" max="180" value="130" step="1" />
-
-            <button id="lock-btn" class="action-btn">⏸ Pause</button>
-            <button id="clear-btn" class="action-btn">Clear</button>
             <button id="test-vibrate-btn" class="action-btn">Test vibration</button>
             <button id="close-settings" class="close-btn">Done</button>
 
diff --git a/src/main.ts b/src/main.ts
index a0f6725..6b70c38 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,7 +2,7 @@ import { CONFIG } from './config'
 import { createPhysicsWorld } from './physics'
 import { createRenderer } from './renderer'
 import { requestMotionPermission, initMotion } from './motion'
-import { initUI, removeStartOverlay } from './ui'
+import { initUI, removeStartOverlay, type ItemKind } from './ui'
 import { setFlashFallback, unlockVibration } from './vibration'
 import { dbg, dbgError } from './debug'
 import { checkCompat, showUnsupportedWall } from './compat'
@@ -58,7 +58,6 @@ async function main(): Promise<void> {
     const savedSettings = loadSettings()
     if (savedSettings) {
         currentNutRadius = savedSettings.nutRadius
-        tapsLocked = savedSettings.locked
     }
 
     // ── Restore nut positions (share URL takes priority over snapshot) ────────
@@ -104,7 +103,7 @@ async function main(): Promise<void> {
         initialLocked: tapsLocked,
         onSizeChange: (radius) => {
             currentNutRadius = radius
-            saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked })
+            saveSettings({ nutRadius: currentNutRadius })
         },
         onClear: () => {
             physics.clearNuts()
@@ -112,12 +111,15 @@ async function main(): Promise<void> {
         },
         onLockChange: (locked) => {
             tapsLocked = locked
-            saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked })
+            saveSettings({ nutRadius: currentNutRadius })
         },
+        onKindChange: (kind) => { currentKind = kind },
         getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
     })
 
     // ── Helpers ───────────────────────────────────────────────────────────────
+    let currentKind: ItemKind = 'random'
+
     function hasSpace(x: number, y: number, r: number): boolean {
         for (const { body, radius } of physics.nuts) {
             const dx = body.position.x - x
@@ -129,11 +131,15 @@ async function main(): Promise<void> {
     }
 
     function tryAddNut(cx: number, cy: number): void {
-        if (tapsLocked) return  // already guarded at pointerdown, but keep for safety
+        if (tapsLocked) return
         if (hasSpace(cx, cy, currentNutRadius)) {
-            const rand = Math.random()
-            if (rand < 0.20) physics.addBalloon(cx, cy, currentNutRadius)
-            else if (rand < 0.45) physics.addGem(cx, cy, currentNutRadius)
+            let kind = currentKind
+            if (kind === 'random') {
+                const rand = Math.random()
+                kind = rand < 0.20 ? 'balloon' : rand < 0.45 ? 'gem' : 'nut'
+            }
+            if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius)
+            else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius)
             else physics.addNut(cx, cy, currentNutRadius)
         }
     }
@@ -155,11 +161,10 @@ async function main(): Promise<void> {
         markActive()
         if (tapsLocked) return
         pointerDown = true
-        canvas.setPointerCapture(e.pointerId)
+        try { canvas.setPointerCapture(e.pointerId) } catch { /* ignore */ }
         const rect = canvas.getBoundingClientRect()
         const x = e.clientX - rect.left
         const y = e.clientY - rect.top
-        // Hit an existing item → erase mode
         eraseMode = physics.removeAt(x, y)
         if (!eraseMode) tryAddNut(x, y)
     })
@@ -302,7 +307,7 @@ async function main(): Promise<void> {
     // Also save on tab close / navigation
     window.addEventListener('pagehide', () => {
         saveSnapshot(physics.nuts, cssWidth, cssHeight)
-        saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked })
+        saveSettings({ nutRadius: currentNutRadius })
     })
 }
 
diff --git a/src/persistence.ts b/src/persistence.ts
index f380786..2839c97 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -10,7 +10,6 @@ const KEYS = {
 export interface PersistedSettings {
     v: number
     nutRadius: number
-    locked: boolean
 }
 
 export function loadSettings(): PersistedSettings | null {
diff --git a/src/style.css b/src/style.css
index 5433a92..a067e82 100644
--- a/src/style.css
+++ b/src/style.css
@@ -100,34 +100,110 @@ body {
     text-transform: uppercase;
 }
 
-/* ── Settings button ────────────────────────────────────────── */
-#settings-btn {
+/* ── Toolbar ─────────────────────────────────────────────────── */
+#toolbar {
     position: fixed;
-    top: max(16px, env(safe-area-inset-top, 16px));
-    right: max(16px, env(safe-area-inset-right, 16px));
-    width: 44px;
-    height: 44px;
+    top: max(10px, env(safe-area-inset-top, 10px));
+    left: 50%;
+    transform: translateX(-50%);
+    display: none;
+    gap: 8px;
+    z-index: 50;
+    padding: 6px 10px;
+    background: rgba(20, 20, 20, 0.72);
+    border-radius: 99px;
+    backdrop-filter: blur(12px);
+    -webkit-backdrop-filter: blur(12px);
+    border: 1px solid rgba(255, 255, 255, 0.08);
+    pointer-events: none;
+    /* container doesn't intercept — buttons do */
+}
+
+#toolbar:not([hidden]) {
+    display: flex;
+}
+
+.toolbar-btn {
+    width: 40px;
+    height: 40px;
     border-radius: 50%;
     border: none;
-    background: rgba(255, 255, 255, 0.1);
-    color: #aaa;
-    font-size: 22px;
+    background: transparent;
+    color: #bbb;
+    font-size: 18px;
     cursor: pointer;
-    z-index: 50;
-    backdrop-filter: blur(8px);
-    -webkit-backdrop-filter: blur(8px);
     display: flex;
     align-items: center;
     justify-content: center;
-    transition: background 0.2s;
+    transition: background 0.15s, color 0.15s;
+    flex-shrink: 0;
+    pointer-events: auto;
+    /* re-enable on each button */
+    -webkit-tap-highlight-color: transparent;
+}
+
+.toolbar-btn:active {
+    background: rgba(255, 255, 255, 0.15);
+}
+
+.toolbar-btn.active {
+    color: #8fce8f;
+    background: rgba(143, 206, 143, 0.15);
+}
+
+.toolbar-btn.kind-nut {
+    color: #c8a84b;
 }
 
-#settings-btn:not([hidden]) {
+.toolbar-btn.kind-gem {
+    color: #80A8FF;
+}
+
+.toolbar-btn.kind-balloon {
+    color: #FF4466;
+}
+
+.toolbar-btn.kind-random {
+    color: #aaa;
+}
+
+/* ── Size pill (slides down from toolbar) ────────────────────── */
+#size-pill {
+    position: fixed;
+    top: max(68px, calc(env(safe-area-inset-top, 10px) + 58px));
+    left: 50%;
+    transform: translateX(-50%);
+    z-index: 49;
+    background: rgba(20, 20, 20, 0.88);
+    border-radius: 99px;
+    border: 1px solid rgba(255, 255, 255, 0.1);
+    backdrop-filter: blur(12px);
+    -webkit-backdrop-filter: blur(12px);
+    padding: 10px 18px;
+    display: none;
+    align-items: center;
+    gap: 12px;
+    min-width: 240px;
+    pointer-events: none;
+}
+
+#size-pill:not([hidden]) {
     display: flex;
+    pointer-events: auto;
+}
+
+.size-pill-value {
+    font-family: system-ui, -apple-system, sans-serif;
+    font-size: 14px;
+    font-weight: 600;
+    color: #c8a84b;
+    min-width: 28px;
+    text-align: right;
 }
 
-#settings-btn:active {
-    background: rgba(255, 255, 255, 0.2);
+/* Settings btn is now inside toolbar — no separate fixed positioning */
+#settings-btn {
+    /* inherits .toolbar-btn */
 }
 
 /* ── Settings panel ─────────────────────────────────────────── */
diff --git a/src/ui.ts b/src/ui.ts
index 614e7af..577b6d7 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -2,48 +2,68 @@ import { CONFIG } from './config'
 import { dbg } from './debug'
 import QRCode from 'qrcode'
 
+export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon'
+
+const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon']
+const KIND_ICON: Record<ItemKind, string> = {
+    random: '?',
+    nut: '🌰',
+    gem: '💎',
+    balloon: '🎈',
+}
+
 export interface UICallbacks {
     initialRadius: number
     initialLocked: boolean
     onSizeChange: (radius: number) => void
     onClear: () => void
     onLockChange: (locked: boolean) => void
+    onKindChange: (kind: ItemKind) => void
     getShareURL: () => string
 }
 
 export function initUI(callbacks: UICallbacks): void {
+    const toolbar = document.getElementById('toolbar') as HTMLDivElement
+    const kindBtn = document.getElementById('kind-btn') as HTMLButtonElement
+    const sizeBtn = document.getElementById('size-btn') as HTMLButtonElement
+    const freezeBtn = document.getElementById('freeze-btn') as HTMLButtonElement
+    const clearTopBtn = document.getElementById('clear-top-btn') as HTMLButtonElement
     const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement
-    const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
+    const sizePill = document.getElementById('size-pill') as HTMLDivElement
     const nutSizeInput = document.getElementById('nut-size') as HTMLInputElement
     const nutSizeDisplay = document.getElementById('nut-size-display') as HTMLSpanElement
-    const lockBtn = document.getElementById('lock-btn') as HTMLButtonElement
-    const clearBtn = document.getElementById('clear-btn') as HTMLButtonElement
+
+    const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
     const testVibrateBtn = document.getElementById('test-vibrate-btn') as HTMLButtonElement
     const closeBtn = document.getElementById('close-settings') as HTMLButtonElement
     const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement
     const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement
     const copyLinkBtn = document.getElementById('copy-link-btn') as HTMLButtonElement
 
-    // Sync slider from config bounds + persisted value
+    // ── Size slider ──────────────────────────────────────────────────────────
     nutSizeInput.min = String(CONFIG.nutMinRadius)
     nutSizeInput.max = String(CONFIG.nutMaxRadius)
     nutSizeInput.value = String(callbacks.initialRadius)
     nutSizeDisplay.textContent = String(callbacks.initialRadius)
 
-    let locked = callbacks.initialLocked
-    lockBtn.textContent = locked ? '▶ Resume' : '⏸ Pause'
-    lockBtn.classList.toggle('active', locked)
-
-    // Show settings button
-    settingsBtn.hidden = false
+    let sizePillOpen = false
 
-    settingsBtn.addEventListener('click', () => {
-        settingsPanel.hidden = false
-        refreshQR()
+    sizeBtn.addEventListener('click', (e) => {
+        e.stopPropagation()
+        sizePillOpen = !sizePillOpen
+        sizePill.hidden = !sizePillOpen
+        sizeBtn.classList.toggle('active', sizePillOpen)
     })
 
-    closeBtn.addEventListener('click', closePanel)
-    backdrop.addEventListener('click', closePanel)
+    // Close pill when tapping outside (bubble phase is fine — we don't block propagation)
+    document.addEventListener('pointerdown', (e) => {
+        if (!sizePillOpen) return
+        if (!sizePill.contains(e.target as Node) && e.target !== sizeBtn) {
+            sizePillOpen = false
+            sizePill.hidden = true
+            sizeBtn.classList.remove('active')
+        }
+    })
 
     nutSizeInput.addEventListener('input', () => {
         const radius = parseInt(nutSizeInput.value, 10)
@@ -51,19 +71,47 @@ export function initUI(callbacks: UICallbacks): void {
         callbacks.onSizeChange(radius)
     })
 
-    lockBtn.addEventListener('click', () => {
+    // ── Kind toggle ──────────────────────────────────────────────────────────
+    let kindIndex = 0  // starts at 'random'
+    kindBtn.textContent = KIND_ICON[KIND_SEQUENCE[kindIndex]]
+    kindBtn.className = `toolbar-btn kind-${KIND_SEQUENCE[kindIndex]}`
+
+    kindBtn.addEventListener('click', () => {
+        kindIndex = (kindIndex + 1) % KIND_SEQUENCE.length
+        const kind = KIND_SEQUENCE[kindIndex]
+        kindBtn.textContent = KIND_ICON[kind]
+        kindBtn.className = `toolbar-btn kind-${kind}`
+        callbacks.onKindChange(kind)
+    })
+
+    // ── Freeze ───────────────────────────────────────────────────────────────
+    let locked = callbacks.initialLocked
+    freezeBtn.classList.toggle('active', locked)
+    freezeBtn.textContent = locked ? '▶' : '⏸'
+
+    freezeBtn.addEventListener('click', () => {
         locked = !locked
-        lockBtn.textContent = locked ? '▶ Resume' : '⏸ Pause'
-        lockBtn.classList.toggle('active', locked)
+        freezeBtn.classList.toggle('active', locked)
+        freezeBtn.textContent = locked ? '▶' : '⏸'
         callbacks.onLockChange(locked)
-        closePanel()
     })
 
-    clearBtn.addEventListener('click', () => {
+    // ── Clear ────────────────────────────────────────────────────────────────
+    clearTopBtn.addEventListener('click', () => {
         callbacks.onClear()
-        closePanel()
     })
 
+    // ── Settings panel ───────────────────────────────────────────────────────
+    toolbar.hidden = false
+
+    settingsBtn.addEventListener('click', () => {
+        settingsPanel.hidden = false
+        refreshQR()
+    })
+
+    closeBtn.addEventListener('click', closePanel)
+    backdrop.addEventListener('click', closePanel)
+
     testVibrateBtn.addEventListener('click', () => {
         const ok = navigator.vibrate(300)
         dbg('test vibrate 300ms ->', ok)
@@ -108,6 +156,7 @@ export function initUI(callbacks: UICallbacks): void {
 export function removeStartOverlay(): void {
     const overlay = document.getElementById('start-overlay')
     if (!overlay) return
+    overlay.style.pointerEvents = 'none'
     overlay.style.transition = 'opacity 0.4s'
     overlay.style.opacity = '0'
     setTimeout(() => overlay.remove(), 420)