fuzzyworld

nuts / shampoo / files / src / ui.ts

Branch shampoo · default branch · all branches

ui.ts

Blame: the commit that last changed each line · file · history

44eb5832 2026-04-12 Pavel Safronov1import { CONFIG } from './config'
f1262fd9 2026-04-12 Pavel Safronov2import { dbg } from './debug'
3import QRCode from 'qrcode'
4ea6baf7 2026-04-12 Pavel Safronov4import { hasVibration } from './compat'
98ba5504 2026-04-13 Pavel Safronov5import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration'
44eb5832 2026-04-12 Pavel Safronov6
a2e3ea1a 2026-04-13 Pavel Safronov7export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin'
937f9abc 2026-04-12 Pavel Safronov8export type GravityMode = 'device' | 'tumbler' | 'rock'
48f320c1 2026-04-12 Pavel Safronov9
a2e3ea1a 2026-04-13 Pavel Safronov10const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
48f320c1 2026-04-12 Pavel Safronov11const KIND_ICON: Record<ItemKind, string> = {
12 random: '?',
13 nut: '🌰',
14 gem: '💎',
15 balloon: '🎈',
a2b3b1f4 2026-04-12 Pavel Safronov16 metalnut: '🔩',
74d506c6 2026-04-12 Pavel Safronov17 rock: '🪨',
cfe80f49 2026-04-13 Pavel Safronov18 stressball: '🟠',
a2e3ea1a 2026-04-13 Pavel Safronov19 coin: '🪙',
48f320c1 2026-04-12 Pavel Safronov20}
21
44eb5832 2026-04-12 Pavel Safronov22export interface UICallbacks {
f1262fd9 2026-04-12 Pavel Safronov23 initialRadius: number
24 initialLocked: boolean
937f9abc 2026-04-12 Pavel Safronov25 initialGravityMode: GravityMode
26 initialVibrateEnabled: boolean
98ba5504 2026-04-13 Pavel Safronov27 initialVibrateItems: boolean
28 initialVibrateEdges: boolean
74d506c6 2026-04-12 Pavel Safronov29 initialKind: ItemKind
98ba5504 2026-04-13 Pavel Safronov30 initialRandomPool: ItemKind[]
aa6e5a99 2026-04-13 Pavel Safronov31 initialRefill: boolean
f1262fd9 2026-04-12 Pavel Safronov32 onSizeChange: (radius: number) => void
33 onClear: () => void
34 onLockChange: (locked: boolean) => void
48f320c1 2026-04-12 Pavel Safronov35 onKindChange: (kind: ItemKind) => void
937f9abc 2026-04-12 Pavel Safronov36 onGravityModeChange: (mode: GravityMode) => void
37 onVibrateChange: (enabled: boolean) => void
98ba5504 2026-04-13 Pavel Safronov38 onVibrateItemsChange: (enabled: boolean) => void
39 onVibrateEdgesChange: (enabled: boolean) => void
40 onRandomPoolChange: (pool: ItemKind[]) => void
aa6e5a99 2026-04-13 Pavel Safronov41 onRefillChange: (enabled: boolean) => void
98ba5504 2026-04-13 Pavel Safronov42 onSettingsOpen: () => void
43 onSettingsClose: () => void
f1262fd9 2026-04-12 Pavel Safronov44 getShareURL: () => string
44eb5832 2026-04-12 Pavel Safronov45}
46
47export function initUI(callbacks: UICallbacks): void {
48f320c1 2026-04-12 Pavel Safronov48 const toolbar = document.getElementById('toolbar') as HTMLDivElement
49 const kindBtn = document.getElementById('kind-btn') as HTMLButtonElement
50 const sizeBtn = document.getElementById('size-btn') as HTMLButtonElement
51 const freezeBtn = document.getElementById('freeze-btn') as HTMLButtonElement
937f9abc 2026-04-12 Pavel Safronov52 const gravityBtn = document.getElementById('gravity-btn') as HTMLButtonElement
48f320c1 2026-04-12 Pavel Safronov53 const clearTopBtn = document.getElementById('clear-top-btn') as HTMLButtonElement
44eb5832 2026-04-12 Pavel Safronov54 const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement
48f320c1 2026-04-12 Pavel Safronov55 const sizePill = document.getElementById('size-pill') as HTMLDivElement
f1262fd9 2026-04-12 Pavel Safronov56 const nutSizeInput = document.getElementById('nut-size') as HTMLInputElement
57 const nutSizeDisplay = document.getElementById('nut-size-display') as HTMLSpanElement
48f320c1 2026-04-12 Pavel Safronov58
59 const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
f1262fd9 2026-04-12 Pavel Safronov60 const testVibrateBtn = document.getElementById('test-vibrate-btn') as HTMLButtonElement
4ea6baf7 2026-04-12 Pavel Safronov61 const vibrateToggleBtn = document.getElementById('vibrate-toggle-btn') as HTMLButtonElement
44eb5832 2026-04-12 Pavel Safronov62 const closeBtn = document.getElementById('close-settings') as HTMLButtonElement
63 const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement
f1262fd9 2026-04-12 Pavel Safronov64 const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement
65 const copyLinkBtn = document.getElementById('copy-link-btn') as HTMLButtonElement
44eb5832 2026-04-12 Pavel Safronov66
48f320c1 2026-04-12 Pavel Safronov67 // ── Size slider ──────────────────────────────────────────────────────────
f1262fd9 2026-04-12 Pavel Safronov68 nutSizeInput.min = String(CONFIG.nutMinRadius)
69 nutSizeInput.max = String(CONFIG.nutMaxRadius)
70 nutSizeInput.value = String(callbacks.initialRadius)
71 nutSizeDisplay.textContent = String(callbacks.initialRadius)
72
48f320c1 2026-04-12 Pavel Safronov73 let sizePillOpen = false
44eb5832 2026-04-12 Pavel Safronov74
48f320c1 2026-04-12 Pavel Safronov75 sizeBtn.addEventListener('click', (e) => {
76 e.stopPropagation()
77 sizePillOpen = !sizePillOpen
78 sizePill.hidden = !sizePillOpen
79 sizeBtn.classList.toggle('active', sizePillOpen)
44eb5832 2026-04-12 Pavel Safronov80 })
81
48f320c1 2026-04-12 Pavel Safronov82 // Close pill when tapping outside (bubble phase is fine — we don't block propagation)
83 document.addEventListener('pointerdown', (e) => {
84 if (!sizePillOpen) return
85 if (!sizePill.contains(e.target as Node) && e.target !== sizeBtn) {
86 sizePillOpen = false
87 sizePill.hidden = true
88 sizeBtn.classList.remove('active')
89 }
90 })
44eb5832 2026-04-12 Pavel Safronov91
f1262fd9 2026-04-12 Pavel Safronov92 nutSizeInput.addEventListener('input', () => {
93 const radius = parseInt(nutSizeInput.value, 10)
94 nutSizeDisplay.textContent = String(radius)
95 callbacks.onSizeChange(radius)
44eb5832 2026-04-12 Pavel Safronov96 })
97
48f320c1 2026-04-12 Pavel Safronov98 // ── Kind toggle ──────────────────────────────────────────────────────────
74d506c6 2026-04-12 Pavel Safronov99 let kindIndex = KIND_SEQUENCE.indexOf(callbacks.initialKind)
100 if (kindIndex === -1) kindIndex = 0
48f320c1 2026-04-12 Pavel Safronov101 kindBtn.textContent = KIND_ICON[KIND_SEQUENCE[kindIndex]]
102 kindBtn.className = `toolbar-btn kind-${KIND_SEQUENCE[kindIndex]}`
103
104 kindBtn.addEventListener('click', () => {
105 kindIndex = (kindIndex + 1) % KIND_SEQUENCE.length
106 const kind = KIND_SEQUENCE[kindIndex]
107 kindBtn.textContent = KIND_ICON[kind]
108 kindBtn.className = `toolbar-btn kind-${kind}`
109 callbacks.onKindChange(kind)
110 })
111
112 // ── Freeze ───────────────────────────────────────────────────────────────
113 let locked = callbacks.initialLocked
114 freezeBtn.classList.toggle('active', locked)
115 freezeBtn.textContent = locked ? '▶' : '⏸'
116
117 freezeBtn.addEventListener('click', () => {
f1262fd9 2026-04-12 Pavel Safronov118 locked = !locked
48f320c1 2026-04-12 Pavel Safronov119 freezeBtn.classList.toggle('active', locked)
120 freezeBtn.textContent = locked ? '▶' : '⏸'
f1262fd9 2026-04-12 Pavel Safronov121 callbacks.onLockChange(locked)
44eb5832 2026-04-12 Pavel Safronov122 })
123
48f320c1 2026-04-12 Pavel Safronov124 // ── Clear ────────────────────────────────────────────────────────────────
125 clearTopBtn.addEventListener('click', () => {
f1262fd9 2026-04-12 Pavel Safronov126 callbacks.onClear()
127 })
128
937f9abc 2026-04-12 Pavel Safronov129 // ── Gravity mode ─────────────────────────────────────────────────────────
130 const GRAVITY_SEQUENCE: GravityMode[] = ['device', 'tumbler', 'rock']
131 const GRAVITY_ICON: Record<GravityMode, string> = { device: '📱', tumbler: '🪨', rock: '🌀' }
132 let gravityModeIndex = GRAVITY_SEQUENCE.indexOf(callbacks.initialGravityMode)
133 if (gravityModeIndex < 0) gravityModeIndex = 0
134 const initMode = GRAVITY_SEQUENCE[gravityModeIndex]
135 gravityBtn.textContent = GRAVITY_ICON[initMode]
136 gravityBtn.classList.toggle('active', initMode !== 'device')
137
138 gravityBtn.addEventListener('click', () => {
139 gravityModeIndex = (gravityModeIndex + 1) % GRAVITY_SEQUENCE.length
140 const mode = GRAVITY_SEQUENCE[gravityModeIndex]
141 gravityBtn.textContent = GRAVITY_ICON[mode]
142 gravityBtn.classList.toggle('active', mode !== 'device')
143 callbacks.onGravityModeChange(mode)
144 })
145
48f320c1 2026-04-12 Pavel Safronov146 // ── Settings panel ───────────────────────────────────────────────────────
147 toolbar.hidden = false
148
4ea6baf7 2026-04-12 Pavel Safronov149 // Hide vibration controls on unsupported platforms
150 if (!hasVibration()) {
151 testVibrateBtn.hidden = true
152 vibrateToggleBtn.hidden = true
98ba5504 2026-04-13 Pavel Safronov153 const noVibNote = document.createElement('p')
154 noVibNote.className = 'no-vibration-note'
155 noVibNote.textContent = '📳 Haptic feedback is available on Android and some other devices.'
156 vibrateToggleBtn.insertAdjacentElement('afterend', noVibNote)
937f9abc 2026-04-12 Pavel Safronov157 } else {
158 // Restore persisted vibration state
159 setVibrateEnabled(callbacks.initialVibrateEnabled)
98ba5504 2026-04-13 Pavel Safronov160 setVibrateItems(callbacks.initialVibrateItems)
161 setVibrateEdges(callbacks.initialVibrateEdges)
937f9abc 2026-04-12 Pavel Safronov162 vibrateToggleBtn.textContent = callbacks.initialVibrateEnabled ? 'Vibration: On' : 'Vibration: Off'
163 vibrateToggleBtn.classList.toggle('active', callbacks.initialVibrateEnabled)
4ea6baf7 2026-04-12 Pavel Safronov164 }
165
48f320c1 2026-04-12 Pavel Safronov166 settingsBtn.addEventListener('click', () => {
167 settingsPanel.hidden = false
98ba5504 2026-04-13 Pavel Safronov168 callbacks.onSettingsOpen()
48f320c1 2026-04-12 Pavel Safronov169 refreshQR()
170 })
171
172 closeBtn.addEventListener('click', closePanel)
173 backdrop.addEventListener('click', closePanel)
174
f1262fd9 2026-04-12 Pavel Safronov175 testVibrateBtn.addEventListener('click', () => {
176 const ok = navigator.vibrate(300)
177 dbg('test vibrate 300ms ->', ok)
178 const orig = testVibrateBtn.textContent
179 testVibrateBtn.textContent = ok ? '✓ Sent' : '✗ Failed'
180 setTimeout(() => { testVibrateBtn.textContent = orig }, 1500)
181 })
182
4ea6baf7 2026-04-12 Pavel Safronov183 vibrateToggleBtn.addEventListener('click', () => {
184 const on = !isVibrateEnabled()
185 setVibrateEnabled(on)
186 vibrateToggleBtn.textContent = on ? 'Vibration: On' : 'Vibration: Off'
187 vibrateToggleBtn.classList.toggle('active', on)
937f9abc 2026-04-12 Pavel Safronov188 callbacks.onVibrateChange(on)
4ea6baf7 2026-04-12 Pavel Safronov189 })
190
98ba5504 2026-04-13 Pavel Safronov191 // ── Vibration sub-options (only shown when vibration available) ──────────
192 if (hasVibration()) {
193 const vibrateItemsBtn = document.createElement('button')
194 vibrateItemsBtn.className = 'action-btn' + (callbacks.initialVibrateItems ? ' active' : '')
195 vibrateItemsBtn.textContent = callbacks.initialVibrateItems ? 'Item hits: On' : 'Item hits: Off'
196 vibrateItemsBtn.addEventListener('click', () => {
197 const on = !isVibrateItems()
198 setVibrateItems(on)
199 vibrateItemsBtn.textContent = on ? 'Item hits: On' : 'Item hits: Off'
200 vibrateItemsBtn.classList.toggle('active', on)
201 callbacks.onVibrateItemsChange(on)
202 })
203
204 const vibrateEdgesBtn = document.createElement('button')
205 vibrateEdgesBtn.className = 'action-btn' + (callbacks.initialVibrateEdges ? ' active' : '')
206 vibrateEdgesBtn.textContent = callbacks.initialVibrateEdges ? 'Edge hits: On' : 'Edge hits: Off'
207 vibrateEdgesBtn.addEventListener('click', () => {
208 const on = !isVibrateEdges()
209 setVibrateEdges(on)
210 vibrateEdgesBtn.textContent = on ? 'Edge hits: On' : 'Edge hits: Off'
211 vibrateEdgesBtn.classList.toggle('active', on)
212 callbacks.onVibrateEdgesChange(on)
213 })
214
215 // Insert after vibrateToggleBtn
216 vibrateToggleBtn.insertAdjacentElement('afterend', vibrateEdgesBtn)
217 vibrateToggleBtn.insertAdjacentElement('afterend', vibrateItemsBtn)
218 }
219
220 // ── Random pool toggles ──────────────────────────────────────────────────
221 const RANDOM_KINDS: Array<{ kind: Exclude<ItemKind, 'random'>; label: string }> = [
222 { kind: 'nut', label: '🌰 Nuts' },
223 { kind: 'gem', label: '💎 Gems' },
224 { kind: 'balloon', label: '🎈 Balloons' },
225 { kind: 'metalnut', label: '🔩 Metal nuts' },
226 { kind: 'rock', label: '🪨 Rocks' },
cfe80f49 2026-04-13 Pavel Safronov227 { kind: 'stressball', label: '🟠 Stress balls' },
a2e3ea1a 2026-04-13 Pavel Safronov228 { kind: 'coin', label: '🪙 Coins' },
98ba5504 2026-04-13 Pavel Safronov229 ]
230 let randomPool = new Set<ItemKind>(callbacks.initialRandomPool)
231
232 const poolSection = document.createElement('div')
233 poolSection.className = 'pool-section'
234 const poolLabel = document.createElement('p')
235 poolLabel.className = 'pool-label'
236 poolLabel.textContent = '? random includes'
237 poolSection.appendChild(poolLabel)
238 const poolGrid = document.createElement('div')
239 poolGrid.className = 'pool-grid'
240
241 for (const { kind, label } of RANDOM_KINDS) {
242 const btn = document.createElement('button')
243 btn.className = 'pool-btn' + (randomPool.has(kind) ? ' active' : '')
244 btn.textContent = label
245 btn.addEventListener('click', () => {
246 if (randomPool.has(kind)) {
247 if (randomPool.size <= 1) return // keep at least one
248 randomPool.delete(kind)
249 btn.classList.remove('active')
250 } else {
251 randomPool.add(kind)
252 btn.classList.add('active')
253 }
254 callbacks.onRandomPoolChange([...randomPool])
255 })
256 poolGrid.appendChild(btn)
257 }
258 poolSection.appendChild(poolGrid)
259
aa6e5a99 2026-04-13 Pavel Safronov260 // ── Refill toggle ────────────────────────────────────────────────────────
261 const refillBtn = document.createElement('button')
262 refillBtn.className = 'action-btn' + (callbacks.initialRefill ? ' active' : '')
263 refillBtn.textContent = callbacks.initialRefill ? 'Refill: On' : 'Refill: Off'
264 refillBtn.addEventListener('click', () => {
265 const on = refillBtn.classList.toggle('active')
266 refillBtn.textContent = on ? 'Refill: On' : 'Refill: Off'
267 callbacks.onRefillChange(on)
268 })
269
98ba5504 2026-04-13 Pavel Safronov270 // Insert before the close button
271 const closeBtn2 = settingsPanel.querySelector('#close-settings') as HTMLButtonElement
272 closeBtn2.parentElement!.insertBefore(poolSection, closeBtn2)
aa6e5a99 2026-04-13 Pavel Safronov273 closeBtn2.parentElement!.insertBefore(refillBtn, closeBtn2)
98ba5504 2026-04-13 Pavel Safronov274
f1262fd9 2026-04-12 Pavel Safronov275 copyLinkBtn.addEventListener('click', () => {
276 const url = callbacks.getShareURL()
277 const orig = copyLinkBtn.textContent
278 if (navigator.share) {
279 navigator.share({ title: 'My collection', url }).catch(() => { /* cancelled */ })
280 } else {
281 navigator.clipboard.writeText(url).then(() => {
282 copyLinkBtn.textContent = '✓ Copied!'
283 setTimeout(() => { copyLinkBtn.textContent = orig }, 1800)
284 }).catch(() => {
285 copyLinkBtn.textContent = '✗ Failed'
286 setTimeout(() => { copyLinkBtn.textContent = orig }, 1800)
287 })
288 }
289 })
290
291 async function refreshQR(): Promise<void> {
292 const url = callbacks.getShareURL()
293 try {
294 await QRCode.toCanvas(qrCanvas, url, {
295 width: 160,
296 margin: 1,
297 color: { dark: '#111111', light: '#ffffff' },
298 })
299 } catch { /* ignore */ }
300 }
301
44eb5832 2026-04-12 Pavel Safronov302 function closePanel(): void {
303 settingsPanel.hidden = true
98ba5504 2026-04-13 Pavel Safronov304 callbacks.onSettingsClose()
44eb5832 2026-04-12 Pavel Safronov305 }
306}
307
308/** Remove the start overlay (call after user interaction / permission grant). */
309export function removeStartOverlay(): void {
310 const overlay = document.getElementById('start-overlay')
311 if (!overlay) return
48f320c1 2026-04-12 Pavel Safronov312 overlay.style.pointerEvents = 'none'
44eb5832 2026-04-12 Pavel Safronov313 overlay.style.transition = 'opacity 0.4s'
314 overlay.style.opacity = '0'
315 setTimeout(() => overlay.remove(), 420)
316}