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