@zakkster/lite-ui-fx 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/UIFXRecipes.js ADDED
@@ -0,0 +1,2797 @@
1
+ /**
2
+ * @zakkster/lite-ui-fx -- Recipe Collection (all 50)
3
+ *
4
+ * The three recipe volumes consolidated into one shipped, typed, versioned
5
+ * module, exposed as the ./recipes subpath export. See
6
+ * decisions/0001-recipes-position.md.
7
+ *
8
+ * Vol.1 (10): SwarmToggle, LiquidToggle, NeonPulseToggle, MagneticButton,
9
+ * ShatterButton, ConfettiButton, GlitchButton, SparkSlider,
10
+ * CosmicSlider, LaserSlider
11
+ * Vol.2 (20): PendulumToggle, CircuitToggle, LightningToggle, DNAToggle,
12
+ * HeartbeatButton, BreathingButton, InkSplashButton,
13
+ * PixelDissolveButton, FireworkButton, AuroraSlider, WaveSlider,
14
+ * ElasticBandSlider, GravitySlider, OrbitLoader, HelixLoader,
15
+ * RippleCheck, MorphCheck, FlameCounter, GlitchCounter, BubbleRating
16
+ * Vol.3 (20): VolumeKnob, CompassKnob, RingProgress, BatteryGauge, SignalMeter,
17
+ * PillTabs, Stepper, RadioOrbit, PasswordStrength, WaterLevel,
18
+ * HeatMap, DayNightToggle, ReactionPicker, NotificationBell,
19
+ * TypewriterField, SoundWaveBtn, UploadProgress, ScratchReveal,
20
+ * TimerCountdown, PullRefresh
21
+ *
22
+ * Registry: RECIPES (null-prototype), RECIPE_META (live), RECIPE_NAMES,
23
+ * registerRecipe(id, factory, meta?), mountRecipe(container, id, options?).
24
+ *
25
+ * Recipe bodies are VERBATIM from the three volumes -- no behaviour change.
26
+ *
27
+ * Uses:
28
+ * @zakkster/lite-lerp -- lerp, clamp, easeOut, easeIn, easeInOut
29
+ * @zakkster/lite-random -- deterministic particle effects
30
+ */
31
+
32
+ import { lerp, clamp, easeOut, easeIn, easeInOut } from '@zakkster/lite-lerp';
33
+ import { Random } from '@zakkster/lite-random';
34
+ import { mountUIFX, UIType } from './UIFXController.js';
35
+
36
+
37
+ // ---------------------------------------------------------
38
+ // SHARED COLD CONSTANTS (U3) -- built once at module load, never per frame
39
+ // ---------------------------------------------------------
40
+
41
+ // Focus-ring dash patterns: one shared array each, so a focused frame's
42
+ // setLineDash() reuses them instead of allocating a fresh array (U-03).
43
+ const DASH_FOCUS = [4, 3];
44
+ const DASH_NONE = [];
45
+
46
+ // Percentage readouts '0%'..'100%', precomputed so a value label is a const
47
+ // lookup -- PCT[Math.round(st.val * 100)] -- not a per-frame template build.
48
+ const PCT = Array.from({ length: 101 }, (_, i) => i + '%');
49
+
50
+
51
+ // ---------------------------------------------------------
52
+ // SHARED HELPERS
53
+ // ---------------------------------------------------------
54
+
55
+ /** Draw a rounded rect (safe for Safari < 17.4). */
56
+ function roundRect(ctx, x, y, w, h, r) {
57
+ ctx.beginPath();
58
+ ctx.moveTo(x + r, y);
59
+ ctx.lineTo(x + w - r, y);
60
+ ctx.arcTo(x + w, y, x + w, y + r, r);
61
+ ctx.lineTo(x + w, y + h - r);
62
+ ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
63
+ ctx.lineTo(x + r, y + h);
64
+ ctx.arcTo(x, y + h, x, y + h - r, r);
65
+ ctx.lineTo(x, y + r);
66
+ ctx.arcTo(x, y, x + r, y, r);
67
+ ctx.closePath();
68
+ }
69
+
70
+ /** Draw a keyboard focus ring (accessibility indicator). */
71
+ function drawFocusRing(ctx, w, h, r) {
72
+ ctx.strokeStyle = 'rgba(110,231,182,.6)';
73
+ ctx.lineWidth = 2;
74
+ ctx.setLineDash(DASH_FOCUS);
75
+ roundRect(ctx, -2, -2, w + 4, h + 4, r + 2);
76
+ ctx.stroke();
77
+ ctx.setLineDash(DASH_NONE);
78
+ }
79
+
80
+ /** Draw a state label so the user knows the current state. font defaults to the
81
+ * historical literal, so an omitted font renders exactly as before. */
82
+ function drawStateLabel(ctx, text, x, y, color, font) {
83
+ ctx.fillStyle = color;
84
+ ctx.font = font || "500 9px 'JetBrains Mono',monospace";
85
+ ctx.textAlign = 'center';
86
+ ctx.fillText(text, x, y);
87
+ }
88
+
89
+ function label(ctx, text, x, y, color = '#9999b8', font) {
90
+ ctx.fillStyle = color; ctx.font = font || "500 9px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.fillText(text, x, y);
91
+ }
92
+ function focusRing(ctx, w, h, r) {
93
+ ctx.strokeStyle = 'rgba(110,231,182,.5)'; ctx.lineWidth = 2; ctx.setLineDash(DASH_FOCUS);
94
+ roundRect(ctx, -2, -2, w + 4, h + 4, r + 2); ctx.stroke(); ctx.setLineDash(DASH_NONE);
95
+ }
96
+
97
+ function rr(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.lineTo(x+w-r,y);c.arcTo(x+w,y,x+w,y+r,r);c.lineTo(x+w,y+h-r);c.arcTo(x+w,y+h,x+w-r,y+h,r);c.lineTo(x+r,y+h);c.arcTo(x,y+h,x,y+h-r,r);c.lineTo(x,y+r);c.arcTo(x,y,x+r,y,r);c.closePath()}
98
+ function lbl(c,t,x,y,col='#9999b8',font){c.fillStyle=col;c.font=font||"500 9px 'JetBrains Mono',monospace";c.textAlign='center';c.fillText(t,x,y)}
99
+ function fr(c,w,h,r){c.strokeStyle='rgba(110,231,182,.5)';c.lineWidth=2;c.setLineDash(DASH_FOCUS);rr(c,-2,-2,w+4,h+4,r+2);c.stroke();c.setLineDash(DASH_NONE)}
100
+ const PI2=Math.PI*2;
101
+
102
+
103
+
104
+ // ===========================================================
105
+ // TOGGLE RECIPES
106
+ // ===========================================================
107
+
108
+ /**
109
+ * Swarm Toggle -- 150 particles form the knob shape.
110
+ * On toggle, they explode outward then regroup at the new position.
111
+ * Uses sunflower phyllotaxis for the packed formation.
112
+ */
113
+ export function SwarmToggle({ seed = 42, count = 150 } = {}) {
114
+ const rng = new Random(seed);
115
+ const N = count;
116
+ const px = new Float32Array(N), py = new Float32Array(N);
117
+ const vx = new Float32Array(N), vy = new Float32Array(N);
118
+ const ox = new Float32Array(N), oy = new Float32Array(N);
119
+
120
+ return {
121
+ init(ctx, w, h) {
122
+ // Sunflower phyllotaxis distribution for the knob shape
123
+ for (let i = 0; i < N; i++) {
124
+ const r = 12 * Math.sqrt(i / N);
125
+ const th = i * Math.PI * (3 - Math.sqrt(5)); // Golden angle
126
+ ox[i] = Math.cos(th) * r;
127
+ oy[i] = Math.sin(th) * r;
128
+ px[i] = 18 + ox[i];
129
+ py[i] = h / 2 + oy[i];
130
+ }
131
+ },
132
+
133
+ onToggle(checked) {
134
+ // Explode particles outward
135
+ for (let i = 0; i < N; i++) {
136
+ const a = rng.range(0, Math.PI * 2);
137
+ const f = rng.range(100, 300);
138
+ vx[i] += Math.cos(a) * f;
139
+ vy[i] += Math.sin(a) * f;
140
+ }
141
+ },
142
+
143
+ tick(ctx, dt, now, st) {
144
+ // Track
145
+ ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
146
+ roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
147
+ ctx.fill();
148
+
149
+ // State label
150
+ drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
151
+ st.toggled ? '#6ee7b6' : '#8888aa');
152
+
153
+ // Knob target position
154
+ const tx = st.toggled ? st.w - 18 : 18;
155
+
156
+ // Render particles (spring toward formation)
157
+ ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
158
+ for (let i = 0; i < N; i++) {
159
+ vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
160
+ vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
161
+ vx[i] *= 0.85;
162
+ vy[i] *= 0.85;
163
+ px[i] += vx[i] * dt;
164
+ py[i] += vy[i] * dt;
165
+ ctx.fillRect(px[i], py[i], 1.5, 1.5);
166
+ }
167
+
168
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
169
+ },
170
+
171
+ destroy() {
172
+ // TypedArrays are GC'd automatically -- no manual cleanup needed
173
+ },
174
+ };
175
+ }
176
+
177
+
178
+ /**
179
+ * Liquid Toggle -- Metaball-style stretching knob.
180
+ * The knob elongates in the direction of motion, squashes perpendicular.
181
+ */
182
+ export function LiquidToggle() {
183
+ let knobX = 18;
184
+
185
+ return {
186
+ tick(ctx, dt, now, st) {
187
+ const tx = st.toggled ? st.w - 18 : 18;
188
+ knobX = lerp(knobX, tx, dt * 12);
189
+ const stretch = Math.abs(knobX - tx) * 0.5;
190
+
191
+ // Track
192
+ ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
193
+ roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
194
+ ctx.fill();
195
+
196
+ // Knob (stretched ellipse)
197
+ ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
198
+ ctx.beginPath();
199
+ ctx.ellipse(knobX, st.h / 2, 14 + stretch, 14 - stretch * 0.2, 0, 0, Math.PI * 2);
200
+ ctx.fill();
201
+
202
+ // State label
203
+ drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
204
+ st.toggled ? '#6ee7b6' : '#8888aa');
205
+
206
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
207
+ },
208
+ };
209
+ }
210
+
211
+
212
+ /**
213
+ * Neon Pulse Toggle -- Expanding shockwave rings on toggle.
214
+ */
215
+ export function NeonPulseToggle() {
216
+ // Fixed ring pool (dead when life <= 0) -- no push/splice on the hot path.
217
+ const RINGS = 8;
218
+ const ringR = new Float64Array(RINGS);
219
+ const ringLife = new Float64Array(RINGS);
220
+ const ringX = new Float64Array(RINGS);
221
+ let knobX = 18;
222
+ let lastW = 64; // last track width, so onToggle (which gets no state) can
223
+ // place a ring at the knob position derived from st.w (U-05).
224
+
225
+ function spawnRing(x) {
226
+ for (let i = 0; i < RINGS; i++) {
227
+ if (ringLife[i] <= 0) { ringR[i] = 14; ringLife[i] = 1; ringX[i] = x; return; }
228
+ }
229
+ }
230
+
231
+ return {
232
+ onToggle(checked) {
233
+ spawnRing(checked ? lastW - 18 : 18);
234
+ },
235
+
236
+ tick(ctx, dt, now, st) {
237
+ lastW = st.w;
238
+ knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 15);
239
+
240
+ // Track
241
+ ctx.fillStyle = st.toggled ? 'rgba(56,189,248,.2)' : 'rgba(255,255,255,.06)';
242
+ roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
243
+ ctx.fill();
244
+
245
+ // Shockwave rings -- const stroke color, per-ring alpha via globalAlpha
246
+ // (was `rgba(56,189,248,${life})` built per ring per frame).
247
+ ctx.strokeStyle = '#38bdf8';
248
+ ctx.lineWidth = 2;
249
+ for (let i = 0; i < RINGS; i++) {
250
+ if (ringLife[i] <= 0) continue;
251
+ ringR[i] += 100 * dt;
252
+ ringLife[i] -= 2 * dt;
253
+ if (ringLife[i] <= 0) continue;
254
+ ctx.globalAlpha = ringLife[i];
255
+ ctx.beginPath();
256
+ ctx.arc(ringX[i], st.h / 2, ringR[i], 0, Math.PI * 2);
257
+ ctx.stroke();
258
+ }
259
+ ctx.globalAlpha = 1;
260
+
261
+ // Knob
262
+ ctx.fillStyle = st.toggled ? '#38bdf8' : '#9999b8';
263
+ ctx.beginPath();
264
+ ctx.arc(knobX, st.h / 2, 14, 0, Math.PI * 2);
265
+ ctx.fill();
266
+
267
+ drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
268
+ st.toggled ? '#38bdf8' : '#8888aa');
269
+
270
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
271
+ },
272
+ };
273
+ }
274
+
275
+
276
+ // ===========================================================
277
+ // BUTTON RECIPES
278
+ // ===========================================================
279
+
280
+ /**
281
+ * Magnetic Button -- The entire button follows the cursor with spring physics.
282
+ * Squashes on click, springs back.
283
+ */
284
+ export function MagneticButton({ maxPull = 15 } = {}) {
285
+ let bx = 0, by = 0, textScale = 1;
286
+
287
+ return {
288
+ onClick() { textScale = 0.85; },
289
+
290
+ tick(ctx, dt, now, st, ptr) {
291
+ const tx = st.hover ? clamp(ptr.x - st.w / 2, -maxPull, maxPull) : 0;
292
+ const ty = st.hover ? clamp(ptr.y - st.h / 2, -maxPull, maxPull) : 0;
293
+ bx = lerp(bx, tx, dt * 10);
294
+ by = lerp(by, ty, dt * 10);
295
+ textScale = lerp(textScale, 1, dt * 10);
296
+
297
+ ctx.translate(bx, by);
298
+
299
+ // Button body
300
+ ctx.fillStyle = st.hover ? '#1e1e2f' : 'rgba(255,255,255,.05)';
301
+ ctx.strokeStyle = st.hover ? '#a78bfa' : 'rgba(255,255,255,.1)';
302
+ ctx.lineWidth = 1;
303
+ roundRect(ctx, 0, 0, st.w, st.h, 10);
304
+ ctx.fill(); ctx.stroke();
305
+
306
+ // Label
307
+ ctx.fillStyle = st.hover ? '#a78bfa' : '#e2e2f0';
308
+ ctx.font = "600 13px 'Space Grotesk',sans-serif";
309
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
310
+ ctx.translate(st.w / 2, st.h / 2);
311
+ ctx.scale(textScale, textScale);
312
+ ctx.fillText('MAGNETIC', 0, 0);
313
+
314
+ if (st.focused) {
315
+ ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
316
+ ctx.translate(st.padding + bx, st.padding + by);
317
+ drawFocusRing(ctx, st.w, st.h, 10);
318
+ }
319
+ },
320
+ };
321
+ }
322
+
323
+
324
+ /**
325
+ * Shatter Button -- Click explodes into falling shards, reforms after 1.5s.
326
+ */
327
+ export function ShatterButton({ seed = 42 } = {}) {
328
+ const rng = new Random(seed);
329
+ // Fixed shard pool (live flag) -- no push/splice on the hot path.
330
+ const SHARDS = 32;
331
+ const shard = [];
332
+ for (let i = 0; i < SHARDS; i++) shard[i] = { x: 0, y: 0, vx: 0, vy: 0, r: 0, vr: 0, sz: 0, live: false };
333
+ let visible = true;
334
+ let respawnTimer = 0;
335
+
336
+ return {
337
+ onClick(x, y, st) {
338
+ if (!visible) return;
339
+ visible = false;
340
+ respawnTimer = 1.5;
341
+ let n = 0;
342
+ for (let i = 0; i < SHARDS && n < 30; i++) {
343
+ const s = shard[i];
344
+ if (s.live) continue;
345
+ s.x = rng.range(0, st.w); s.y = rng.range(0, st.h);
346
+ s.vx = rng.range(-150, 150); s.vy = rng.range(-150, 50);
347
+ s.r = rng.range(0, 6); s.vr = rng.range(-5, 5);
348
+ s.sz = rng.range(4, 12); s.live = true;
349
+ n++;
350
+ }
351
+ },
352
+
353
+ tick(ctx, dt, now, st) {
354
+ if (respawnTimer > 0) {
355
+ respawnTimer -= dt;
356
+ if (respawnTimer <= 0) visible = true;
357
+ }
358
+
359
+ if (visible) {
360
+ ctx.fillStyle = st.active ? 'rgba(255,255,255,.15)' : 'rgba(255,255,255,.08)';
361
+ roundRect(ctx, 0, 0, st.w, st.h, 10);
362
+ ctx.fill();
363
+ ctx.fillStyle = '#e2e2f0';
364
+ ctx.font = "600 13px 'Space Grotesk',sans-serif";
365
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
366
+ ctx.fillText('SHATTER', st.w / 2, st.h / 2);
367
+
368
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
369
+ }
370
+
371
+ // Falling shards -- fixed pool
372
+ ctx.fillStyle = 'rgba(255,255,255,.5)';
373
+ for (let i = 0; i < SHARDS; i++) {
374
+ const s = shard[i];
375
+ if (!s.live) continue;
376
+ s.vy += 400 * dt;
377
+ s.x += s.vx * dt; s.y += s.vy * dt;
378
+ s.r += s.vr * dt;
379
+ ctx.save();
380
+ ctx.translate(s.x, s.y); ctx.rotate(s.r);
381
+ ctx.fillRect(-s.sz / 2, -s.sz / 2, s.sz, s.sz);
382
+ ctx.restore();
383
+ if (s.y > st.h + 100) s.live = false;
384
+ }
385
+ },
386
+ };
387
+ }
388
+
389
+
390
+ /**
391
+ * Confetti Button -- 3D tumbling confetti burst from click point.
392
+ */
393
+ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#fbbf24', '#f43f5e'] } = {}) {
394
+ const rng = new Random(seed);
395
+ // Fixed confetti pool (live flag) -- no push/splice on the hot path.
396
+ const CONF = 80;
397
+ const conf = [];
398
+ for (let i = 0; i < CONF; i++) conf[i] = { x: 0, y: 0, vx: 0, vy: 0, c: '#fff', w: 0, h: 0, rx: 0, ry: 0, vrx: 0, vry: 0, live: false };
399
+ let pressScale = 1;
400
+
401
+ return {
402
+ onClick(x, y) {
403
+ pressScale = 0.85;
404
+ let n = 0;
405
+ for (let i = 0; i < CONF && n < 40; i++) {
406
+ const c = conf[i];
407
+ if (c.live) continue;
408
+ const a = rng.range(Math.PI, Math.PI * 2);
409
+ const v = rng.range(100, 300);
410
+ c.x = x; c.y = y; c.vx = Math.cos(a) * v; c.vy = Math.sin(a) * v;
411
+ c.c = rng.pick(colors);
412
+ c.w = rng.range(4, 8); c.h = rng.range(8, 14);
413
+ c.rx = rng.range(0, Math.PI); c.ry = rng.range(0, Math.PI);
414
+ c.vrx = rng.range(5, 15); c.vry = rng.range(5, 15);
415
+ c.live = true;
416
+ n++;
417
+ }
418
+ },
419
+
420
+ tick(ctx, dt, now, st) {
421
+ pressScale = lerp(pressScale, 1, dt * 10);
422
+
423
+ ctx.translate(st.w / 2, st.h / 2);
424
+ ctx.scale(pressScale, pressScale);
425
+ ctx.translate(-st.w / 2, -st.h / 2);
426
+
427
+ // Button body
428
+ ctx.fillStyle = '#DC143C';
429
+ roundRect(ctx, 0, 0, st.w, st.h, 10);
430
+ ctx.fill();
431
+ ctx.fillStyle = '#fff';
432
+ ctx.font = "600 13px 'Space Grotesk',sans-serif";
433
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
434
+ ctx.fillText('CONFETTI', st.w / 2, st.h / 2);
435
+
436
+ // Tumbling confetti -- fixed pool
437
+ for (let i = 0; i < CONF; i++) {
438
+ const c = conf[i];
439
+ if (!c.live) continue;
440
+ c.vy += 300 * dt;
441
+ c.x += c.vx * dt; c.y += c.vy * dt;
442
+ c.rx += c.vrx * dt; c.ry += c.vry * dt;
443
+ ctx.save();
444
+ ctx.translate(c.x, c.y); ctx.rotate(c.rx);
445
+ ctx.scale(1, Math.cos(c.ry)); // 3D tumble
446
+ ctx.fillStyle = c.c;
447
+ ctx.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
448
+ ctx.restore();
449
+ if (c.y > st.h + 100) c.live = false;
450
+ }
451
+
452
+ if (st.focused) {
453
+ ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
454
+ ctx.translate(st.padding, st.padding);
455
+ drawFocusRing(ctx, st.w, st.h, 10);
456
+ }
457
+ },
458
+ };
459
+ }
460
+
461
+
462
+ /**
463
+ * Glitch Button -- RGB channel split on hover. Random slice displacement.
464
+ */
465
+ export function GlitchButton({ seed = 42 } = {}) {
466
+ const rng = new Random(seed);
467
+ let glitchIntensity = 0;
468
+
469
+ // Hoisted out of tick (was a per-frame arrow closure). Body alpha via
470
+ // globalAlpha (was two `rgba(...,${intensity})` templates); label at full.
471
+ function drawBase(ctx, st, ox, oy, color, alpha) {
472
+ ctx.globalAlpha = alpha;
473
+ ctx.fillStyle = color;
474
+ roundRect(ctx, ox, oy, st.w, st.h, 10);
475
+ ctx.fill();
476
+ ctx.globalAlpha = 1;
477
+ ctx.fillStyle = '#fff';
478
+ ctx.font = "600 13px 'Space Grotesk',sans-serif";
479
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
480
+ ctx.fillText('GLITCH', st.w / 2 + ox, st.h / 2 + oy);
481
+ }
482
+
483
+ return {
484
+ tick(ctx, dt, now, st) {
485
+ glitchIntensity = lerp(glitchIntensity, st.hover ? 1 : 0, dt * 10);
486
+
487
+ if (glitchIntensity > 0.01) {
488
+ ctx.globalCompositeOperation = 'screen';
489
+ const off = rng.range(2, 6) * glitchIntensity;
490
+ // Alternate between split and normal for flicker
491
+ if (rng.next() > 0.4) {
492
+ drawBase(ctx, st, -off, 0, '#ff0055', 0.6 * glitchIntensity);
493
+ drawBase(ctx, st, off, 0, '#00ffcc', 0.6 * glitchIntensity);
494
+ } else {
495
+ drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.1)', 1);
496
+ }
497
+ ctx.globalCompositeOperation = 'source-over';
498
+ } else {
499
+ drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.06)', 1);
500
+ }
501
+
502
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
503
+ },
504
+ };
505
+ }
506
+
507
+
508
+ // ===========================================================
509
+ // SLIDER RECIPES
510
+ // ===========================================================
511
+
512
+ /**
513
+ * Spark Slider -- Emits directional sparks based on drag velocity.
514
+ * Sparks fly opposite to drag direction with motion-blur stretch.
515
+ */
516
+ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
517
+ const rng = new Random(seed);
518
+ // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
519
+ const SPARKS = 64;
520
+ const spark = [];
521
+ for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
522
+ let lastVal = 0.5;
523
+ let lastW = 200; // last track width, so onDrag (no state) spawns at the
524
+ // thumb position derived from st.w (was hardcoded val*200).
525
+ function spawn(val, dir) {
526
+ for (let i = 0; i < SPARKS; i++) {
527
+ const s = spark[i];
528
+ if (s.life <= 0) {
529
+ s.x = val * lastW; s.y = 14;
530
+ s.vx = -dir * rng.range(100, 300); s.vy = rng.range(-150, 50); s.life = 1;
531
+ return;
532
+ }
533
+ }
534
+ }
535
+
536
+ return {
537
+ onDrag(val) {
538
+ const delta = val - lastVal;
539
+ if (Math.abs(delta) > 0.001) {
540
+ const dir = Math.sign(delta);
541
+ for (let i = 0; i < 4; i++) spawn(val, dir);
542
+ }
543
+ lastVal = val;
544
+ },
545
+
546
+ tick(ctx, dt, now, st) {
547
+ lastW = st.w;
548
+ // Track background
549
+ ctx.fillStyle = 'rgba(255,255,255,.08)';
550
+ roundRect(ctx, 0, 12, st.w, 4, 2);
551
+ ctx.fill();
552
+
553
+ // Filled track
554
+ const tx = st.val * st.w;
555
+ ctx.fillStyle = color;
556
+ roundRect(ctx, 0, 12, tx, 4, 2);
557
+ ctx.fill();
558
+
559
+ // Thumb
560
+ ctx.beginPath();
561
+ ctx.arc(tx, 14, 10, 0, Math.PI * 2);
562
+ ctx.fill();
563
+
564
+ // Value label
565
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
566
+
567
+ // Sparks -- fixed pool, themed color, alpha via globalAlpha
568
+ // (was `rgba(251,191,36,${life})` built per spark per frame).
569
+ ctx.globalCompositeOperation = 'screen';
570
+ ctx.fillStyle = color;
571
+ for (let i = 0; i < SPARKS; i++) {
572
+ const s = spark[i];
573
+ if (s.life <= 0) continue;
574
+ s.vy += 400 * dt;
575
+ s.vx *= 0.95;
576
+ s.x += s.vx * dt; s.y += s.vy * dt;
577
+ s.life -= 2.0 * dt;
578
+ if (s.life <= 0) continue;
579
+ ctx.globalAlpha = s.life;
580
+ ctx.save();
581
+ ctx.translate(s.x, s.y);
582
+ ctx.rotate(Math.atan2(s.vy, s.vx));
583
+ ctx.fillRect(0, -1, Math.max(3, Math.abs(s.vx) * 0.05), 2);
584
+ ctx.restore();
585
+ }
586
+ ctx.globalAlpha = 1;
587
+ ctx.globalCompositeOperation = 'source-over';
588
+
589
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
590
+ },
591
+ };
592
+ }
593
+
594
+
595
+ /**
596
+ * Cosmic Slider -- Thumb is a black hole that sucks in background dust.
597
+ * Particles respawn when consumed.
598
+ */
599
+ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
600
+ const rng = new Random(seed);
601
+ const dust = [];
602
+
603
+ return {
604
+ init(ctx, w, h) {
605
+ for (let i = 0; i < dustCount; i++) {
606
+ dust.push({ x: rng.range(0, w), y: rng.range(-10, 34), vx: 0, vy: 0 });
607
+ }
608
+ },
609
+
610
+ tick(ctx, dt, now, st) {
611
+ // Track
612
+ ctx.fillStyle = 'rgba(255,255,255,.06)';
613
+ roundRect(ctx, 0, 12, st.w, 4, 2);
614
+ ctx.fill();
615
+
616
+ const tx = st.val * st.w;
617
+
618
+ // Dust particles attracted to thumb (indexed loop -- for..of over an
619
+ // array can allocate an iterator on the hot path)
620
+ ctx.fillStyle = '#fff';
621
+ for (let di = 0; di < dust.length; di++) {
622
+ const d = dust[di];
623
+ const dx = tx - d.x, dy = 14 - d.y;
624
+ const dist = Math.sqrt(dx * dx + dy * dy);
625
+ if (dist < 40) {
626
+ d.vx += (dx / dist) * 200 * dt;
627
+ d.vy += (dy / dist) * 200 * dt;
628
+ }
629
+ d.vx *= 0.9; d.vy *= 0.9;
630
+ d.x += d.vx * dt; d.y += d.vy * dt;
631
+ if (dist < 5) {
632
+ d.x = rng.range(0, st.w);
633
+ d.y = rng.range(-20, 44);
634
+ d.vx = 0; d.vy = 0;
635
+ }
636
+ ctx.fillRect(d.x, d.y, 1, 1);
637
+ }
638
+
639
+ // Black hole thumb with glow
640
+ ctx.shadowBlur = 10; ctx.shadowColor = '#a78bfa';
641
+ ctx.fillStyle = '#000'; ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 2;
642
+ ctx.beginPath(); ctx.arc(tx, 14, 12, 0, Math.PI * 2);
643
+ ctx.fill(); ctx.stroke();
644
+ ctx.shadowBlur = 0;
645
+
646
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
647
+
648
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
649
+ },
650
+ };
651
+ }
652
+
653
+
654
+ /**
655
+ * Laser Slider -- Energy beam traces the filled track. Pulsing plasma thumb.
656
+ */
657
+ export function LaserSlider() {
658
+ const REF = 100; // gradient reference width; scaled to tx at paint
659
+ let pulseTime = 0, beam = null;
660
+
661
+ return {
662
+ init(ctx) {
663
+ // Fixed-stop beam gradient built once (0..REF). Scaling the space to
664
+ // tx/REF at fill maps it to 0..tx with the white tip at the thumb --
665
+ // no per-frame createLinearGradient.
666
+ beam = ctx.createLinearGradient(0, 0, REF, 0);
667
+ beam.addColorStop(0, 'transparent');
668
+ beam.addColorStop(0.8, '#38bdf8');
669
+ beam.addColorStop(1, '#fff');
670
+ },
671
+ tick(ctx, dt, now, st) {
672
+ pulseTime += dt * 10;
673
+
674
+ // Track background
675
+ ctx.fillStyle = 'rgba(255,255,255,.06)';
676
+ roundRect(ctx, 0, 12, st.w, 4, 2);
677
+ ctx.fill();
678
+
679
+ const tx = st.val * st.w;
680
+
681
+ // Laser beam (gradient from init, scaled to 0..tx)
682
+ if (tx > 2) {
683
+ ctx.save();
684
+ ctx.scale(tx / REF, 1);
685
+ ctx.fillStyle = beam || '#38bdf8';
686
+ ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
687
+ roundRect(ctx, 0, 12, REF, 4, 2);
688
+ ctx.fill();
689
+ ctx.restore();
690
+ }
691
+
692
+ // Plasma thumb (pulsing) -- keep the glow the beam left on
693
+ ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
694
+ ctx.fillStyle = '#fff';
695
+ ctx.beginPath();
696
+ ctx.arc(tx, 14, 8 + Math.sin(pulseTime) * 2, 0, Math.PI * 2);
697
+ ctx.fill();
698
+ ctx.shadowBlur = 0;
699
+
700
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
701
+
702
+ if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
703
+ },
704
+ };
705
+ }
706
+
707
+
708
+ // ===========================================================
709
+ // TOGGLES
710
+ // ===========================================================
711
+
712
+ /** 1. Pendulum Toggle -- The knob swings like a pendulum with overshoot. */
713
+ export function PendulumToggle() {
714
+ let angle = -0.4, velocity = 0;
715
+ const STIFFNESS = 12, DAMPING = 3.5;
716
+ return {
717
+ onToggle() { velocity += 8; },
718
+ tick(ctx, dt, now, st) {
719
+ const target = st.toggled ? 0.4 : -0.4;
720
+ velocity += (target - angle) * STIFFNESS * dt;
721
+ velocity *= (1 - DAMPING * dt);
722
+ angle += velocity * dt;
723
+
724
+ ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
725
+ roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
726
+
727
+ const cx = st.w / 2, cy = -10, len = st.h / 2 + 14;
728
+ const bx = cx + Math.sin(angle) * len, by = cy + Math.cos(angle) * len;
729
+
730
+ ctx.strokeStyle = st.toggled ? 'rgba(110,231,182,.3)' : 'rgba(255,255,255,.08)';
731
+ ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(bx, by); ctx.stroke();
732
+ ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
733
+ ctx.beginPath(); ctx.arc(bx, by, 12, 0, Math.PI * 2); ctx.fill();
734
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#6ee7b6' : '#8888aa');
735
+ if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
736
+ },
737
+ };
738
+ }
739
+
740
+ /** 2. Circuit Toggle -- Electricity flows through a circuit path when ON. */
741
+ export function CircuitToggle({ seed = 42 } = {}) {
742
+ const rng = new Random(seed);
743
+ // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
744
+ const SPARKS = 16;
745
+ const spark = [];
746
+ for (let i = 0; i < SPARKS; i++) spark[i] = { t: 0, life: 0, speed: 0 };
747
+ const DASH_FLOW = [4, 4]; // one shared dash array, reused every frame
748
+ let knobX = 18, flowT = 0;
749
+
750
+ function spawn(t) {
751
+ for (let i = 0; i < SPARKS; i++) {
752
+ const s = spark[i];
753
+ if (s.life <= 0) { s.t = t; s.life = 1; s.speed = rng.range(0.3, 0.8); return; }
754
+ }
755
+ }
756
+
757
+ return {
758
+ onToggle(checked) {
759
+ if (checked) for (let i = 0; i < 8; i++) spawn(rng.range(0, 1));
760
+ },
761
+ tick(ctx, dt, now, st) {
762
+ knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 12);
763
+ flowT = (flowT + dt * 2) % 1;
764
+
765
+ ctx.fillStyle = 'rgba(255,255,255,.04)';
766
+ roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
767
+
768
+ // Circuit path
769
+ ctx.strokeStyle = st.toggled ? '#22d3ee' : 'rgba(255,255,255,.06)';
770
+ ctx.lineWidth = 2; ctx.setLineDash(DASH_FLOW); ctx.lineDashOffset = -flowT * 16;
771
+ ctx.beginPath(); ctx.moveTo(8, st.h / 2); ctx.lineTo(st.w - 8, st.h / 2); ctx.stroke();
772
+ ctx.setLineDash(DASH_NONE); ctx.lineDashOffset = 0;
773
+
774
+ // Flowing sparks -- fixed pool, per-spark alpha via globalAlpha
775
+ if (st.toggled) {
776
+ ctx.fillStyle = '#22d3ee';
777
+ let alive = 0;
778
+ for (let i = 0; i < SPARKS; i++) {
779
+ const s = spark[i];
780
+ if (s.life <= 0) continue;
781
+ s.t += s.speed * dt; s.life -= dt * 0.5;
782
+ if (s.life <= 0 || s.t > 1) { s.life = 0; continue; }
783
+ alive++;
784
+ const sx = 8 + s.t * (st.w - 16);
785
+ ctx.globalAlpha = s.life;
786
+ ctx.beginPath(); ctx.arc(sx, st.h / 2, 2, 0, Math.PI * 2); ctx.fill();
787
+ }
788
+ ctx.globalAlpha = 1;
789
+ // Continuous spawn
790
+ if (alive < 12) spawn(0);
791
+ }
792
+
793
+ ctx.fillStyle = st.toggled ? '#22d3ee' : '#9999b8';
794
+ ctx.beginPath(); ctx.arc(knobX, st.h / 2, 12, 0, Math.PI * 2); ctx.fill();
795
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#22d3ee' : '#8888aa');
796
+ if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
797
+ },
798
+ };
799
+ }
800
+
801
+ /** 3. Lightning Toggle -- Electric arc between endpoints. */
802
+ export function LightningToggle({ seed = 42 } = {}) {
803
+ const rng = new Random(seed);
804
+ let knobX = 18, arcTime = 0;
805
+ function bolt(ctx, x1, y1, x2, y2, depth) {
806
+ if (depth <= 0) { ctx.lineTo(x2, y2); return; }
807
+ const mx = (x1 + x2) / 2 + (rng.next() - 0.5) * 12 * depth;
808
+ const my = (y1 + y2) / 2 + (rng.next() - 0.5) * 8 * depth;
809
+ bolt(ctx, x1, y1, mx, my, depth - 1);
810
+ bolt(ctx, mx, my, x2, y2, depth - 1);
811
+ }
812
+ return {
813
+ tick(ctx, dt, now, st) {
814
+ knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 14);
815
+ arcTime += dt;
816
+
817
+ ctx.fillStyle = st.toggled ? 'rgba(251,191,36,.15)' : 'rgba(255,255,255,.04)';
818
+ roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
819
+
820
+ if (st.toggled && ((arcTime * 8) | 0) % 3 !== 0) {
821
+ rng.reset(((now / 80) | 0) * 7 + 1);
822
+ ctx.strokeStyle = 'rgba(251,191,36,.7)'; ctx.lineWidth = 1.5;
823
+ ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
824
+ ctx.strokeStyle = 'rgba(255,255,255,.3)'; ctx.lineWidth = 0.5;
825
+ ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
826
+ }
827
+
828
+ ctx.fillStyle = st.toggled ? '#fbbf24' : '#9999b8';
829
+ ctx.beginPath(); ctx.arc(knobX, st.h / 2, 12, 0, Math.PI * 2); ctx.fill();
830
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#fbbf24' : '#8888aa');
831
+ if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
832
+ },
833
+ };
834
+ }
835
+
836
+ /** 4. DNA Toggle -- Double helix wraps around the track. */
837
+ export function DNAToggle() {
838
+ let knobX = 18, phase = 0;
839
+ return {
840
+ tick(ctx, dt, now, st) {
841
+ knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 10);
842
+ phase += dt * (st.toggled ? 4 : 1.5);
843
+
844
+ ctx.fillStyle = 'rgba(255,255,255,.04)';
845
+ roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
846
+
847
+ const cy = st.h / 2;
848
+ for (let i = 0; i < 20; i++) {
849
+ const t = i / 20, x = 6 + t * (st.w - 12);
850
+ const y1 = cy + Math.sin(phase + t * Math.PI * 3) * 8;
851
+ const y2 = cy - Math.sin(phase + t * Math.PI * 3) * 8;
852
+ // Const strand colors, per-dot alpha via globalAlpha (was two
853
+ // `rgba(...,${...})` templates built per dot per frame).
854
+ if (st.toggled) {
855
+ ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.3 + Math.sin(phase + t * 6) * 0.2;
856
+ ctx.beginPath(); ctx.arc(x, y1, 2, 0, Math.PI * 2); ctx.fill();
857
+ ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.3 + Math.cos(phase + t * 6) * 0.2;
858
+ ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
859
+ ctx.globalAlpha = 1;
860
+ } else {
861
+ ctx.fillStyle = 'rgba(255,255,255,.08)';
862
+ ctx.beginPath(); ctx.arc(x, y1, 2, 0, Math.PI * 2); ctx.fill();
863
+ ctx.fillStyle = 'rgba(255,255,255,.06)';
864
+ ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
865
+ }
866
+ if (i % 3 === 0) {
867
+ ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
868
+ ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke();
869
+ }
870
+ }
871
+
872
+ ctx.fillStyle = st.toggled ? '#c084fc' : '#9999b8';
873
+ ctx.beginPath(); ctx.arc(knobX, cy, 12, 0, Math.PI * 2); ctx.fill();
874
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
875
+ if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
876
+ },
877
+ };
878
+ }
879
+
880
+
881
+ // ===========================================================
882
+ // BUTTONS
883
+ // ===========================================================
884
+
885
+ /** 5. Heartbeat Button -- Heart icon pumps with particle burst on click. */
886
+ export function HeartbeatButton({ seed = 42 } = {}) {
887
+ const rng = new Random(seed);
888
+ // Fixed particle pool (life <= 0 == dead) -- no push/splice on the hot path.
889
+ const PARTS = 32;
890
+ const part = [];
891
+ for (let i = 0; i < PARTS; i++) part[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
892
+ let scale = 1, beatPhase = 0;
893
+ function spawn(x, y) {
894
+ for (let i = 0; i < PARTS; i++) {
895
+ const p = part[i];
896
+ if (p.life <= 0) {
897
+ const a = rng.range(0, Math.PI * 2), v = rng.range(40, 120);
898
+ p.x = x; p.y = y; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
899
+ return;
900
+ }
901
+ }
902
+ }
903
+ return {
904
+ onClick(x, y) {
905
+ scale = 1.3;
906
+ for (let i = 0; i < 12; i++) spawn(x || 80, y || 24);
907
+ },
908
+ tick(ctx, dt, now, st) {
909
+ scale = lerp(scale, 1, dt * 8);
910
+ beatPhase += dt * 5;
911
+ const pulse = 1 + Math.sin(beatPhase) * 0.03;
912
+
913
+ ctx.fillStyle = st.active ? 'rgba(220,20,60,.2)' : 'rgba(220,20,60,.08)';
914
+ roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
915
+
916
+ // Heart shape
917
+ ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(scale * pulse, scale * pulse);
918
+ ctx.fillStyle = '#DC143C';
919
+ ctx.beginPath();
920
+ ctx.moveTo(0, 4); ctx.bezierCurveTo(-10, -6, -20, -2, -20, 4);
921
+ ctx.bezierCurveTo(-20, 14, 0, 20, 0, 20);
922
+ ctx.bezierCurveTo(0, 20, 20, 14, 20, 4);
923
+ ctx.bezierCurveTo(20, -2, 10, -6, 0, 4);
924
+ ctx.fill(); ctx.restore();
925
+
926
+ // Particles -- fixed pool, const color, alpha via globalAlpha
927
+ ctx.globalCompositeOperation = 'screen';
928
+ ctx.fillStyle = '#ff3c64';
929
+ for (let i = 0; i < PARTS; i++) {
930
+ const p = part[i];
931
+ if (p.life <= 0) continue;
932
+ p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 2 * dt;
933
+ if (p.life <= 0) continue;
934
+ ctx.globalAlpha = p.life;
935
+ ctx.beginPath(); ctx.arc(p.x, p.y, 2, 0, Math.PI * 2); ctx.fill();
936
+ }
937
+ ctx.globalAlpha = 1;
938
+ ctx.globalCompositeOperation = 'source-over';
939
+ if (st.focused) focusRing(ctx, st.w, st.h, 10);
940
+ },
941
+ };
942
+ }
943
+
944
+ /** 6. Breathing Button -- Inhale/exhale pulse with particle halo. */
945
+ export function BreathingButton() {
946
+ // Alpha-graded green LUT (same RGB #6ee7b6, quantized alpha) built once, so
947
+ // the pulsing glow is a const-string lookup instead of three
948
+ // `rgba(110,231,182,${...})` templates per frame. shadowColor's alpha is not
949
+ // globalAlpha, so a LUT (not globalAlpha) is the zero-alloc fit here.
950
+ const GREEN = [];
951
+ for (let i = 0; i <= 64; i++) GREEN[i] = 'rgba(110,231,182,' + (i / 64).toFixed(3) + ')';
952
+ function greenA(a) { const i = a <= 0 ? 0 : a >= 1 ? 64 : (a * 64) | 0; return GREEN[i]; }
953
+ let phase = 0;
954
+ return {
955
+ tick(ctx, dt, now, st) {
956
+ phase += dt * 1.5;
957
+ const breath = (Math.sin(phase) + 1) / 2; // 0--1
958
+ const radius = 4 + breath * 6;
959
+
960
+ // Outer glow
961
+ ctx.shadowBlur = 10 + breath * 15; ctx.shadowColor = greenA(0.2 + breath * 0.3);
962
+ ctx.fillStyle = greenA(0.05 + breath * 0.08);
963
+ roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
964
+ ctx.shadowBlur = 0;
965
+
966
+ // Halo ring
967
+ ctx.strokeStyle = greenA(0.1 + breath * 0.2);
968
+ ctx.lineWidth = 1;
969
+ ctx.beginPath(); ctx.arc(st.w / 2, st.h / 2, st.w / 2 + radius, 0, Math.PI * 2); ctx.stroke();
970
+
971
+ ctx.fillStyle = '#6ee7b6';
972
+ ctx.font = "600 12px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
973
+ ctx.fillText('BREATHE', st.w / 2, st.h / 2);
974
+ if (st.focused) focusRing(ctx, st.w, st.h, 10);
975
+ },
976
+ };
977
+ }
978
+
979
+ /** 7. Ink Splash Button -- Calligraphy ink splatter on click. */
980
+ export function InkSplashButton({ seed = 42 } = {}) {
981
+ const rng = new Random(seed);
982
+ // Fixed splat pool (life <= 0 == dead).
983
+ const SPLATS = 32;
984
+ const splat = [];
985
+ for (let i = 0; i < SPLATS; i++) splat[i] = { x: 0, y: 0, r: 0, life: 0 };
986
+ let pressScale = 1;
987
+ function spawn(cx, cy) {
988
+ for (let i = 0; i < SPLATS; i++) {
989
+ const s = splat[i];
990
+ if (s.life <= 0) {
991
+ const a = rng.range(0, Math.PI * 2), d = rng.range(5, 35);
992
+ s.x = cx + Math.cos(a) * d; s.y = cy + Math.sin(a) * d; s.r = rng.range(2, 8); s.life = 1;
993
+ return;
994
+ }
995
+ }
996
+ }
997
+ return {
998
+ onClick(x, y) {
999
+ pressScale = 0.88;
1000
+ const cx = x || 80, cy = y || 24;
1001
+ for (let i = 0; i < 20; i++) spawn(cx, cy);
1002
+ },
1003
+ tick(ctx, dt, now, st) {
1004
+ pressScale = lerp(pressScale, 1, dt * 8);
1005
+ ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
1006
+
1007
+ ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1008
+ ctx.fillStyle = '#e2e2f0'; ctx.font = "600 13px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1009
+ ctx.fillText('INK', st.w / 2, st.h / 2);
1010
+ ctx.restore();
1011
+
1012
+ // Splats -- fixed pool, const color, alpha via globalAlpha
1013
+ ctx.fillStyle = '#1e1e32';
1014
+ for (let i = 0; i < SPLATS; i++) {
1015
+ const s = splat[i];
1016
+ if (s.life <= 0) continue;
1017
+ s.life -= 1.5 * dt;
1018
+ if (s.life <= 0) continue;
1019
+ ctx.globalAlpha = s.life * 0.7;
1020
+ ctx.beginPath(); ctx.arc(s.x, s.y, s.r * easeOut(1 - s.life), 0, Math.PI * 2); ctx.fill();
1021
+ }
1022
+ ctx.globalAlpha = 1;
1023
+ if (st.focused) focusRing(ctx, st.w, st.h, 10);
1024
+ },
1025
+ };
1026
+ }
1027
+
1028
+ /** 8. Pixel Dissolve Button -- Hover breaks into floating pixels, reforms on leave. */
1029
+ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1030
+ const rng = new Random(seed);
1031
+ const N = cols * rows;
1032
+ const ox = new Float32Array(N), oy = new Float32Array(N);
1033
+ const px = new Float32Array(N), py = new Float32Array(N);
1034
+ const vx = new Float32Array(N), vy = new Float32Array(N);
1035
+ let dissolveT = 0;
1036
+
1037
+ return {
1038
+ init(ctx, w, h) {
1039
+ const pw = w / cols, ph = h / rows;
1040
+ for (let i = 0; i < N; i++) {
1041
+ ox[i] = (i % cols) * pw + pw / 2;
1042
+ oy[i] = ((i / cols) | 0) * ph + ph / 2;
1043
+ px[i] = ox[i]; py[i] = oy[i];
1044
+ }
1045
+ },
1046
+ onHover() { rng.reset(42); for (let i = 0; i < N; i++) { vx[i] = rng.range(-30, 30); vy[i] = rng.range(-30, 30); } },
1047
+ tick(ctx, dt, now, st) {
1048
+ dissolveT = lerp(dissolveT, st.hover ? 1 : 0, dt * 6);
1049
+
1050
+ ctx.fillStyle = st.hover ? 'rgba(167,139,250,.4)' : 'rgba(255,255,255,.12)';
1051
+ const pw = st.w / cols, ph = st.h / rows;
1052
+
1053
+ for (let i = 0; i < N; i++) {
1054
+ const tx = ox[i] + vx[i] * dissolveT;
1055
+ const ty = oy[i] + vy[i] * dissolveT;
1056
+ px[i] = lerp(px[i], tx, dt * 10);
1057
+ py[i] = lerp(py[i], ty, dt * 10);
1058
+ ctx.globalAlpha = 1 - dissolveT * 0.3;
1059
+ ctx.fillRect(px[i] - pw / 2 + 0.5, py[i] - ph / 2 + 0.5, pw - 1, ph - 1);
1060
+ }
1061
+ ctx.globalAlpha = 1;
1062
+
1063
+ if (dissolveT < 0.3) {
1064
+ ctx.fillStyle = '#e2e2f0'; ctx.font = "600 12px 'Space Grotesk',sans-serif";
1065
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1066
+ ctx.globalAlpha = 1 - dissolveT / 0.3;
1067
+ ctx.fillText('DISSOLVE', st.w / 2, st.h / 2);
1068
+ ctx.globalAlpha = 1;
1069
+ }
1070
+ if (st.focused) focusRing(ctx, st.w, st.h, 0);
1071
+ },
1072
+ };
1073
+ }
1074
+
1075
+ /** 9. Firework Button -- Shoots fireworks upward on click. */
1076
+ export function FireworkButton({ seed = 42 } = {}) {
1077
+ const rng = new Random(seed);
1078
+ const colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff6bcb'];
1079
+ // Fixed pools (life <= 0 == dead) -- no push/splice on the hot path.
1080
+ const ROCKETS = 16, SPARKS = 256;
1081
+ const rocket = [];
1082
+ for (let i = 0; i < ROCKETS; i++) rocket[i] = { x: 0, y: 0, vy: 0, life: 0, color: '#fff' };
1083
+ const spark = [];
1084
+ for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0, color: '#fff' };
1085
+ let pressScale = 1;
1086
+
1087
+ function spawnRocket(x, y) {
1088
+ for (let i = 0; i < ROCKETS; i++) {
1089
+ const r = rocket[i];
1090
+ if (r.life <= 0) { r.x = x; r.y = y; r.vy = -rng.range(150, 250); r.life = 1; r.color = rng.pick(colors); return; }
1091
+ }
1092
+ }
1093
+ function spawnSpark(x, y, color) {
1094
+ for (let i = 0; i < SPARKS; i++) {
1095
+ const s = spark[i];
1096
+ if (s.life <= 0) {
1097
+ const a = rng.range(0, Math.PI * 2), v = rng.range(30, 100);
1098
+ s.x = x; s.y = y; s.vx = Math.cos(a) * v; s.vy = Math.sin(a) * v; s.life = 1; s.color = color;
1099
+ return;
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ return {
1105
+ onClick(x, y, st) {
1106
+ pressScale = 0.88;
1107
+ spawnRocket(x || st.w / 2, st.h);
1108
+ },
1109
+ tick(ctx, dt, now, st) {
1110
+ pressScale = lerp(pressScale, 1, dt * 10);
1111
+ ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
1112
+ ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1113
+ ctx.fillStyle = '#fbbf24'; ctx.font = "600 13px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1114
+ ctx.fillText('\u{1F386} FIRE', st.w / 2, st.h / 2);
1115
+ ctx.restore();
1116
+
1117
+ // Rockets -- fixed pool; on death, burst into sparks
1118
+ for (let i = 0; i < ROCKETS; i++) {
1119
+ const r = rocket[i];
1120
+ if (r.life <= 0) continue;
1121
+ r.y += r.vy * dt; r.vy += 80 * dt; r.life -= dt;
1122
+ ctx.fillStyle = r.color; ctx.beginPath(); ctx.arc(r.x, r.y, 3, 0, Math.PI * 2); ctx.fill();
1123
+ if (r.life <= 0 || r.y < -20) {
1124
+ for (let j = 0; j < 20; j++) spawnSpark(r.x, r.y, r.color);
1125
+ r.life = 0;
1126
+ }
1127
+ }
1128
+
1129
+ // Sparks -- fixed pool, const color per spark, alpha via globalAlpha
1130
+ ctx.globalCompositeOperation = 'screen';
1131
+ for (let i = 0; i < SPARKS; i++) {
1132
+ const s = spark[i];
1133
+ if (s.life <= 0) continue;
1134
+ s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 60 * dt; s.life -= 1.5 * dt;
1135
+ if (s.life <= 0) continue;
1136
+ ctx.fillStyle = s.color; ctx.globalAlpha = s.life;
1137
+ ctx.beginPath(); ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2); ctx.fill();
1138
+ }
1139
+ ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1;
1140
+ if (st.focused) focusRing(ctx, st.w, st.h, 10);
1141
+ },
1142
+ };
1143
+ }
1144
+
1145
+
1146
+ // ===========================================================
1147
+ // SLIDERS
1148
+ // ===========================================================
1149
+
1150
+ /** 10. Aurora Slider -- Northern lights colors flow along the filled track. */
1151
+ export function AuroraSlider() {
1152
+ const REF = 100, PHASES = 12;
1153
+ const colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#c084fc'];
1154
+ let time = 0;
1155
+ // The shimmer animated the gradient stops per frame. Pre-build one gradient
1156
+ // per animation phase in init (all 0..REF) and cycle them by time; scale to
1157
+ // tx/REF at paint. Bounded, const set -> no per-frame createLinearGradient.
1158
+ const beams = [];
1159
+ return {
1160
+ init(ctx) {
1161
+ for (let p = 0; p < PHASES; p++) {
1162
+ const t = p / PHASES * PI2;
1163
+ const g = ctx.createLinearGradient(0, 0, REF, 0);
1164
+ for (let i = 0; i < 4; i++) {
1165
+ const pos = clamp(i / 3 + Math.sin(t + i) * 0.15, 0, 1);
1166
+ g.addColorStop(pos, colors[i]);
1167
+ }
1168
+ beams[p] = g;
1169
+ }
1170
+ },
1171
+ tick(ctx, dt, now, st) {
1172
+ time += dt;
1173
+ ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
1174
+ const tx = st.val * st.w;
1175
+ if (tx > 2) {
1176
+ const beam = beams[((time * 2) | 0) % PHASES] || beams[0];
1177
+ ctx.save();
1178
+ ctx.scale(tx / REF, 1);
1179
+ ctx.fillStyle = beam; ctx.shadowBlur = 8; ctx.shadowColor = '#6ee7b6';
1180
+ roundRect(ctx, 0, 12, REF, 4, 2); ctx.fill();
1181
+ ctx.restore();
1182
+ ctx.shadowBlur = 0;
1183
+ }
1184
+ ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
1185
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1186
+ if (st.focused) focusRing(ctx, st.w, st.h, 4);
1187
+ },
1188
+ };
1189
+ }
1190
+
1191
+ /** 11. Wave Slider -- Audio waveform visualization on the filled track. */
1192
+ export function WaveSlider({ seed = 42 } = {}) {
1193
+ const rng = new Random(seed);
1194
+ const bars = new Float32Array(40);
1195
+ return {
1196
+ init() { for (let i = 0; i < 40; i++) bars[i] = rng.range(0.2, 1); },
1197
+ tick(ctx, dt, now, st) {
1198
+ ctx.fillStyle = 'rgba(255,255,255,.04)'; roundRect(ctx, 0, 4, st.w, 20, 3); ctx.fill();
1199
+ const tx = st.val * st.w, bw = st.w / 40;
1200
+ for (let i = 0; i < 40; i++) {
1201
+ const bx = i * bw, active = bx < tx;
1202
+ const h = bars[i] * 16 * (0.5 + Math.sin(now / 300 + i * 0.3) * 0.3);
1203
+ ctx.fillStyle = active ? '#f472b6' : 'rgba(255,255,255,.06)';
1204
+ ctx.fillRect(bx + 1, 14 - h / 2, bw - 2, h);
1205
+ }
1206
+ ctx.fillStyle = '#f472b6'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
1207
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1208
+ if (st.focused) focusRing(ctx, st.w, st.h, 4);
1209
+ },
1210
+ };
1211
+ }
1212
+
1213
+ /** 12. Elastic Band Slider -- Track rubber-bands ahead of thumb, snaps back. */
1214
+ export function ElasticBandSlider() {
1215
+ let leadX = 0;
1216
+ return {
1217
+ tick(ctx, dt, now, st) {
1218
+ const tx = st.val * st.w;
1219
+ leadX = lerp(leadX, tx, dt * 4); // Laggy -- creates stretch
1220
+ const stretch = (tx - leadX) * 0.3;
1221
+
1222
+ ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
1223
+
1224
+ // Elastic band (curves toward target)
1225
+ ctx.strokeStyle = '#fb923c'; ctx.lineWidth = 3;
1226
+ ctx.beginPath(); ctx.moveTo(0, 14);
1227
+ ctx.quadraticCurveTo(leadX + stretch, 14 + stretch * 0.2, tx, 14);
1228
+ ctx.stroke();
1229
+
1230
+ ctx.fillStyle = '#fb923c'; ctx.beginPath(); ctx.arc(tx, 14, 9, 0, Math.PI * 2); ctx.fill();
1231
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1232
+ if (st.focused) focusRing(ctx, st.w, st.h, 4);
1233
+ },
1234
+ };
1235
+ }
1236
+
1237
+ /** 13. Gravity Slider -- Track sags under the weight of the thumb. */
1238
+ export function GravitySlider() {
1239
+ return {
1240
+ tick(ctx, dt, now, st) {
1241
+ const tx = st.val * st.w, sag = 8;
1242
+
1243
+ // Sagging track (quadratic curve through thumb position)
1244
+ ctx.strokeStyle = 'rgba(255,255,255,.1)'; ctx.lineWidth = 3;
1245
+ ctx.beginPath(); ctx.moveTo(0, 14);
1246
+ ctx.quadraticCurveTo(tx, 14 + sag, st.w, 14);
1247
+ ctx.stroke();
1248
+
1249
+ // Filled portion follows the sag
1250
+ ctx.strokeStyle = '#c084fc'; ctx.lineWidth = 3;
1251
+ ctx.beginPath(); ctx.moveTo(0, 14);
1252
+ const midSag = sag * (tx / st.w);
1253
+ ctx.quadraticCurveTo(tx / 2, 14 + midSag, tx, 14 + sag * Math.sin(Math.PI * st.val));
1254
+ ctx.stroke();
1255
+
1256
+ ctx.fillStyle = '#c084fc'; ctx.beginPath();
1257
+ ctx.arc(tx, 14 + sag * Math.sin(Math.PI * st.val), 10, 0, Math.PI * 2); ctx.fill();
1258
+
1259
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 14);
1260
+ if (st.focused) focusRing(ctx, st.w, st.h + 8, 4);
1261
+ },
1262
+ };
1263
+ }
1264
+
1265
+
1266
+ // ===========================================================
1267
+ // LOADERS (use toggle to start/stop)
1268
+ // ===========================================================
1269
+
1270
+ /** 14. Orbit Loader -- Planets orbit a sun. Toggle starts/stops. */
1271
+ export function OrbitLoader() {
1272
+ let phase = 0;
1273
+ const planets = [
1274
+ { r: 14, speed: 2.0, size: 3, color: '#38bdf8' },
1275
+ { r: 22, speed: 1.2, size: 2.5, color: '#6ee7b6' },
1276
+ { r: 30, speed: 0.7, size: 2, color: '#fbbf24' },
1277
+ ];
1278
+ return {
1279
+ tick(ctx, dt, now, st) {
1280
+ if (st.toggled) phase += dt;
1281
+ const cx = st.w / 2, cy = st.h / 2;
1282
+
1283
+ // Orbits (plain loop -- forEach's arrow was a per-frame closure alloc)
1284
+ for (let i = 0; i < planets.length; i++) {
1285
+ const p = planets[i];
1286
+ ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
1287
+ ctx.beginPath(); ctx.arc(cx, cy, p.r, 0, Math.PI * 2); ctx.stroke();
1288
+ const a = phase * p.speed;
1289
+ ctx.fillStyle = p.color;
1290
+ ctx.beginPath(); ctx.arc(cx + Math.cos(a) * p.r, cy + Math.sin(a) * p.r, p.size, 0, Math.PI * 2); ctx.fill();
1291
+ }
1292
+
1293
+ // Sun
1294
+ ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
1295
+ label(ctx, st.toggled ? 'LOADING...' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#fbbf24' : '#8888aa');
1296
+ if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
1297
+ },
1298
+ };
1299
+ }
1300
+
1301
+ /** 15. Helix Loader -- DNA double helix spinning. */
1302
+ export function HelixLoader() {
1303
+ let phase = 0;
1304
+ return {
1305
+ tick(ctx, dt, now, st) {
1306
+ if (st.toggled) phase += dt * 4;
1307
+ const cx = st.w / 2, cy = st.h / 2;
1308
+
1309
+ for (let i = 0; i < 12; i++) {
1310
+ const t = i / 12, a = phase + t * Math.PI * 2;
1311
+ const x1 = cx + Math.cos(a) * 16, y1 = cy + Math.sin(a) * 6;
1312
+ const x2 = cx - Math.cos(a) * 16, y2 = cy - Math.sin(a) * 6;
1313
+ const depth = (Math.sin(a) + 1) / 2;
1314
+
1315
+ // Const strand colors, per-node alpha via globalAlpha (was three
1316
+ // `rgba(...,${...})` templates per node per frame).
1317
+ ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.2 + depth * 0.6;
1318
+ ctx.beginPath(); ctx.arc(x1, y1, 2 + depth, 0, Math.PI * 2); ctx.fill();
1319
+ ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.2 + (1 - depth) * 0.6;
1320
+ ctx.beginPath(); ctx.arc(x2, y2, 2 + (1 - depth), 0, Math.PI * 2); ctx.fill();
1321
+
1322
+ ctx.strokeStyle = '#ffffff'; ctx.globalAlpha = 0.03 + depth * 0.04;
1323
+ ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
1324
+ }
1325
+ ctx.globalAlpha = 1;
1326
+ label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
1327
+ if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
1328
+ },
1329
+ };
1330
+ }
1331
+
1332
+
1333
+ // ===========================================================
1334
+ // CHECKBOXES (use toggle events)
1335
+ // ===========================================================
1336
+
1337
+ /** 16. Ripple Checkbox -- Material-style ripple ring + morphing checkmark. */
1338
+ export function RippleCheck() {
1339
+ // Fixed ripple pool (life <= 0 == dead) -- no push/splice on the hot path.
1340
+ const RIPPLES = 8;
1341
+ const ripR = new Float64Array(RIPPLES);
1342
+ const ripLife = new Float64Array(RIPPLES);
1343
+ let checkT = 0;
1344
+ function spawn() {
1345
+ for (let i = 0; i < RIPPLES; i++) {
1346
+ if (ripLife[i] <= 0) { ripR[i] = 0; ripLife[i] = 1; return; }
1347
+ }
1348
+ }
1349
+ return {
1350
+ onToggle(checked) { if (checked) spawn(); },
1351
+ tick(ctx, dt, now, st) {
1352
+ checkT = lerp(checkT, st.toggled ? 1 : 0, dt * 10);
1353
+ const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
1354
+
1355
+ // Box
1356
+ ctx.fillStyle = st.toggled ? '#6ee7b6' : 'rgba(255,255,255,.06)';
1357
+ roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1358
+
1359
+ // Ripples -- fixed pool, const color, alpha via globalAlpha
1360
+ ctx.strokeStyle = '#6ee7b6'; ctx.lineWidth = 2;
1361
+ for (let i = 0; i < RIPPLES; i++) {
1362
+ if (ripLife[i] <= 0) continue;
1363
+ ripR[i] += 50 * dt; ripLife[i] -= 2 * dt;
1364
+ if (ripLife[i] <= 0) continue;
1365
+ ctx.globalAlpha = ripLife[i];
1366
+ ctx.beginPath(); ctx.arc(cx, cy, ripR[i], 0, Math.PI * 2); ctx.stroke();
1367
+ }
1368
+ ctx.globalAlpha = 1;
1369
+
1370
+ // Checkmark (animated draw)
1371
+ if (checkT > 0.01) {
1372
+ ctx.strokeStyle = '#000'; ctx.lineWidth = 3; ctx.lineCap = 'round';
1373
+ ctx.beginPath();
1374
+ const p1 = clamp(checkT * 2, 0, 1); // First stroke
1375
+ const p2 = clamp(checkT * 2 - 1, 0, 1); // Second stroke
1376
+ ctx.moveTo(cx - 7, cy);
1377
+ ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
1378
+ if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
1379
+ ctx.stroke(); ctx.lineCap = 'butt';
1380
+ }
1381
+ label(ctx, st.toggled ? '\u2713' : '\u25CB', sz / 2, sz + 12, st.toggled ? '#6ee7b6' : '#8888aa');
1382
+ if (st.focused) focusRing(ctx, sz, sz, 6);
1383
+ },
1384
+ };
1385
+ }
1386
+
1387
+ /** 17. Morph Checkbox -- X morphs into checkmark smoothly. */
1388
+ export function MorphCheck() {
1389
+ let t = 0;
1390
+ return {
1391
+ tick(ctx, dt, now, st) {
1392
+ t = lerp(t, st.toggled ? 1 : 0, dt * 8);
1393
+ const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
1394
+
1395
+ // Box bg: faint white early, blue (alpha via globalAlpha) as t rises.
1396
+ if (lerp(0.06, 0.15, t) < 0.1) {
1397
+ ctx.fillStyle = 'rgba(255,255,255,.06)';
1398
+ roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1399
+ } else {
1400
+ ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = lerp(0, 0.2, t);
1401
+ roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1402
+ ctx.globalAlpha = 1;
1403
+ }
1404
+
1405
+ ctx.strokeStyle = t > 0.5 ? '#38bdf8' : '#9999b8'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
1406
+
1407
+ // Morph: X -> checkmark by interpolating endpoints
1408
+ const x1a = cx - 8, y1a = cy - 8; // X top-left
1409
+ const x1b = cx + 8, y1b = cy + 8; // X bottom-right
1410
+ const x2a = cx - 8, y2a = cy + 8; // X bottom-left
1411
+ const x2b = cx + 8, y2b = cy - 8; // X top-right
1412
+
1413
+ const c1a = cx - 7, c1b = cy; // Check start
1414
+ const c2a = cx - 2, c2b = cy + 7; // Check middle
1415
+ const c3a = cx + 8, c3b = cy - 6; // Check end
1416
+
1417
+ ctx.beginPath();
1418
+ ctx.moveTo(lerp(x1a, c1a, t), lerp(y1a, c1b, t));
1419
+ ctx.lineTo(lerp(x1b, c2a, t), lerp(y1b, c2b, t));
1420
+ ctx.stroke();
1421
+
1422
+ ctx.beginPath();
1423
+ ctx.moveTo(lerp(x2a, c2a, t), lerp(y2a, c2b, t));
1424
+ ctx.lineTo(lerp(x2b, c3a, t), lerp(y2b, c3b, t));
1425
+ ctx.stroke();
1426
+ ctx.lineCap = 'butt';
1427
+
1428
+ label(ctx, st.toggled ? '\u2713' : '\u2715', sz / 2, sz + 12, st.toggled ? '#38bdf8' : '#8888aa');
1429
+ if (st.focused) focusRing(ctx, sz, sz, 6);
1430
+ },
1431
+ };
1432
+ }
1433
+
1434
+
1435
+ // ===========================================================
1436
+ // COUNTERS (use slider val as input)
1437
+ // ===========================================================
1438
+
1439
+ /** 18. Flame Counter -- Number with rising heat particles driven by slider value. */
1440
+ export function FlameCounter({ seed = 42 } = {}) {
1441
+ const rng = new Random(seed);
1442
+ // Fixed ember pool + heat-color LUT (rgb quantized by value), so neither the
1443
+ // pool nor the per-ember color allocates. The number string is rebuilt only
1444
+ // when the displayed integer changes.
1445
+ const EMBERS = 64;
1446
+ const ember = [];
1447
+ for (let i = 0; i < EMBERS; i++) ember[i] = { x: 0, y: 0, vy: 0, life: 0, size: 0 };
1448
+ const HEAT = [];
1449
+ for (let i = 0; i <= 32; i++) HEAT[i] = 'rgb(' + Math.round(200 + (i / 32) * 55) + ',' + Math.round(60 + (i / 32) * 80) + ',20)';
1450
+ let numStr = '0', lastNum = -1;
1451
+ function spawn(st) {
1452
+ for (let i = 0; i < EMBERS; i++) {
1453
+ const e = ember[i];
1454
+ if (e.life <= 0) { e.x = rng.range(20, st.w - 20); e.y = st.h - 5; e.vy = -rng.range(20, 60); e.life = 1; e.size = rng.range(1, 3); return; }
1455
+ }
1456
+ }
1457
+ return {
1458
+ tick(ctx, dt, now, st) {
1459
+ const val = Math.round(st.val * 999);
1460
+ if (val !== lastNum) { lastNum = val; numStr = String(val); }
1461
+
1462
+ // Spawn embers proportional to value (pool self-caps at EMBERS)
1463
+ if (st.val > 0.1) spawn(st);
1464
+
1465
+ // Background
1466
+ ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1467
+
1468
+ // Embers -- fixed pool, heat color via LUT, alpha via globalAlpha
1469
+ ctx.globalCompositeOperation = 'screen';
1470
+ ctx.fillStyle = HEAT[(st.val * 32) | 0] || HEAT[32];
1471
+ for (let i = 0; i < EMBERS; i++) {
1472
+ const e = ember[i];
1473
+ if (e.life <= 0) continue;
1474
+ e.y += e.vy * dt; e.life -= dt * 0.8;
1475
+ if (e.life <= 0) continue;
1476
+ ctx.globalAlpha = e.life;
1477
+ ctx.beginPath(); ctx.arc(e.x, e.y, e.size, 0, Math.PI * 2); ctx.fill();
1478
+ }
1479
+ ctx.globalAlpha = 1;
1480
+ ctx.globalCompositeOperation = 'source-over';
1481
+
1482
+ // Number
1483
+ ctx.fillStyle = st.val > 0.7 ? '#ff6b6b' : st.val > 0.3 ? '#fbbf24' : '#9999b8';
1484
+ ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1485
+ ctx.fillText(numStr, st.w / 2, st.h / 2);
1486
+
1487
+ label(ctx, 'HEAT', st.w / 2, st.h + 12);
1488
+ if (st.focused) focusRing(ctx, st.w, st.h, 10);
1489
+ },
1490
+ };
1491
+ }
1492
+
1493
+ /** 19. Glitch Counter -- Number glitches and jitters as slider value increases. */
1494
+ export function GlitchCounter({ seed = 42 } = {}) {
1495
+ const rng = new Random(seed);
1496
+ let numStr = '0', lastNum = -1;
1497
+ return {
1498
+ tick(ctx, dt, now, st) {
1499
+ const val = Math.round(st.val * 999);
1500
+ if (val !== lastNum) { lastNum = val; numStr = String(val); }
1501
+ const intensity = st.val;
1502
+
1503
+ ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1504
+
1505
+ // Scanlines -- const white, alpha via globalAlpha
1506
+ if (intensity > 0.2) {
1507
+ ctx.fillStyle = '#ffffff'; ctx.globalAlpha = intensity * 0.03;
1508
+ for (let y = 0; y < st.h; y += 3) ctx.fillRect(0, y, st.w, 1);
1509
+ ctx.globalAlpha = 1;
1510
+ }
1511
+
1512
+ // Glitched number
1513
+ const jx = intensity > 0.5 ? (rng.next() - 0.5) * intensity * 6 : 0;
1514
+ const jy = intensity > 0.5 ? (rng.next() - 0.5) * intensity * 4 : 0;
1515
+
1516
+ if (intensity > 0.3 && rng.next() > 0.6) {
1517
+ ctx.globalCompositeOperation = 'screen';
1518
+ ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1519
+ // RGB split -- const channel colors, shared alpha via globalAlpha
1520
+ ctx.globalAlpha = intensity * 0.4;
1521
+ ctx.fillStyle = '#ff0055';
1522
+ ctx.fillText(numStr, st.w / 2 + jx - 2, st.h / 2 + jy);
1523
+ ctx.fillStyle = '#00ffcc';
1524
+ ctx.fillText(numStr, st.w / 2 + jx + 2, st.h / 2 + jy);
1525
+ ctx.globalAlpha = 1;
1526
+ ctx.globalCompositeOperation = 'source-over';
1527
+ }
1528
+
1529
+ ctx.fillStyle = '#e2e2f0';
1530
+ ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1531
+ ctx.fillText(numStr, st.w / 2 + jx, st.h / 2 + jy);
1532
+
1533
+ label(ctx, 'SIGNAL', st.w / 2, st.h + 12);
1534
+ if (st.focused) focusRing(ctx, st.w, st.h, 10);
1535
+ },
1536
+ };
1537
+ }
1538
+
1539
+
1540
+ // ===========================================================
1541
+ // RATING (uses slider position mapped to 1--5 stars)
1542
+ // ===========================================================
1543
+
1544
+ /** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
1545
+ export function BubbleRating({ seed = 42 } = {}) {
1546
+ const rng = new Random(seed);
1547
+ const R5 = ['0 / 5', '1 / 5', '2 / 5', '3 / 5', '4 / 5', '5 / 5']; // const labels
1548
+ const POPS = 32;
1549
+ const pop = [];
1550
+ for (let i = 0; i < POPS; i++) pop[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
1551
+ const sizes = new Float32Array(5);
1552
+ let lastW = 200;
1553
+ function spawnPops(cx) {
1554
+ for (let j = 0; j < 5; j++) {
1555
+ for (let i = 0; i < POPS; i++) {
1556
+ const p = pop[i];
1557
+ if (p.life <= 0) {
1558
+ const a = rng.range(0, Math.PI * 2), v = rng.range(20, 50);
1559
+ p.x = cx; p.y = 14; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
1560
+ break;
1561
+ }
1562
+ }
1563
+ }
1564
+ }
1565
+ return {
1566
+ tick(ctx, dt, now, st) {
1567
+ lastW = st.w;
1568
+ const rating = Math.round(st.val * 5);
1569
+ const gap = st.w / 5;
1570
+
1571
+ for (let i = 0; i < 5; i++) {
1572
+ const active = i < rating;
1573
+ const targetSz = active ? 12 : 6;
1574
+ sizes[i] = lerp(sizes[i] || 6, targetSz, dt * 8);
1575
+ const cx = gap * i + gap / 2, cy = st.h / 2;
1576
+
1577
+ // Bubble -- const color, active alpha via globalAlpha
1578
+ if (active) { ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
1579
+ else { ctx.fillStyle = 'rgba(255,255,255,.05)'; ctx.globalAlpha = 1; }
1580
+ ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.fill();
1581
+ ctx.globalAlpha = 1;
1582
+ ctx.strokeStyle = active ? '#38bdf8' : 'rgba(255,255,255,.08)';
1583
+ ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.stroke();
1584
+
1585
+ // Highlight
1586
+ if (active) {
1587
+ ctx.fillStyle = 'rgba(255,255,255,.15)';
1588
+ ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, Math.PI * 2); ctx.fill();
1589
+ }
1590
+ }
1591
+
1592
+ // Pop particles -- fixed pool, const color, alpha via globalAlpha
1593
+ ctx.fillStyle = '#38bdf8';
1594
+ for (let i = 0; i < POPS; i++) {
1595
+ const p = pop[i];
1596
+ if (p.life <= 0) continue;
1597
+ p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 3 * dt;
1598
+ if (p.life <= 0) continue;
1599
+ ctx.globalAlpha = p.life;
1600
+ ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2); ctx.fill();
1601
+ }
1602
+ ctx.globalAlpha = 1;
1603
+
1604
+ label(ctx, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? '#38bdf8' : '#9999b8');
1605
+ if (st.focused) focusRing(ctx, st.w, st.h, 4);
1606
+ },
1607
+ onDrag(val) {
1608
+ const rating = Math.round(val * 5);
1609
+ // Pop the newly activated bubble (position derived from st.w via lastW)
1610
+ const gap = lastW / 5;
1611
+ const cx = gap * (rating - 1) + gap / 2;
1612
+ spawnPops(cx);
1613
+ },
1614
+ };
1615
+ }
1616
+
1617
+
1618
+ // ===========================================================
1619
+ // KNOBS
1620
+ // ===========================================================
1621
+
1622
+ /** 1. Volume Knob -- Rotary dial with tick marks and arc indicator. */
1623
+ export function VolumeKnob() {
1624
+ let displayVal = 0, grad = null;
1625
+ let numStr = '0', lastNum = -1;
1626
+ return {
1627
+ init(c, w, h) {
1628
+ // Value-arc gradient depends only on w/h (fixed per mount) -> build once.
1629
+ grad = c.createLinearGradient(0, h, w, 0);
1630
+ grad.addColorStop(0, '#6ee7b6'); grad.addColorStop(1, '#38bdf8');
1631
+ },
1632
+ tick(c,dt,now,st) {
1633
+ displayVal = lerp(displayVal, st.val, dt * 12);
1634
+ const cx=st.w/2, cy=st.h/2, R=Math.min(cx,cy)-4;
1635
+ const startA=-Math.PI*0.75, endA=Math.PI*0.75, range=endA-startA;
1636
+
1637
+ // Background ring
1638
+ c.strokeStyle='rgba(255,255,255,.06)'; c.lineWidth=4;
1639
+ c.beginPath(); c.arc(cx,cy,R,startA,endA); c.stroke();
1640
+
1641
+ // Value arc (gradient built once in init)
1642
+ const valA = startA + displayVal * range;
1643
+ c.strokeStyle = grad || '#38bdf8'; c.lineWidth=4;
1644
+ c.beginPath(); c.arc(cx,cy,R,startA,valA); c.stroke();
1645
+
1646
+ // Tick marks
1647
+ for(let i=0;i<=10;i++){
1648
+ const a=startA+i/10*range;
1649
+ const inner=R-6, outer=R+2;
1650
+ c.strokeStyle=i/10<=displayVal?'rgba(110,231,182,.5)':'rgba(255,255,255,.08)';
1651
+ c.lineWidth=1; c.beginPath();
1652
+ c.moveTo(cx+Math.cos(a)*inner,cy+Math.sin(a)*inner);
1653
+ c.lineTo(cx+Math.cos(a)*outer,cy+Math.sin(a)*outer);
1654
+ c.stroke();
1655
+ }
1656
+
1657
+ // Needle dot
1658
+ c.fillStyle='#fff';
1659
+ c.beginPath(); c.arc(cx+Math.cos(valA)*(R-12),cy+Math.sin(valA)*(R-12),3,0,PI2); c.fill();
1660
+
1661
+ // Center
1662
+ c.fillStyle='rgba(255,255,255,.04)';
1663
+ c.beginPath(); c.arc(cx,cy,R*0.4,0,PI2); c.fill();
1664
+
1665
+ // Value text (rebuilt only when the integer changes)
1666
+ const v = Math.round(displayVal * 100);
1667
+ if (v !== lastNum) { lastNum = v; numStr = String(v); }
1668
+ c.fillStyle='#e2e2f0'; c.font="700 14px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
1669
+ c.fillText(numStr,cx,cy);
1670
+ lbl(c,'VOLUME',cx,st.h+10);
1671
+ if(st.focused)fr(c,st.w,st.h,st.h/2);
1672
+ },
1673
+ };
1674
+ }
1675
+
1676
+ /** 2. Compass Knob -- Needle points based on slider value (0=N, 0.5=S, 1=N). */
1677
+ export function CompassKnob() {
1678
+ const dirs = ['N', 'E', 'S', 'W']; // hoisted out of tick
1679
+ const cols = ['#ff6b6b', '#9999b8', '#9999b8', '#9999b8']; // (were per-frame arrays)
1680
+ let needleA = 0;
1681
+ let degStr = '0\u00B0', lastDeg = -1;
1682
+ return {
1683
+ tick(c,dt,now,st) {
1684
+ const targetA = st.val * PI2;
1685
+ needleA = lerp(needleA, targetA, dt*8);
1686
+ const cx=st.w/2, cy=st.h/2, R=Math.min(cx,cy)-4;
1687
+
1688
+ // Ring
1689
+ c.strokeStyle='rgba(255,255,255,.06)'; c.lineWidth=2;
1690
+ c.beginPath(); c.arc(cx,cy,R,0,PI2); c.stroke();
1691
+
1692
+ // Cardinal marks
1693
+ for(let i=0;i<4;i++){
1694
+ const a=-Math.PI/2+i*Math.PI/2;
1695
+ c.fillStyle=cols[i]; c.font="600 9px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
1696
+ c.fillText(dirs[i],cx+Math.cos(a)*(R-10),cy+Math.sin(a)*(R-10));
1697
+ }
1698
+
1699
+ // Needle
1700
+ c.save(); c.translate(cx,cy); c.rotate(needleA-Math.PI/2);
1701
+ c.fillStyle='#ff6b6b';
1702
+ c.beginPath(); c.moveTo(0,-R+18); c.lineTo(-4,4); c.lineTo(4,4); c.closePath(); c.fill();
1703
+ c.fillStyle='rgba(255,255,255,.15)';
1704
+ c.beginPath(); c.moveTo(0,R-18); c.lineTo(-4,-4); c.lineTo(4,-4); c.closePath(); c.fill();
1705
+ c.restore();
1706
+
1707
+ // Center pin
1708
+ c.fillStyle='#333'; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.fill();
1709
+ c.strokeStyle='rgba(255,255,255,.1)'; c.lineWidth=1; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.stroke();
1710
+
1711
+ const deg = Math.round(st.val * 360);
1712
+ if (deg !== lastDeg) { lastDeg = deg; degStr = deg + '\u00B0'; }
1713
+ lbl(c, degStr, cx, st.h + 10);
1714
+ if(st.focused)fr(c,st.w,st.h,st.h/2);
1715
+ },
1716
+ };
1717
+ }
1718
+
1719
+
1720
+ // ===========================================================
1721
+ // PROGRESS
1722
+ // ===========================================================
1723
+
1724
+ /** 3. Ring Progress -- Circular progress with animated fill and particles at the tip. */
1725
+ export function RingProgress({seed=42}={}) {
1726
+ const rng=new Random(seed);
1727
+ // Fixed spark pool + a bounded set of conic gradients (one per progress
1728
+ // level) built in init, so the green stop still tracks the arc tip with zero
1729
+ // per-frame gradient construction.
1730
+ const SPARKS = 24;
1731
+ const spark = [];
1732
+ for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
1733
+ const GPHASES = 16;
1734
+ const grads = [];
1735
+ let displayVal = 0;
1736
+ function spawn(x, y) {
1737
+ for (let i = 0; i < SPARKS; i++) {
1738
+ const s = spark[i];
1739
+ if (s.life <= 0) { s.x = x; s.y = y; s.vx = rng.range(-20, 20); s.vy = rng.range(-20, 20); s.life = 1; return; }
1740
+ }
1741
+ }
1742
+ return {
1743
+ init(c, w, h) {
1744
+ for (let p = 0; p < GPHASES; p++) {
1745
+ const g = c.createConicGradient(-Math.PI / 2, w / 2, h / 2);
1746
+ g.addColorStop(0, '#a78bfa');
1747
+ g.addColorStop((p / (GPHASES - 1)) * 0.95, '#6ee7b6');
1748
+ g.addColorStop(1, 'rgba(110,231,182,.2)');
1749
+ grads[p] = g;
1750
+ }
1751
+ },
1752
+ tick(c,dt,now,st) {
1753
+ displayVal=lerp(displayVal,st.val,dt*6);
1754
+ const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-6,lw=6;
1755
+
1756
+ // Background
1757
+ c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=lw;
1758
+ c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
1759
+
1760
+ // Fill arc (conic gradient from init; the green stop tracks the tip)
1761
+ const a=-Math.PI/2,ea=a+displayVal*PI2;
1762
+ c.strokeStyle = grads[(displayVal * (GPHASES - 1)) | 0] || '#6ee7b6';c.lineWidth=lw;
1763
+ c.beginPath();c.arc(cx,cy,R,a,ea);c.stroke();
1764
+
1765
+ // Tip sparks -- fixed pool, const color, alpha via globalAlpha
1766
+ if(displayVal>0.02){
1767
+ const tx=cx+Math.cos(ea)*R,ty=cy+Math.sin(ea)*R;
1768
+ spawn(tx, ty);
1769
+ }
1770
+ c.fillStyle = '#6ee7b6';
1771
+ for(let i=0;i<SPARKS;i++){
1772
+ const s=spark[i];
1773
+ if(s.life<=0)continue;
1774
+ s.x+=s.vx*dt;s.y+=s.vy*dt;s.life-=2*dt;
1775
+ if(s.life<=0)continue;
1776
+ c.globalAlpha=s.life;
1777
+ c.beginPath();c.arc(s.x,s.y,1.5,0,PI2);c.fill();
1778
+ }
1779
+ c.globalAlpha=1;
1780
+
1781
+ // Percentage
1782
+ c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
1783
+ c.fillText(PCT[Math.round(displayVal*100)],cx,cy);
1784
+ if(st.focused)fr(c,st.w,st.h,st.h/2);
1785
+ },
1786
+ };
1787
+ }
1788
+
1789
+ /** 4. Battery Gauge -- Battery icon that fills and changes color. */
1790
+ export function BatteryGauge() {
1791
+ let displayVal=0;
1792
+ return {
1793
+ tick(c,dt,now,st) {
1794
+ displayVal=lerp(displayVal,st.val,dt*8);
1795
+ const bw=st.w-16,bh=st.h-12,bx=8,by=6,r=4;
1796
+
1797
+ // Shell
1798
+ c.strokeStyle='rgba(255,255,255,.15)';c.lineWidth=2;
1799
+ rr(c,bx,by,bw,bh,r);c.stroke();
1800
+ // Cap
1801
+ c.fillStyle='rgba(255,255,255,.15)';
1802
+ c.fillRect(bx+bw+1,by+bh/2-4,4,8);
1803
+
1804
+ // Fill
1805
+ const fillW=Math.max(0,(bw-6)*displayVal);
1806
+ const col=displayVal<.2?'#ff6b6b':displayVal<.5?'#fbbf24':'#6ee7b6';
1807
+ c.fillStyle=col;
1808
+ rr(c,bx+3,by+3,fillW,bh-6,2);c.fill();
1809
+
1810
+ // Pulse glow when charging (high values)
1811
+ if(displayVal>.8){
1812
+ const pulse=Math.sin(now/200)*.3+.7;
1813
+ c.shadowBlur=8*pulse;c.shadowColor=col;
1814
+ rr(c,bx+3,by+3,fillW,bh-6,2);c.fill();
1815
+ c.shadowBlur=0;
1816
+ }
1817
+
1818
+ // Low battery flash
1819
+ if(displayVal<.15&&((now/500|0)%2===0)){
1820
+ c.fillStyle='rgba(255,80,80,.15)';rr(c,bx,by,bw,bh,r);c.fill();
1821
+ }
1822
+
1823
+ c.fillStyle='#e2e2f0';c.font="600 10px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
1824
+ c.fillText(PCT[Math.round(displayVal*100)],bx+bw/2,by+bh/2);
1825
+ if(st.focused)fr(c,st.w+4,st.h,r);
1826
+ },
1827
+ };
1828
+ }
1829
+
1830
+ /** 5. Signal Meter -- WiFi-style signal bars. */
1831
+ export function SignalMeter() {
1832
+ let bars=[0,0,0,0,0];
1833
+ return {
1834
+ tick(c,dt,now,st) {
1835
+ const level=Math.ceil(st.val*5);
1836
+ for(let i=0;i<5;i++){
1837
+ const target=i<level?1:0;
1838
+ bars[i]=lerp(bars[i],target,dt*10);
1839
+ }
1840
+ const bw=8,gap=4,total=5*(bw+gap)-gap;
1841
+ const ox=(st.w-total)/2;
1842
+
1843
+ for(let i=0;i<5;i++){
1844
+ const maxH=8+i*6, h=maxH*bars[i]+2;
1845
+ const x=ox+i*(bw+gap), y=st.h-6-h;
1846
+ const active=bars[i]>.5;
1847
+ c.fillStyle=active?(st.val>.6?'#6ee7b6':st.val>.3?'#fbbf24':'#ff6b6b'):'rgba(255,255,255,.06)';
1848
+ rr(c,x,y,bw,h,2);c.fill();
1849
+ }
1850
+ lbl(c,level+'/5',st.w/2,st.h+10,st.val>.6?'#6ee7b6':'#9999b8');
1851
+ if(st.focused)fr(c,st.w,st.h,4);
1852
+ },
1853
+ };
1854
+ }
1855
+
1856
+
1857
+ // ===========================================================
1858
+ // CONTROLS
1859
+ // ===========================================================
1860
+
1861
+ /** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
1862
+ export function PillTabs() {
1863
+ let indicatorX=0, indicatorW=0, selected=0;
1864
+ const labels=['Alpha','Beta','Gamma'];
1865
+ return {
1866
+ onClick(x,y,st) {
1867
+ const idx=Math.floor(x/(st.w/3));
1868
+ selected=clamp(idx,0,2);
1869
+ },
1870
+ tick(c,dt,now,st) {
1871
+ const tw=st.w/3;
1872
+ const tx=selected*tw, targetW=tw;
1873
+ indicatorX=lerp(indicatorX,tx,dt*12);
1874
+ indicatorW=lerp(indicatorW,targetW,dt*12);
1875
+
1876
+ // Background
1877
+ c.fillStyle='rgba(255,255,255,.03)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
1878
+
1879
+ // Indicator
1880
+ c.fillStyle='rgba(167,139,250,.12)';c.strokeStyle='rgba(167,139,250,.25)';c.lineWidth=1;
1881
+ rr(c,indicatorX+2,2,indicatorW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
1882
+
1883
+ // Labels
1884
+ c.font="600 11px 'Space Grotesk',sans-serif";c.textAlign='center';c.textBaseline='middle';
1885
+ for(let i=0;i<3;i++){
1886
+ c.fillStyle=i===selected?'#c4b5fd':'#9999b8';
1887
+ c.fillText(labels[i],tw*i+tw/2,st.h/2);
1888
+ }
1889
+ if(st.focused)fr(c,st.w,st.h,st.h/2);
1890
+ },
1891
+ };
1892
+ }
1893
+
1894
+ /** 7. Stepper -- +/- buttons with spring counter. */
1895
+ export function Stepper() {
1896
+ let count=0,displayCount=0,flashDir=0,flashTimer=0;
1897
+ return {
1898
+ onClick(x,y,st) {
1899
+ if(x<st.w*0.33){count=Math.max(0,count-1);flashDir=-1;}
1900
+ else if(x>st.w*0.66){count++;flashDir=1;}
1901
+ flashTimer=0.3;
1902
+ },
1903
+ tick(c,dt,now,st) {
1904
+ displayCount=lerp(displayCount,count,dt*10);
1905
+ flashTimer=Math.max(0,flashTimer-dt);
1906
+
1907
+ // Background
1908
+ c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,10);c.fill();
1909
+
1910
+ // Minus zone
1911
+ const third=st.w/3;
1912
+ c.fillStyle=flashDir===-1&&flashTimer>0?'rgba(255,100,100,.15)':'rgba(255,255,255,.03)';
1913
+ rr(c,2,2,third-4,st.h-4,8);c.fill();
1914
+ c.fillStyle='#ff6b6b';c.font="700 18px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
1915
+ c.fillText('\u2212',third/2,st.h/2);
1916
+
1917
+ // Plus zone
1918
+ c.fillStyle=flashDir===1&&flashTimer>0?'rgba(110,231,182,.15)':'rgba(255,255,255,.03)';
1919
+ rr(c,third*2+2,2,third-4,st.h-4,8);c.fill();
1920
+ c.fillStyle='#6ee7b6';c.fillText('+',third*2+third/2,st.h/2);
1921
+
1922
+ // Counter
1923
+ c.fillStyle='#e2e2f0';c.font="700 20px 'JetBrains Mono',monospace";
1924
+ c.fillText(Math.round(displayCount),st.w/2,st.h/2);
1925
+
1926
+ if(st.focused)fr(c,st.w,st.h,10);
1927
+ },
1928
+ };
1929
+ }
1930
+
1931
+ /** 8. Radio Orbit -- 4 options arranged in a circle. Slider picks one. */
1932
+ export function RadioOrbit() {
1933
+ let selectedGlow=new Float32Array(4);
1934
+ const names=['A','B','C','D'],colors=['#ff6b6b','#fbbf24','#6ee7b6','#38bdf8'];
1935
+ const OPTS=['OPTION A','OPTION B','OPTION C','OPTION D']; // const (was 'OPTION '+name concat)
1936
+ return {
1937
+ tick(c,dt,now,st) {
1938
+ const sel=Math.round(st.val*3);
1939
+ const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-12;
1940
+
1941
+ // Center
1942
+ c.fillStyle='rgba(255,255,255,.03)';c.beginPath();c.arc(cx,cy,8,0,PI2);c.fill();
1943
+
1944
+ for(let i=0;i<4;i++){
1945
+ const a=-Math.PI/2+i*Math.PI/2;
1946
+ const ox=cx+Math.cos(a)*R, oy=cy+Math.sin(a)*R;
1947
+ const active=i===sel;
1948
+ selectedGlow[i]=lerp(selectedGlow[i],active?1:0,dt*10);
1949
+
1950
+ // Orbit line -- const white, alpha via globalAlpha
1951
+ c.strokeStyle='#ffffff';c.globalAlpha=.03+selectedGlow[i]*.05;c.lineWidth=1;
1952
+ c.beginPath();c.moveTo(cx,cy);c.lineTo(ox,oy);c.stroke();c.globalAlpha=1;
1953
+
1954
+ // Node
1955
+ const sz=6+selectedGlow[i]*4;
1956
+ c.fillStyle=active?colors[i]:'rgba(255,255,255,.06)';
1957
+ c.beginPath();c.arc(ox,oy,sz,0,PI2);c.fill();
1958
+ if(active){c.strokeStyle=colors[i];c.lineWidth=1;c.beginPath();c.arc(ox,oy,sz+3,0,PI2);c.stroke()}
1959
+
1960
+ c.fillStyle=active?'#fff':'#9999b8';c.font="600 9px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
1961
+ c.fillText(names[i],ox,oy);
1962
+ }
1963
+ lbl(c,OPTS[sel],cx,st.h+10,colors[sel]);
1964
+ if(st.focused)fr(c,st.w,st.h,4);
1965
+ },
1966
+ };
1967
+ }
1968
+
1969
+
1970
+ // ===========================================================
1971
+ // INDICATORS
1972
+ // ===========================================================
1973
+
1974
+ /** 9. Password Strength -- Segmented bar with color progression and label. */
1975
+ export function PasswordStrength() {
1976
+ let segs=[0,0,0,0];
1977
+ const labels=['WEAK','FAIR','GOOD','STRONG'],colors=['#ff6b6b','#fbbf24','#38bdf8','#6ee7b6'];
1978
+ return {
1979
+ tick(c,dt,now,st) {
1980
+ const level=Math.ceil(st.val*4);
1981
+ for(let i=0;i<4;i++) segs[i]=lerp(segs[i],i<level?1:0,dt*10);
1982
+
1983
+ const segW=(st.w-12)/4,segH=8;
1984
+ for(let i=0;i<4;i++){
1985
+ const x=2+i*(segW+2);
1986
+ c.fillStyle=segs[i]>.5?colors[Math.min(level-1,3)]:'rgba(255,255,255,.06)';
1987
+ const w=segW*segs[i];
1988
+ rr(c,x,st.h/2-segH/2,Math.max(2,w),segH,3);c.fill();
1989
+
1990
+ // Empty track
1991
+ c.strokeStyle='rgba(255,255,255,.04)';c.lineWidth=1;
1992
+ rr(c,x,st.h/2-segH/2,segW,segH,3);c.stroke();
1993
+ }
1994
+
1995
+ const idx=clamp(level-1,0,3);
1996
+ lbl(c,level>0?labels[idx]:'NONE',st.w/2,st.h/2+16,level>0?colors[idx]:'#666');
1997
+ if(st.focused)fr(c,st.w,st.h,4);
1998
+ },
1999
+ };
2000
+ }
2001
+
2002
+ /** 10. Water Level -- Animated wave surface inside a container. */
2003
+ export function WaterLevel() {
2004
+ let displayVal=0, wavePhase=0;
2005
+ return {
2006
+ tick(c,dt,now,st) {
2007
+ displayVal=lerp(displayVal,st.val,dt*4);
2008
+ wavePhase+=dt*3;
2009
+ const bx=4,by=4,bw=st.w-8,bh=st.h-8;
2010
+
2011
+ // Container
2012
+ c.strokeStyle='rgba(255,255,255,.1)';c.lineWidth=1.5;rr(c,bx,by,bw,bh,6);c.stroke();
2013
+
2014
+ // Water
2015
+ const waterY=by+bh-(bh*displayVal);
2016
+ c.save();c.beginPath();rr(c,bx+1,by+1,bw-2,bh-2,5);c.clip();
2017
+
2018
+ // Wave surface
2019
+ c.fillStyle=displayVal>.7?'rgba(56,189,248,.3)':displayVal>.3?'rgba(110,231,182,.25)':'rgba(251,191,36,.2)';
2020
+ c.beginPath();c.moveTo(bx,by+bh);
2021
+ for(let x=0;x<=bw;x++){
2022
+ const wave=Math.sin(wavePhase+x*0.08)*3+Math.sin(wavePhase*1.5+x*0.12)*2;
2023
+ c.lineTo(bx+x,waterY+wave);
2024
+ }
2025
+ c.lineTo(bx+bw,by+bh);c.closePath();c.fill();
2026
+
2027
+ // Deeper water
2028
+ c.fillStyle=displayVal>.7?'rgba(56,189,248,.15)':displayVal>.3?'rgba(110,231,182,.1)':'rgba(251,191,36,.08)';
2029
+ c.fillRect(bx,waterY+5,bw,bh);
2030
+
2031
+ c.restore();
2032
+
2033
+ c.fillStyle='#e2e2f0';c.font="700 12px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2034
+ c.fillText(PCT[Math.round(displayVal*100)],st.w/2,st.h/2);
2035
+ if(st.focused)fr(c,st.w,st.h,6);
2036
+ },
2037
+ };
2038
+ }
2039
+
2040
+ /** 11. Heat Map -- 5×3 grid of cells that heat up based on slider. */
2041
+ export function HeatMap({seed=42}={}) {
2042
+ const rng=new Random(seed);
2043
+ const N=15,thresholds=new Float32Array(N);
2044
+ let vals=new Float32Array(N);
2045
+ // Heat-color LUT (rgb quantized by heat): a const lookup + globalAlpha
2046
+ // instead of a per-cell rgb() template every frame.
2047
+ const HEATC=[];
2048
+ for(let i=0;i<=32;i++)HEATC[i]='rgb('+Math.round(100+i/32*155)+','+Math.round(60+i/32*20)+','+Math.round(60-i/32*40)+')';
2049
+ return {
2050
+ init(){for(let i=0;i<N;i++)thresholds[i]=rng.range(0,1)},
2051
+ tick(c,dt,now,st) {
2052
+ const cols=5,rows=3,gap=3;
2053
+ const cw=(st.w-gap*(cols-1))/cols, ch=(st.h-gap*(rows-1))/rows;
2054
+
2055
+ for(let i=0;i<N;i++){
2056
+ const active=st.val>=thresholds[i];
2057
+ vals[i]=lerp(vals[i],active?1:0,dt*6);
2058
+ const col=i%cols,row=(i/cols)|0;
2059
+ const x=col*(cw+gap),y=row*(ch+gap);
2060
+ const heat=vals[i];
2061
+ if(heat>.1){c.fillStyle=HEATC[(heat*32)|0]||HEATC[32];c.globalAlpha=.15+heat*.4;}
2062
+ else{c.fillStyle='rgba(255,255,255,.04)';c.globalAlpha=1;}
2063
+ rr(c,x,y,cw,ch,3);c.fill();
2064
+ c.globalAlpha=1;
2065
+ }
2066
+ lbl(c,PCT[Math.round(st.val*100)],st.w/2,st.h+10);
2067
+ if(st.focused)fr(c,st.w,st.h,3);
2068
+ },
2069
+ };
2070
+ }
2071
+
2072
+
2073
+ // ===========================================================
2074
+ // MOOD
2075
+ // ===========================================================
2076
+
2077
+ /** 12. Day/Night Toggle -- Sun/moon transition with star particles. */
2078
+ export function DayNightToggle({seed=42}={}) {
2079
+ const rng=new Random(seed);
2080
+ // Sky-color LUT (day->night rgb quantized by t): a const lookup, not an
2081
+ // rgb() template per frame. Stars are a fixed pool filled in init.
2082
+ const SKY=[];
2083
+ for(let i=0;i<=32;i++){const u=i/32;SKY[i]='rgb('+Math.round(lerp(135,10,u))+','+Math.round(lerp(206,10,u))+','+Math.round(lerp(250,30,u))+')';}
2084
+ const STARS=30;
2085
+ const star=[];
2086
+ let t=0;
2087
+ return {
2088
+ init(ctx,w,h){for(let i=0;i<STARS;i++)star[i]={x:rng.range(4,w-4),y:rng.range(4,h-4),twinkle:rng.range(0,PI2)}},
2089
+ tick(c,dt,now,st) {
2090
+ t=lerp(t,st.toggled?1:0,dt*6);
2091
+
2092
+ // Sky background (LUT)
2093
+ c.fillStyle=SKY[(t*32)|0]||SKY[32];rr(c,0,0,st.w,st.h,st.h/2);c.fill();
2094
+
2095
+ // Stars (night only) -- const white, per-star alpha via globalAlpha
2096
+ if(t>.3){
2097
+ c.fillStyle='#ffffff';
2098
+ for(let i=0;i<STARS;i++){
2099
+ const s=star[i];
2100
+ const tw=Math.sin(now/400+s.twinkle)*.5+.5;
2101
+ c.globalAlpha=(t-.3)/.7*tw*.6;
2102
+ c.fillRect(s.x,s.y,1,1);
2103
+ }
2104
+ c.globalAlpha=1;
2105
+ }
2106
+
2107
+ // Sun/Moon
2108
+ const orbX=lerp(18,st.w-18,t);
2109
+ if(t<.5){
2110
+ // Sun
2111
+ c.fillStyle='#fbbf24';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2112
+ // Rays -- const color, alpha via globalAlpha
2113
+ c.strokeStyle='#fbbf24';c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
2114
+ for(let i=0;i<8;i++){const a=now/800+i*Math.PI/4;c.beginPath();c.moveTo(orbX+Math.cos(a)*12,st.h/2+Math.sin(a)*12);c.lineTo(orbX+Math.cos(a)*16,st.h/2+Math.sin(a)*16);c.stroke()}
2115
+ c.globalAlpha=1;
2116
+ } else {
2117
+ // Moon
2118
+ c.fillStyle='#d4d4e8';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2119
+ // Crater shadows -- const black, alpha via globalAlpha
2120
+ c.fillStyle='#000000';c.globalAlpha=(t-.5)*2*.15;
2121
+ c.beginPath();c.arc(orbX-3,st.h/2-2,3,0,PI2);c.fill();
2122
+ c.beginPath();c.arc(orbX+4,st.h/2+3,2,0,PI2);c.fill();
2123
+ c.globalAlpha=1;
2124
+ }
2125
+
2126
+ lbl(c,st.toggled?'NIGHT':'DAY',st.w/2,st.h+14,st.toggled?'#9999b8':'#fbbf24');
2127
+ if(st.focused)fr(c,st.w,st.h,st.h/2);
2128
+ },
2129
+ };
2130
+ }
2131
+
2132
+ /** 13. Reaction Picker -- 5 emoji-style circles that inflate on hover region. */
2133
+ export function ReactionPicker() {
2134
+ let sizes=new Float32Array(5), selected=-1;
2135
+ const emojis=['\u{1F610}','\u{1F642}','\u{1F60A}','\u{1F604}','\u{1F929}'],colors=['#9999b8','#fbbf24','#fb923c','#f472b6','#ff6b6b'];
2136
+ const colors30=colors.map((col)=>col+'30'); // active fill (was `${color}30`)
2137
+ const REACT_LABELS=['MEH','OK','NICE','GREAT','LOVE']; // was a per-frame array literal
2138
+ const FONTS=[]; for(let i=0;i<=48;i++)FONTS[i]=i+'px sans-serif'; // emoji font by rounded size
2139
+ return {
2140
+ tick(c,dt,now,st,ptr) {
2141
+ const gap=st.w/5;
2142
+ const hoverIdx=st.hover?clamp(Math.floor(ptr.x/gap),0,4):-1;
2143
+
2144
+ for(let i=0;i<5;i++){
2145
+ const active=i===hoverIdx;
2146
+ sizes[i]=lerp(sizes[i],active?16:10,dt*12);
2147
+ const cx=gap*i+gap/2,cy=st.h/2;
2148
+
2149
+ // Circle
2150
+ c.fillStyle=active?colors30[i]:'rgba(255,255,255,.04)';
2151
+ c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();
2152
+
2153
+ // Emoji face (simplified) -- font from a const-string LUT
2154
+ c.font=FONTS[Math.round(sizes[i]*1.2)]||FONTS[48];c.textAlign='center';c.textBaseline='middle';
2155
+ c.fillText(emojis[i],cx,cy-sizes[i]+10);
2156
+ }
2157
+
2158
+ // Selection line
2159
+ if(hoverIdx>=0){
2160
+ const sx=gap*hoverIdx+gap/2;
2161
+ c.fillStyle=colors[hoverIdx];
2162
+ c.fillRect(sx-10,st.h-4,20,2);
2163
+ }
2164
+
2165
+ lbl(c,hoverIdx>=0?REACT_LABELS[hoverIdx]:'REACT',st.w/2,st.h+10,hoverIdx>=0?colors[hoverIdx]:'#9999b8');
2166
+ if(st.focused)fr(c,st.w,st.h,4);
2167
+ },
2168
+ };
2169
+ }
2170
+
2171
+ /** 14. Notification Bell -- Bell icon with bounce and count badge. */
2172
+ export function NotificationBell() {
2173
+ let count=0, bellAngle=0, bellVel=0, badgeScale=0;
2174
+ let badgeStr='0'; // rebuilt only on click (count change), not per frame
2175
+ return {
2176
+ onClick() {
2177
+ count++;bellVel=6;badgeScale=1.5;badgeStr=count>99?'99+':String(count);
2178
+ },
2179
+ tick(c,dt,now,st) {
2180
+ bellVel+=(0-bellAngle)*20*dt;bellVel*=0.9;bellAngle+=bellVel*dt;
2181
+ badgeScale=lerp(badgeScale,1,dt*8);
2182
+ const cx=st.w/2,cy=st.h/2;
2183
+
2184
+ // Bell body
2185
+ c.save();c.translate(cx,cy-4);c.rotate(bellAngle*0.3);
2186
+ c.fillStyle='#fbbf24';
2187
+ c.beginPath();c.moveTo(-10,0);c.quadraticCurveTo(-12,-14,0,-18);c.quadraticCurveTo(12,-14,10,0);c.lineTo(-10,0);c.fill();
2188
+ // Clapper
2189
+ c.fillStyle='#fbbf24';c.beginPath();c.arc(0,3,3,0,PI2);c.fill();
2190
+ c.restore();
2191
+
2192
+ // Badge
2193
+ if(count>0){
2194
+ const bx=cx+10,by=cy-16;
2195
+ c.save();c.translate(bx,by);c.scale(badgeScale,badgeScale);
2196
+ c.fillStyle='#ff6b6b';c.beginPath();c.arc(0,0,8,0,PI2);c.fill();
2197
+ c.fillStyle='#fff';c.font="700 8px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2198
+ c.fillText(badgeStr,0,0);
2199
+ c.restore();
2200
+ }
2201
+
2202
+ lbl(c,'NOTIFY',cx,st.h+10);
2203
+ if(st.focused)fr(c,st.w,st.h,4);
2204
+ },
2205
+ };
2206
+ }
2207
+
2208
+
2209
+ // ===========================================================
2210
+ // FEEDBACK
2211
+ // ===========================================================
2212
+
2213
+ /** 15. Typewriter Field -- Characters appear one by one with cursor blink. */
2214
+ export function TypewriterField() {
2215
+ const text='Hello World';
2216
+ let charIdx=0, timer=0, cursorBlink=0, typing=false;
2217
+ let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
2218
+ return {
2219
+ onToggle(checked){typing=checked;if(checked){charIdx=0;timer=0}},
2220
+ tick(c,dt,now,st) {
2221
+ cursorBlink=(cursorBlink+dt*3)%2;
2222
+ if(typing&&charIdx<text.length){timer+=dt;if(timer>.08){timer=0;charIdx++}}
2223
+
2224
+ c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
2225
+ c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
2226
+
2227
+ c.fillStyle='#6ee7b6';c.font="500 13px 'JetBrains Mono',monospace";c.textAlign='left';c.textBaseline='middle';
2228
+ // Rebuild the visible substring + its width only when a char is added.
2229
+ if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
2230
+ c.fillText(display,8,st.h/2);
2231
+
2232
+ // Cursor
2233
+ if(cursorBlink<1){
2234
+ c.fillStyle='#6ee7b6';c.fillRect(9+dispW,st.h/2-8,1.5,16);
2235
+ }
2236
+
2237
+ lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?'#6ee7b6':'#8888aa');
2238
+ if(st.focused)fr(c,st.w,st.h,6);
2239
+ },
2240
+ };
2241
+ }
2242
+
2243
+ /** 16. Sound Wave Button -- Oscillating waveform on press, static on idle. */
2244
+ export function SoundWaveBtn() {
2245
+ let intensity=0, phase=0;
2246
+ return {
2247
+ onClick(){intensity=1},
2248
+ tick(c,dt,now,st) {
2249
+ intensity=lerp(intensity,0,dt*2);
2250
+ phase+=dt*12;
2251
+
2252
+ c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,10);c.fill();
2253
+
2254
+ // Waveform
2255
+ const cy=st.h/2, amp=10*intensity;
2256
+ c.strokeStyle=intensity>.1?'#f472b6':'rgba(255,255,255,.08)';c.lineWidth=2;
2257
+ c.beginPath();
2258
+ for(let x=0;x<=st.w;x++){
2259
+ const freq=x*0.1;
2260
+ const y=cy+Math.sin(phase+freq)*amp*Math.sin(x/st.w*Math.PI);
2261
+ x===0?c.moveTo(x,y):c.lineTo(x,y);
2262
+ }
2263
+ c.stroke();
2264
+
2265
+ // Flatline when idle
2266
+ if(intensity<.05){
2267
+ c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;
2268
+ c.beginPath();c.moveTo(10,cy);c.lineTo(st.w-10,cy);c.stroke();
2269
+ }
2270
+
2271
+ c.fillStyle=intensity>.1?'#f472b6':'#9999b8';c.font="600 11px 'Space Grotesk',sans-serif";c.textAlign='center';c.textBaseline='middle';
2272
+ c.fillText(intensity>.1?'\u25CF REC':'RECORD',st.w/2,st.h/2);
2273
+ if(st.focused)fr(c,st.w,st.h,10);
2274
+ },
2275
+ };
2276
+ }
2277
+
2278
+ /** 17. Upload Progress -- File icon fills from bottom as slider increases. */
2279
+ export function UploadProgress() {
2280
+ let displayVal=0;
2281
+ return {
2282
+ tick(c,dt,now,st) {
2283
+ displayVal=lerp(displayVal,st.val,dt*6);
2284
+ const cx=st.w/2,fw=30,fh=38,fx=cx-fw/2,fy=(st.h-fh)/2;
2285
+
2286
+ // File icon shape
2287
+ const fold=8;
2288
+ c.strokeStyle='rgba(255,255,255,.15)';c.lineWidth=1.5;
2289
+ c.beginPath();c.moveTo(fx,fy);c.lineTo(fx+fw-fold,fy);c.lineTo(fx+fw,fy+fold);c.lineTo(fx+fw,fy+fh);c.lineTo(fx,fy+fh);c.closePath();c.stroke();
2290
+ // Fold line
2291
+ c.beginPath();c.moveTo(fx+fw-fold,fy);c.lineTo(fx+fw-fold,fy+fold);c.lineTo(fx+fw,fy+fold);c.stroke();
2292
+
2293
+ // Fill
2294
+ const fillH=fh*displayVal;
2295
+ const col=displayVal>=1?'#6ee7b6':'#38bdf8';
2296
+ c.fillStyle=displayVal>=1?'#6ee7b630':'#38bdf830'; // was `${col}30`
2297
+ c.save();c.beginPath();c.rect(fx+1,fy+fh-fillH,fw-1,fillH);c.clip();
2298
+ c.beginPath();c.moveTo(fx,fy);c.lineTo(fx+fw-fold,fy);c.lineTo(fx+fw,fy+fold);c.lineTo(fx+fw,fy+fh);c.lineTo(fx,fy+fh);c.closePath();c.fill();
2299
+ c.restore();
2300
+
2301
+ // Arrow or checkmark
2302
+ c.strokeStyle=col;c.lineWidth=2;c.lineCap='round';
2303
+ if(displayVal>=.99){
2304
+ c.beginPath();c.moveTo(cx-5,fy+fh/2);c.lineTo(cx-1,fy+fh/2+5);c.lineTo(cx+6,fy+fh/2-4);c.stroke();
2305
+ } else {
2306
+ c.beginPath();c.moveTo(cx,fy+fh/2+5);c.lineTo(cx,fy+fh/2-5);c.stroke();
2307
+ c.beginPath();c.moveTo(cx-4,fy+fh/2-1);c.lineTo(cx,fy+fh/2-5);c.lineTo(cx+4,fy+fh/2-1);c.stroke();
2308
+ }
2309
+ c.lineCap='butt';
2310
+
2311
+ lbl(c,displayVal>=.99?'DONE':PCT[Math.round(displayVal*100)],cx,st.h+10,displayVal>=.99?'#6ee7b6':'#9999b8');
2312
+ if(st.focused)fr(c,st.w,st.h,4);
2313
+ },
2314
+ };
2315
+ }
2316
+
2317
+
2318
+ // ===========================================================
2319
+ // FUN
2320
+ // ===========================================================
2321
+
2322
+ /** 18. Scratch Reveal -- Drag to erase a mask and reveal the prize beneath. */
2323
+ export function ScratchReveal({seed=42}={}) {
2324
+ const rng=new Random(seed);
2325
+ // Fixed scratch pool (holes persist). Once all SCR slots are used the scratch
2326
+ // stops adding holes -- existing marks are never overwritten, so earlier
2327
+ // scratches never "heal". Spawn position derives from st.w via lastW.
2328
+ const SCR=64;
2329
+ const scr=[];
2330
+ for(let i=0;i<SCR;i++)scr[i]={x:0,y:0,r:0,live:false};
2331
+ let revealed=0, lastW=200;
2332
+ function spawn(val){
2333
+ for(let i=0;i<SCR;i++){
2334
+ const s=scr[i];
2335
+ if(!s.live){ s.x=val*lastW; s.y=rng.range(2,26); s.r=rng.range(6,14); s.live=true; return; }
2336
+ }
2337
+ }
2338
+ return {
2339
+ onDrag(val,vx) {
2340
+ if(Math.abs(vx)>0.5){
2341
+ for(let i=0;i<3;i++)spawn(val);
2342
+ revealed=Math.min(1,revealed+0.015);
2343
+ }
2344
+ },
2345
+ tick(c,dt,now,st) {
2346
+ lastW=st.w;
2347
+ // Prize background
2348
+ c.fillStyle='rgba(110,231,182,.08)';rr(c,0,0,st.w,st.h,8);c.fill();
2349
+
2350
+ // Prize text -- const color, alpha via globalAlpha
2351
+ c.fillStyle='#6ee7b6';c.globalAlpha=.1+revealed*.6;c.font="700 14px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2352
+ c.fillText(revealed>.6?'\u{1F389} WINNER!':'? ? ?',st.w/2,st.h/2);
2353
+ c.globalAlpha=1;
2354
+
2355
+ // Scratch mask (gets holes)
2356
+ if(revealed<.95){
2357
+ c.fillStyle='rgba(40,40,60,.85)';rr(c,0,0,st.w,st.h,8);c.fill();
2358
+
2359
+ // Cut holes -- fixed ring, indexed loop
2360
+ c.globalCompositeOperation='destination-out';
2361
+ for(let i=0;i<SCR;i++){
2362
+ const s=scr[i];
2363
+ if(!s.live)continue;
2364
+ c.beginPath();c.arc(s.x,s.y,s.r,0,PI2);c.fill();
2365
+ }
2366
+ c.globalCompositeOperation='source-over';
2367
+
2368
+ // Shimmer line
2369
+ c.fillStyle='rgba(255,255,255,.04)';
2370
+ const shimX=(now/20)%st.w;
2371
+ c.fillRect(shimX-2,0,4,st.h);
2372
+ }
2373
+
2374
+ lbl(c,revealed>.6?'REVEALED!':'DRAG TO SCRATCH',st.w/2,st.h+10,revealed>.6?'#6ee7b6':'#9999b8');
2375
+ if(st.focused)fr(c,st.w,st.h,8);
2376
+ },
2377
+ };
2378
+ }
2379
+
2380
+ /** 19. Timer Countdown -- Circular countdown timer. Toggle starts/stops. */
2381
+ export function TimerCountdown() {
2382
+ let timeLeft=10, running=false, flashAlpha=0;
2383
+ let timeStr='10.0s', lastTenths=-1; // rebuilt at ~10 Hz, not per frame
2384
+ return {
2385
+ onToggle(checked) { running=checked; if(checked)timeLeft=10; },
2386
+ tick(c,dt,now,st) {
2387
+ if(running&&timeLeft>0) timeLeft=Math.max(0,timeLeft-dt);
2388
+ if(timeLeft<=0&&running) flashAlpha=Math.sin(now/150)*.5+.5;
2389
+ else flashAlpha=0;
2390
+
2391
+ const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-4;
2392
+
2393
+ // Flash bg when done
2394
+ if(flashAlpha>0){c.fillStyle='#ff6464';c.globalAlpha=flashAlpha*.1;c.beginPath();c.arc(cx,cy,R+4,0,PI2);c.fill();c.globalAlpha=1;}
2395
+
2396
+ // Background ring
2397
+ c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=4;c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
2398
+
2399
+ // Progress arc
2400
+ const progress=timeLeft/10;
2401
+ const col=timeLeft>3?'#6ee7b6':timeLeft>1?'#fbbf24':'#ff6b6b';
2402
+ c.strokeStyle=col;c.lineWidth=4;
2403
+ c.beginPath();c.arc(cx,cy,R,-Math.PI/2,-Math.PI/2+progress*PI2,false);c.stroke();
2404
+
2405
+ // Time text
2406
+ const tenths=Math.round(timeLeft*10);
2407
+ if(tenths!==lastTenths){lastTenths=tenths;timeStr=(tenths/10).toFixed(1)+'s';}
2408
+ c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2409
+ c.fillText(timeStr,cx,cy);
2410
+
2411
+ lbl(c,running?(timeLeft>0?'RUNNING':'TIME UP!'):'TOGGLE TO START',cx,st.h+10,running?col:'#8888aa');
2412
+ if(st.focused)fr(c,st.w,st.h,R);
2413
+ },
2414
+ };
2415
+ }
2416
+
2417
+ /** 20. Pull Refresh -- Drag down to charge, release to spin. */
2418
+ export function PullRefresh() {
2419
+ let pullAmt=0, spinning=false, spinAngle=0, spinTimer=0;
2420
+ return {
2421
+ onDrag(val) { if(!spinning) pullAmt=val; },
2422
+ onToggle(checked) {
2423
+ if(checked&&pullAmt>.6){ spinning=true;spinTimer=2; }
2424
+ pullAmt=0;
2425
+ },
2426
+ tick(c,dt,now,st) {
2427
+ if(spinning){spinAngle+=dt*12;spinTimer-=dt;if(spinTimer<=0){spinning=false;spinAngle=0}}
2428
+ const cx=st.w/2,cy=st.h/2;
2429
+
2430
+ // Container
2431
+ c.fillStyle='rgba(255,255,255,.03)';c.beginPath();c.arc(cx,cy,24,0,PI2);c.fill();
2432
+
2433
+ // Pull indicator or spinner
2434
+ if(spinning){
2435
+ // Spinner arcs
2436
+ for(let i=0;i<3;i++){
2437
+ const a=spinAngle+i*PI2/3;
2438
+ c.strokeStyle='#38bdf8';c.globalAlpha=.3+i*.2;c.lineWidth=3;
2439
+ c.beginPath();c.arc(cx,cy,16,a,a+.8);c.stroke();
2440
+ }
2441
+ c.globalAlpha=1;
2442
+ lbl(c,'LOADING...',cx,st.h+10,'#38bdf8');
2443
+ } else {
2444
+ // Arrow that stretches with pull
2445
+ const stretch=pullAmt*14;
2446
+ c.strokeStyle=pullAmt>.6?'#6ee7b6':'rgba(255,255,255,.15)';c.lineWidth=2;c.lineCap='round';
2447
+ c.beginPath();c.moveTo(cx,cy-8-stretch);c.lineTo(cx,cy+4);c.stroke();
2448
+ c.beginPath();c.moveTo(cx-5,cy+0);c.lineTo(cx,cy+4);c.lineTo(cx+5,cy+0);c.stroke();
2449
+ c.lineCap='butt';
2450
+
2451
+ // Fill ring
2452
+ if(pullAmt>0){
2453
+ c.strokeStyle=pullAmt>.6?'#6ee7b6':'rgba(255,255,255,.1)';c.lineWidth=2;
2454
+ c.beginPath();c.arc(cx,cy,20,-Math.PI/2,-Math.PI/2+pullAmt*PI2);c.stroke();
2455
+ }
2456
+ lbl(c,pullAmt>.6?'RELEASE!':'DRAG SLIDER',cx,st.h+10,pullAmt>.6?'#6ee7b6':'#9999b8');
2457
+ }
2458
+ if(st.focused)fr(c,st.w,st.h,24);
2459
+ },
2460
+ };
2461
+ }
2462
+
2463
+
2464
+ // ===========================================================
2465
+ // BARREL OBJECTS (back-compat)
2466
+ // ===========================================================
2467
+
2468
+ export const UIFXRecipes = {
2469
+ SwarmToggle, LiquidToggle, NeonPulseToggle,
2470
+ MagneticButton, ShatterButton, ConfettiButton, GlitchButton,
2471
+ SparkSlider, CosmicSlider, LaserSlider,
2472
+ };
2473
+
2474
+ export const UIFXRecipes2 = {
2475
+ PendulumToggle, CircuitToggle, LightningToggle, DNAToggle,
2476
+ HeartbeatButton, BreathingButton, InkSplashButton, PixelDissolveButton, FireworkButton,
2477
+ AuroraSlider, WaveSlider, ElasticBandSlider, GravitySlider,
2478
+ OrbitLoader, HelixLoader,
2479
+ RippleCheck, MorphCheck,
2480
+ FlameCounter, GlitchCounter,
2481
+ BubbleRating,
2482
+ };
2483
+
2484
+ export const UIFXRecipes3 = {
2485
+ VolumeKnob, CompassKnob,
2486
+ RingProgress, BatteryGauge, SignalMeter,
2487
+ PillTabs, Stepper, RadioOrbit,
2488
+ PasswordStrength, WaterLevel, HeatMap,
2489
+ DayNightToggle, ReactionPicker, NotificationBell,
2490
+ TypewriterField, SoundWaveBtn, UploadProgress,
2491
+ ScratchReveal, TimerCountdown, PullRefresh,
2492
+ };
2493
+
2494
+
2495
+ // ===========================================================
2496
+ // DEFAULT EXPORT -- combined all-50 namespace
2497
+ // ===========================================================
2498
+
2499
+ export default {
2500
+ SwarmToggle,
2501
+ LiquidToggle,
2502
+ NeonPulseToggle,
2503
+ MagneticButton,
2504
+ ShatterButton,
2505
+ ConfettiButton,
2506
+ GlitchButton,
2507
+ SparkSlider,
2508
+ CosmicSlider,
2509
+ LaserSlider,
2510
+ PendulumToggle,
2511
+ CircuitToggle,
2512
+ LightningToggle,
2513
+ DNAToggle,
2514
+ HeartbeatButton,
2515
+ BreathingButton,
2516
+ InkSplashButton,
2517
+ PixelDissolveButton,
2518
+ FireworkButton,
2519
+ AuroraSlider,
2520
+ WaveSlider,
2521
+ ElasticBandSlider,
2522
+ GravitySlider,
2523
+ OrbitLoader,
2524
+ HelixLoader,
2525
+ RippleCheck,
2526
+ MorphCheck,
2527
+ FlameCounter,
2528
+ GlitchCounter,
2529
+ BubbleRating,
2530
+ VolumeKnob,
2531
+ CompassKnob,
2532
+ RingProgress,
2533
+ BatteryGauge,
2534
+ SignalMeter,
2535
+ PillTabs,
2536
+ Stepper,
2537
+ RadioOrbit,
2538
+ PasswordStrength,
2539
+ WaterLevel,
2540
+ HeatMap,
2541
+ DayNightToggle,
2542
+ ReactionPicker,
2543
+ NotificationBell,
2544
+ TypewriterField,
2545
+ SoundWaveBtn,
2546
+ UploadProgress,
2547
+ ScratchReveal,
2548
+ TimerCountdown,
2549
+ PullRefresh,
2550
+ };
2551
+
2552
+
2553
+ // ===========================================================
2554
+ // RECIPE REGISTRY
2555
+ // ===========================================================
2556
+
2557
+ /**
2558
+ * Every recipe factory keyed by id, for data-driven pickers (demo dropdowns,
2559
+ * random selection, config files). A null-prototype object so keys never
2560
+ * collide with Object.prototype.
2561
+ */
2562
+ export const RECIPES = Object.assign(Object.create(null), {
2563
+ swarmToggle: SwarmToggle,
2564
+ liquidToggle: LiquidToggle,
2565
+ neonPulseToggle: NeonPulseToggle,
2566
+ magneticButton: MagneticButton,
2567
+ shatterButton: ShatterButton,
2568
+ confettiButton: ConfettiButton,
2569
+ glitchButton: GlitchButton,
2570
+ sparkSlider: SparkSlider,
2571
+ cosmicSlider: CosmicSlider,
2572
+ laserSlider: LaserSlider,
2573
+ pendulumToggle: PendulumToggle,
2574
+ circuitToggle: CircuitToggle,
2575
+ lightningToggle: LightningToggle,
2576
+ dnaToggle: DNAToggle,
2577
+ heartbeatButton: HeartbeatButton,
2578
+ breathingButton: BreathingButton,
2579
+ inkSplashButton: InkSplashButton,
2580
+ pixelDissolveButton: PixelDissolveButton,
2581
+ fireworkButton: FireworkButton,
2582
+ auroraSlider: AuroraSlider,
2583
+ waveSlider: WaveSlider,
2584
+ elasticBandSlider: ElasticBandSlider,
2585
+ gravitySlider: GravitySlider,
2586
+ orbitLoader: OrbitLoader,
2587
+ helixLoader: HelixLoader,
2588
+ rippleCheck: RippleCheck,
2589
+ morphCheck: MorphCheck,
2590
+ flameCounter: FlameCounter,
2591
+ glitchCounter: GlitchCounter,
2592
+ bubbleRating: BubbleRating,
2593
+ volumeKnob: VolumeKnob,
2594
+ compassKnob: CompassKnob,
2595
+ ringProgress: RingProgress,
2596
+ batteryGauge: BatteryGauge,
2597
+ signalMeter: SignalMeter,
2598
+ pillTabs: PillTabs,
2599
+ stepper: Stepper,
2600
+ radioOrbit: RadioOrbit,
2601
+ passwordStrength: PasswordStrength,
2602
+ waterLevel: WaterLevel,
2603
+ heatMap: HeatMap,
2604
+ dayNightToggle: DayNightToggle,
2605
+ reactionPicker: ReactionPicker,
2606
+ notificationBell: NotificationBell,
2607
+ typewriterField: TypewriterField,
2608
+ soundWaveBtn: SoundWaveBtn,
2609
+ uploadProgress: UploadProgress,
2610
+ scratchReveal: ScratchReveal,
2611
+ timerCountdown: TimerCountdown,
2612
+ pullRefresh: PullRefresh,
2613
+ });
2614
+
2615
+ /**
2616
+ * Display + capability metadata for every built-in recipe, so a host can build
2617
+ * a picker without hardcoding the list. A live array: registerRecipe() updates
2618
+ * it, so existing pickers keep working.
2619
+ *
2620
+ * type 'toggle' | 'button' | 'slider' -- the native element it mounts on
2621
+ * family display grouping (Toggles, Buttons, Sliders, Knobs, ...)
2622
+ * themeable accepts { colors, theme } (false for all until a later pass)
2623
+ * motionSafe inherently-calm under prefers-reduced-motion (false for all)
2624
+ */
2625
+ export const RECIPE_META = [
2626
+ { id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2627
+ { id: 'liquidToggle', name: 'Liquid Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2628
+ { id: 'neonPulseToggle', name: 'Neon Pulse Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2629
+ { id: 'magneticButton', name: 'Magnetic Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2630
+ { id: 'shatterButton', name: 'Shatter Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2631
+ { id: 'confettiButton', name: 'Confetti Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2632
+ { id: 'glitchButton', name: 'Glitch Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2633
+ { id: 'sparkSlider', name: 'Spark Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2634
+ { id: 'cosmicSlider', name: 'Cosmic Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2635
+ { id: 'laserSlider', name: 'Laser Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2636
+ { id: 'pendulumToggle', name: 'Pendulum Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2637
+ { id: 'circuitToggle', name: 'Circuit Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2638
+ { id: 'lightningToggle', name: 'Lightning Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2639
+ { id: 'dnaToggle', name: 'Dna Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2640
+ { id: 'heartbeatButton', name: 'Heartbeat Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2641
+ { id: 'breathingButton', name: 'Breathing Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2642
+ { id: 'inkSplashButton', name: 'Ink Splash Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2643
+ { id: 'pixelDissolveButton', name: 'Pixel Dissolve Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2644
+ { id: 'fireworkButton', name: 'Firework Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2645
+ { id: 'auroraSlider', name: 'Aurora Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2646
+ { id: 'waveSlider', name: 'Wave Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2647
+ { id: 'elasticBandSlider', name: 'Elastic Band Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2648
+ { id: 'gravitySlider', name: 'Gravity Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2649
+ { id: 'orbitLoader', name: 'Orbit Loader', type: 'toggle', family: 'Loaders', themeable: false, motionSafe: false },
2650
+ { id: 'helixLoader', name: 'Helix Loader', type: 'toggle', family: 'Loaders', themeable: false, motionSafe: false },
2651
+ { id: 'rippleCheck', name: 'Ripple Check', type: 'toggle', family: 'Checkboxes', themeable: false, motionSafe: false },
2652
+ { id: 'morphCheck', name: 'Morph Check', type: 'toggle', family: 'Checkboxes', themeable: false, motionSafe: false },
2653
+ { id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: false, motionSafe: false },
2654
+ { id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: false, motionSafe: false },
2655
+ { id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: false, motionSafe: false },
2656
+ { id: 'volumeKnob', name: 'Volume Knob', type: 'slider', family: 'Knobs', themeable: false, motionSafe: false },
2657
+ { id: 'compassKnob', name: 'Compass Knob', type: 'slider', family: 'Knobs', themeable: false, motionSafe: false },
2658
+ { id: 'ringProgress', name: 'Ring Progress', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
2659
+ { id: 'batteryGauge', name: 'Battery Gauge', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
2660
+ { id: 'signalMeter', name: 'Signal Meter', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
2661
+ { id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: false, motionSafe: false },
2662
+ { id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: false, motionSafe: false },
2663
+ { id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: false, motionSafe: false },
2664
+ { id: 'passwordStrength', name: 'Password Strength', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
2665
+ { id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
2666
+ { id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
2667
+ { id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: false, motionSafe: false },
2668
+ { id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: false, motionSafe: false },
2669
+ { id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: false, motionSafe: false },
2670
+ { id: 'typewriterField', name: 'Typewriter Field', type: 'toggle', family: 'Feedback', themeable: false, motionSafe: false },
2671
+ { id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: false, motionSafe: false },
2672
+ { id: 'uploadProgress', name: 'Upload Progress', type: 'slider', family: 'Feedback', themeable: false, motionSafe: false },
2673
+ { id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: false, motionSafe: false },
2674
+ { id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: false, motionSafe: false },
2675
+ { id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: false, motionSafe: false },
2676
+ ];
2677
+
2678
+ /** Names of every built-in recipe (the keys of RECIPES at load time). */
2679
+ export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
2680
+
2681
+ /**
2682
+ * Register a custom recipe, or override a built-in. Instantly usable via
2683
+ * RECIPES[id] and reflected in RECIPE_META so existing pickers keep working.
2684
+ *
2685
+ * @param {string} id the RECIPES key
2686
+ * @param {Function} factory a recipe factory: (opts) => Recipe
2687
+ * @param {{ name?: string, type?: string, family?: string, themeable?: boolean, motionSafe?: boolean }} [meta]
2688
+ * Omitted fields fall back to the existing entry (when overriding), then to a
2689
+ * de-camelCased name, family 'custom', and false flags.
2690
+ * @returns {Function} the registered factory
2691
+ */
2692
+ export function registerRecipe(id, factory, meta) {
2693
+ if (typeof id !== 'string' || id.length === 0) {
2694
+ throw new TypeError('registerRecipe: id must be a non-empty string');
2695
+ }
2696
+ if (typeof factory !== 'function') {
2697
+ throw new TypeError('registerRecipe: factory must be a function');
2698
+ }
2699
+
2700
+ const idx = RECIPE_META.findIndex((m) => m.id === id);
2701
+ const prev = idx >= 0 ? RECIPE_META[idx] : null;
2702
+ const type = (meta && meta.type) || (prev && prev.type) || undefined;
2703
+ // A recipe's type selects its native element; a typeless recipe cannot be
2704
+ // mounted. Reject it at registration (fail closed) -- checked BEFORE any
2705
+ // mutation, so a rejected call leaves RECIPES/RECIPE_META untouched.
2706
+ if (type !== 'toggle' && type !== 'button' && type !== 'slider') {
2707
+ throw new TypeError('registerRecipe: type must be "toggle", "button", or "slider"');
2708
+ }
2709
+
2710
+ RECIPES[id] = factory;
2711
+ const entry = {
2712
+ id,
2713
+ name: (meta && meta.name) || (prev && prev.name)
2714
+ || id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^[a-z]/, (c) => c.toUpperCase()),
2715
+ type,
2716
+ family: (meta && meta.family) || (prev && prev.family) || 'custom',
2717
+ themeable: meta && 'themeable' in meta ? !!meta.themeable : (prev ? prev.themeable : false),
2718
+ motionSafe: meta && 'motionSafe' in meta ? !!meta.motionSafe : (prev ? prev.motionSafe : false),
2719
+ };
2720
+ if (idx >= 0) RECIPE_META[idx] = entry; else RECIPE_META.push(entry);
2721
+ return factory;
2722
+ }
2723
+
2724
+ /**
2725
+ * Levenshtein edit distance (module-local, for the did-you-mean hint). Bounded
2726
+ * by the short recipe-id strings; never on a hot path.
2727
+ */
2728
+ function editDistance(a, b) {
2729
+ const m = a.length, n = b.length;
2730
+ if (m === 0) return n;
2731
+ if (n === 0) return m;
2732
+ let prev = new Array(n + 1);
2733
+ let curr = new Array(n + 1);
2734
+ for (let j = 0; j <= n; j++) prev[j] = j;
2735
+ for (let i = 1; i <= m; i++) {
2736
+ curr[0] = i;
2737
+ for (let j = 1; j <= n; j++) {
2738
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
2739
+ const del = prev[j] + 1;
2740
+ const ins = curr[j - 1] + 1;
2741
+ const sub = prev[j - 1] + cost;
2742
+ curr[j] = del < ins ? (del < sub ? del : sub) : (ins < sub ? ins : sub);
2743
+ }
2744
+ const tmp = prev; prev = curr; curr = tmp;
2745
+ }
2746
+ return prev[n];
2747
+ }
2748
+
2749
+ /** Nearest known recipe id to a mistyped one, for the did-you-mean hint. */
2750
+ function nearestRecipe(id) {
2751
+ let best = RECIPE_NAMES[0], bestD = Infinity;
2752
+ for (let i = 0; i < RECIPE_NAMES.length; i++) {
2753
+ const d = editDistance(id, RECIPE_NAMES[i]);
2754
+ if (d < bestD) { bestD = d; best = RECIPE_NAMES[i]; }
2755
+ }
2756
+ return best;
2757
+ }
2758
+
2759
+ /**
2760
+ * Resolve a recipe id to its factory + declared type and mount it via
2761
+ * mountUIFX. Fail closed:
2762
+ * - unknown id -> throw naming the nearest known id (did-you-mean).
2763
+ * - options.type present and != the recipe's declared type -> throw.
2764
+ * options.type is consumed here, never forwarded as a mount option.
2765
+ *
2766
+ * @param {HTMLElement} container
2767
+ * @param {string} id
2768
+ * @param {Object} [options]
2769
+ * @returns {{ el: HTMLElement, destroy: Function }}
2770
+ */
2771
+ export function mountRecipe(container, id, options) {
2772
+ const factory = RECIPES[id];
2773
+ if (typeof factory !== 'function') {
2774
+ // did-you-mean only makes sense for a string typo; a non-string id
2775
+ // (null, a number) gets the clean message without entering the helper
2776
+ // (nearestRecipe -> editDistance reads .length, which would throw on null).
2777
+ const hint = typeof id === 'string'
2778
+ ? ' Did you mean "' + nearestRecipe(id) + '"?'
2779
+ : '';
2780
+ throw new Error('mountRecipe: unknown recipe "' + id + '".' + hint);
2781
+ }
2782
+ const meta = RECIPE_META.find((m) => m.id === id) || null;
2783
+ const type = meta ? meta.type : undefined;
2784
+ if (options && 'type' in options && options.type !== type) {
2785
+ throw new Error(
2786
+ 'mountRecipe: recipe "' + id + '" mounts as "' + type +
2787
+ '", not "' + options.type + '"'
2788
+ );
2789
+ }
2790
+ let mountOptions = options;
2791
+ if (options && 'type' in options) {
2792
+ mountOptions = {};
2793
+ for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
2794
+ }
2795
+ return mountUIFX(container, type, factory, mountOptions);
2796
+ }
2797
+