@zakkster/lite-ui-fx 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/UIFXRecipes.js CHANGED
@@ -34,6 +34,20 @@ import { Random } from '@zakkster/lite-random';
34
34
  import { mountUIFX, UIType } from './UIFXController.js';
35
35
 
36
36
 
37
+ // ---------------------------------------------------------
38
+ // SHARED COLD CONSTANTS (U3) -- built once at module load, never per frame
39
+ // ---------------------------------------------------------
40
+
41
+ // Focus-ring dash patterns: one shared array each, so a focused frame's
42
+ // setLineDash() reuses them instead of allocating a fresh array (U-03).
43
+ const DASH_FOCUS = [4, 3];
44
+ const DASH_NONE = [];
45
+
46
+ // Percentage readouts '0%'..'100%', precomputed so a value label is a const
47
+ // lookup -- PCT[Math.round(st.val * 100)] -- not a per-frame template build.
48
+ const PCT = Array.from({ length: 101 }, (_, i) => i + '%');
49
+
50
+
37
51
  // ---------------------------------------------------------
38
52
  // SHARED HELPERS
39
53
  // ---------------------------------------------------------
@@ -57,31 +71,32 @@ function roundRect(ctx, x, y, w, h, r) {
57
71
  function drawFocusRing(ctx, w, h, r) {
58
72
  ctx.strokeStyle = 'rgba(110,231,182,.6)';
59
73
  ctx.lineWidth = 2;
60
- ctx.setLineDash([4, 3]);
74
+ ctx.setLineDash(DASH_FOCUS);
61
75
  roundRect(ctx, -2, -2, w + 4, h + 4, r + 2);
62
76
  ctx.stroke();
63
- ctx.setLineDash([]);
77
+ ctx.setLineDash(DASH_NONE);
64
78
  }
65
79
 
66
- /** Draw a state label so the user knows the current state. */
67
- function drawStateLabel(ctx, text, x, y, color) {
80
+ /** Draw a state label so the user knows the current state. font defaults to the
81
+ * historical literal, so an omitted font renders exactly as before. */
82
+ function drawStateLabel(ctx, text, x, y, color, font) {
68
83
  ctx.fillStyle = color;
69
- ctx.font = "500 9px 'JetBrains Mono',monospace";
84
+ ctx.font = font || "500 9px 'JetBrains Mono',monospace";
70
85
  ctx.textAlign = 'center';
71
86
  ctx.fillText(text, x, y);
72
87
  }
73
88
 
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);
89
+ function label(ctx, text, x, y, color = '#9999b8', font) {
90
+ ctx.fillStyle = color; ctx.font = font || "500 9px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.fillText(text, x, y);
76
91
  }
77
92
  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([]);
93
+ ctx.strokeStyle = 'rgba(110,231,182,.5)'; ctx.lineWidth = 2; ctx.setLineDash(DASH_FOCUS);
94
+ roundRect(ctx, -2, -2, w + 4, h + 4, r + 2); ctx.stroke(); ctx.setLineDash(DASH_NONE);
80
95
  }
81
96
 
82
97
  function rr(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.lineTo(x+w-r,y);c.arcTo(x+w,y,x+w,y+r,r);c.lineTo(x+w,y+h-r);c.arcTo(x+w,y+h,x+w-r,y+h,r);c.lineTo(x+r,y+h);c.arcTo(x,y+h,x,y+h-r,r);c.lineTo(x,y+r);c.arcTo(x,y,x+r,y,r);c.closePath()}
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([])}
98
+ function lbl(c,t,x,y,col='#9999b8',font){c.fillStyle=col;c.font=font||"500 9px 'JetBrains Mono',monospace";c.textAlign='center';c.fillText(t,x,y)}
99
+ function fr(c,w,h,r){c.strokeStyle='rgba(110,231,182,.5)';c.lineWidth=2;c.setLineDash(DASH_FOCUS);rr(c,-2,-2,w+4,h+4,r+2);c.stroke();c.setLineDash(DASH_NONE)}
85
100
  const PI2=Math.PI*2;
86
101
 
87
102
 
@@ -198,15 +213,28 @@ export function LiquidToggle() {
198
213
  * Neon Pulse Toggle -- Expanding shockwave rings on toggle.
199
214
  */
200
215
  export function NeonPulseToggle() {
201
- let rings = [];
216
+ // Fixed ring pool (dead when life <= 0) -- no push/splice on the hot path.
217
+ const RINGS = 8;
218
+ const ringR = new Float64Array(RINGS);
219
+ const ringLife = new Float64Array(RINGS);
220
+ const ringX = new Float64Array(RINGS);
202
221
  let knobX = 18;
222
+ let lastW = 64; // last track width, so onToggle (which gets no state) can
223
+ // place a ring at the knob position derived from st.w (U-05).
224
+
225
+ function spawnRing(x) {
226
+ for (let i = 0; i < RINGS; i++) {
227
+ if (ringLife[i] <= 0) { ringR[i] = 14; ringLife[i] = 1; ringX[i] = x; return; }
228
+ }
229
+ }
203
230
 
204
231
  return {
205
232
  onToggle(checked) {
206
- rings.push({ r: 14, life: 1, x: checked ? 46 : 18 });
233
+ spawnRing(checked ? lastW - 18 : 18);
207
234
  },
208
235
 
209
236
  tick(ctx, dt, now, st) {
237
+ lastW = st.w;
210
238
  knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 15);
211
239
 
212
240
  // Track
@@ -214,18 +242,21 @@ export function NeonPulseToggle() {
214
242
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
215
243
  ctx.fill();
216
244
 
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;
245
+ // Shockwave rings -- const stroke color, per-ring alpha via globalAlpha
246
+ // (was `rgba(56,189,248,${life})` built per ring per frame).
247
+ ctx.strokeStyle = '#38bdf8';
248
+ ctx.lineWidth = 2;
249
+ for (let i = 0; i < RINGS; i++) {
250
+ if (ringLife[i] <= 0) continue;
251
+ ringR[i] += 100 * dt;
252
+ ringLife[i] -= 2 * dt;
253
+ if (ringLife[i] <= 0) continue;
254
+ ctx.globalAlpha = ringLife[i];
225
255
  ctx.beginPath();
226
- ctx.arc(r.x, st.h / 2, r.r, 0, Math.PI * 2);
256
+ ctx.arc(ringX[i], st.h / 2, ringR[i], 0, Math.PI * 2);
227
257
  ctx.stroke();
228
258
  }
259
+ ctx.globalAlpha = 1;
229
260
 
230
261
  // Knob
231
262
  ctx.fillStyle = st.toggled ? '#38bdf8' : '#9999b8';
@@ -295,7 +326,10 @@ export function MagneticButton({ maxPull = 15 } = {}) {
295
326
  */
296
327
  export function ShatterButton({ seed = 42 } = {}) {
297
328
  const rng = new Random(seed);
298
- let shards = [];
329
+ // Fixed shard pool (live flag) -- no push/splice on the hot path.
330
+ const SHARDS = 32;
331
+ const shard = [];
332
+ for (let i = 0; i < SHARDS; i++) shard[i] = { x: 0, y: 0, vx: 0, vy: 0, r: 0, vr: 0, sz: 0, live: false };
299
333
  let visible = true;
300
334
  let respawnTimer = 0;
301
335
 
@@ -304,13 +338,15 @@ export function ShatterButton({ seed = 42 } = {}) {
304
338
  if (!visible) return;
305
339
  visible = false;
306
340
  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
- });
341
+ let n = 0;
342
+ for (let i = 0; i < SHARDS && n < 30; i++) {
343
+ const s = shard[i];
344
+ if (s.live) continue;
345
+ s.x = rng.range(0, st.w); s.y = rng.range(0, st.h);
346
+ s.vx = rng.range(-150, 150); s.vy = rng.range(-150, 50);
347
+ s.r = rng.range(0, 6); s.vr = rng.range(-5, 5);
348
+ s.sz = rng.range(4, 12); s.live = true;
349
+ n++;
314
350
  }
315
351
  },
316
352
 
@@ -332,10 +368,11 @@ export function ShatterButton({ seed = 42 } = {}) {
332
368
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
333
369
  }
334
370
 
335
- // Falling shards
371
+ // Falling shards -- fixed pool
336
372
  ctx.fillStyle = 'rgba(255,255,255,.5)';
337
- for (let i = shards.length - 1; i >= 0; i--) {
338
- const s = shards[i];
373
+ for (let i = 0; i < SHARDS; i++) {
374
+ const s = shard[i];
375
+ if (!s.live) continue;
339
376
  s.vy += 400 * dt;
340
377
  s.x += s.vx * dt; s.y += s.vy * dt;
341
378
  s.r += s.vr * dt;
@@ -343,7 +380,7 @@ export function ShatterButton({ seed = 42 } = {}) {
343
380
  ctx.translate(s.x, s.y); ctx.rotate(s.r);
344
381
  ctx.fillRect(-s.sz / 2, -s.sz / 2, s.sz, s.sz);
345
382
  ctx.restore();
346
- if (s.y > st.h + 100) shards.splice(i, 1);
383
+ if (s.y > st.h + 100) s.live = false;
347
384
  }
348
385
  },
349
386
  };
@@ -355,22 +392,28 @@ export function ShatterButton({ seed = 42 } = {}) {
355
392
  */
356
393
  export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#fbbf24', '#f43f5e'] } = {}) {
357
394
  const rng = new Random(seed);
358
- let conf = [];
395
+ // Fixed confetti pool (live flag) -- no push/splice on the hot path.
396
+ const CONF = 80;
397
+ const conf = [];
398
+ for (let i = 0; i < CONF; i++) conf[i] = { x: 0, y: 0, vx: 0, vy: 0, c: '#fff', w: 0, h: 0, rx: 0, ry: 0, vrx: 0, vry: 0, live: false };
359
399
  let pressScale = 1;
360
400
 
361
401
  return {
362
402
  onClick(x, y) {
363
403
  pressScale = 0.85;
364
- for (let i = 0; i < 40; i++) {
404
+ let n = 0;
405
+ for (let i = 0; i < CONF && n < 40; i++) {
406
+ const c = conf[i];
407
+ if (c.live) continue;
365
408
  const a = rng.range(Math.PI, Math.PI * 2);
366
409
  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
- });
410
+ c.x = x; c.y = y; c.vx = Math.cos(a) * v; c.vy = Math.sin(a) * v;
411
+ c.c = rng.pick(colors);
412
+ c.w = rng.range(4, 8); c.h = rng.range(8, 14);
413
+ c.rx = rng.range(0, Math.PI); c.ry = rng.range(0, Math.PI);
414
+ c.vrx = rng.range(5, 15); c.vry = rng.range(5, 15);
415
+ c.live = true;
416
+ n++;
374
417
  }
375
418
  },
376
419
 
@@ -390,9 +433,10 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
390
433
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
391
434
  ctx.fillText('CONFETTI', st.w / 2, st.h / 2);
392
435
 
393
- // Tumbling confetti
394
- for (let i = conf.length - 1; i >= 0; i--) {
436
+ // Tumbling confetti -- fixed pool
437
+ for (let i = 0; i < CONF; i++) {
395
438
  const c = conf[i];
439
+ if (!c.live) continue;
396
440
  c.vy += 300 * dt;
397
441
  c.x += c.vx * dt; c.y += c.vy * dt;
398
442
  c.rx += c.vrx * dt; c.ry += c.vry * dt;
@@ -402,7 +446,7 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
402
446
  ctx.fillStyle = c.c;
403
447
  ctx.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
404
448
  ctx.restore();
405
- if (c.y > st.h + 100) conf.splice(i, 1);
449
+ if (c.y > st.h + 100) c.live = false;
406
450
  }
407
451
 
408
452
  if (st.focused) {
@@ -422,33 +466,37 @@ export function GlitchButton({ seed = 42 } = {}) {
422
466
  const rng = new Random(seed);
423
467
  let glitchIntensity = 0;
424
468
 
469
+ // Hoisted out of tick (was a per-frame arrow closure). Body alpha via
470
+ // globalAlpha (was two `rgba(...,${intensity})` templates); label at full.
471
+ function drawBase(ctx, st, ox, oy, color, alpha) {
472
+ ctx.globalAlpha = alpha;
473
+ ctx.fillStyle = color;
474
+ roundRect(ctx, ox, oy, st.w, st.h, 10);
475
+ ctx.fill();
476
+ ctx.globalAlpha = 1;
477
+ ctx.fillStyle = '#fff';
478
+ ctx.font = "600 13px 'Space Grotesk',sans-serif";
479
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
480
+ ctx.fillText('GLITCH', st.w / 2 + ox, st.h / 2 + oy);
481
+ }
482
+
425
483
  return {
426
484
  tick(ctx, dt, now, st) {
427
485
  glitchIntensity = lerp(glitchIntensity, st.hover ? 1 : 0, dt * 10);
428
486
 
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
487
  if (glitchIntensity > 0.01) {
440
488
  ctx.globalCompositeOperation = 'screen';
441
489
  const off = rng.range(2, 6) * glitchIntensity;
442
490
  // Alternate between split and normal for flicker
443
491
  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})`);
492
+ drawBase(ctx, st, -off, 0, '#ff0055', 0.6 * glitchIntensity);
493
+ drawBase(ctx, st, off, 0, '#00ffcc', 0.6 * glitchIntensity);
446
494
  } else {
447
- drawBase(0, 0, 'rgba(255,255,255,.1)');
495
+ drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.1)', 1);
448
496
  }
449
497
  ctx.globalCompositeOperation = 'source-over';
450
498
  } else {
451
- drawBase(0, 0, 'rgba(255,255,255,.06)');
499
+ drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.06)', 1);
452
500
  }
453
501
 
454
502
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
@@ -467,27 +515,36 @@ export function GlitchButton({ seed = 42 } = {}) {
467
515
  */
468
516
  export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
469
517
  const rng = new Random(seed);
470
- let sparks = [];
518
+ // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
519
+ const SPARKS = 64;
520
+ const spark = [];
521
+ for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
471
522
  let lastVal = 0.5;
523
+ let lastW = 200; // last track width, so onDrag (no state) spawns at the
524
+ // thumb position derived from st.w (was hardcoded val*200).
525
+ function spawn(val, dir) {
526
+ for (let i = 0; i < SPARKS; i++) {
527
+ const s = spark[i];
528
+ if (s.life <= 0) {
529
+ s.x = val * lastW; s.y = 14;
530
+ s.vx = -dir * rng.range(100, 300); s.vy = rng.range(-150, 50); s.life = 1;
531
+ return;
532
+ }
533
+ }
534
+ }
472
535
 
473
536
  return {
474
537
  onDrag(val) {
475
538
  const delta = val - lastVal;
476
539
  if (Math.abs(delta) > 0.001) {
477
540
  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
- }
541
+ for (let i = 0; i < 4; i++) spawn(val, dir);
486
542
  }
487
543
  lastVal = val;
488
544
  },
489
545
 
490
546
  tick(ctx, dt, now, st) {
547
+ lastW = st.w;
491
548
  // Track background
492
549
  ctx.fillStyle = 'rgba(255,255,255,.08)';
493
550
  roundRect(ctx, 0, 12, st.w, 4, 2);
@@ -505,26 +562,28 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
505
562
  ctx.fill();
506
563
 
507
564
  // Value label
508
- drawStateLabel(ctx, `${Math.round(st.val * 100)}%`, st.w / 2, st.h + 10, '#9999b8');
565
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
509
566
 
510
- // Sparks
567
+ // Sparks -- fixed pool, themed color, alpha via globalAlpha
568
+ // (was `rgba(251,191,36,${life})` built per spark per frame).
511
569
  ctx.globalCompositeOperation = 'screen';
512
- for (let i = sparks.length - 1; i >= 0; i--) {
513
- const s = sparks[i];
570
+ ctx.fillStyle = color;
571
+ for (let i = 0; i < SPARKS; i++) {
572
+ const s = spark[i];
573
+ if (s.life <= 0) continue;
514
574
  s.vy += 400 * dt;
515
575
  s.vx *= 0.95;
516
576
  s.x += s.vx * dt; s.y += s.vy * dt;
517
577
  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})`;
578
+ if (s.life <= 0) continue;
579
+ ctx.globalAlpha = s.life;
522
580
  ctx.save();
523
581
  ctx.translate(s.x, s.y);
524
582
  ctx.rotate(Math.atan2(s.vy, s.vx));
525
583
  ctx.fillRect(0, -1, Math.max(3, Math.abs(s.vx) * 0.05), 2);
526
584
  ctx.restore();
527
585
  }
586
+ ctx.globalAlpha = 1;
528
587
  ctx.globalCompositeOperation = 'source-over';
529
588
 
530
589
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
@@ -556,9 +615,11 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
556
615
 
557
616
  const tx = st.val * st.w;
558
617
 
559
- // Dust particles attracted to thumb
618
+ // Dust particles attracted to thumb (indexed loop -- for..of over an
619
+ // array can allocate an iterator on the hot path)
560
620
  ctx.fillStyle = '#fff';
561
- for (const d of dust) {
621
+ for (let di = 0; di < dust.length; di++) {
622
+ const d = dust[di];
562
623
  const dx = tx - d.x, dy = 14 - d.y;
563
624
  const dist = Math.sqrt(dx * dx + dy * dy);
564
625
  if (dist < 40) {
@@ -582,7 +643,7 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
582
643
  ctx.fill(); ctx.stroke();
583
644
  ctx.shadowBlur = 0;
584
645
 
585
- drawStateLabel(ctx, `${Math.round(st.val * 100)}%`, st.w / 2, st.h + 10, '#9999b8');
646
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
586
647
 
587
648
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
588
649
  },
@@ -594,9 +655,19 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
594
655
  * Laser Slider -- Energy beam traces the filled track. Pulsing plasma thumb.
595
656
  */
596
657
  export function LaserSlider() {
597
- let pulseTime = 0;
658
+ const REF = 100; // gradient reference width; scaled to tx at paint
659
+ let pulseTime = 0, beam = null;
598
660
 
599
661
  return {
662
+ init(ctx) {
663
+ // Fixed-stop beam gradient built once (0..REF). Scaling the space to
664
+ // tx/REF at fill maps it to 0..tx with the white tip at the thumb --
665
+ // no per-frame createLinearGradient.
666
+ beam = ctx.createLinearGradient(0, 0, REF, 0);
667
+ beam.addColorStop(0, 'transparent');
668
+ beam.addColorStop(0.8, '#38bdf8');
669
+ beam.addColorStop(1, '#fff');
670
+ },
600
671
  tick(ctx, dt, now, st) {
601
672
  pulseTime += dt * 10;
602
673
 
@@ -607,26 +678,26 @@ export function LaserSlider() {
607
678
 
608
679
  const tx = st.val * st.w;
609
680
 
610
- // Laser beam gradient
681
+ // Laser beam (gradient from init, scaled to 0..tx)
611
682
  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;
683
+ ctx.save();
684
+ ctx.scale(tx / REF, 1);
685
+ ctx.fillStyle = beam || '#38bdf8';
617
686
  ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
618
- roundRect(ctx, 0, 12, tx, 4, 2);
687
+ roundRect(ctx, 0, 12, REF, 4, 2);
619
688
  ctx.fill();
689
+ ctx.restore();
620
690
  }
621
691
 
622
- // Plasma thumb (pulsing)
692
+ // Plasma thumb (pulsing) -- keep the glow the beam left on
693
+ ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
623
694
  ctx.fillStyle = '#fff';
624
695
  ctx.beginPath();
625
696
  ctx.arc(tx, 14, 8 + Math.sin(pulseTime) * 2, 0, Math.PI * 2);
626
697
  ctx.fill();
627
698
  ctx.shadowBlur = 0;
628
699
 
629
- drawStateLabel(ctx, `${Math.round(st.val * 100)}%`, st.w / 2, st.h + 10, '#9999b8');
700
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
630
701
 
631
702
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
632
703
  },
@@ -669,10 +740,23 @@ export function PendulumToggle() {
669
740
  /** 2. Circuit Toggle -- Electricity flows through a circuit path when ON. */
670
741
  export function CircuitToggle({ seed = 42 } = {}) {
671
742
  const rng = new Random(seed);
672
- let sparks = [], knobX = 18, flowT = 0;
743
+ // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
744
+ const SPARKS = 16;
745
+ const spark = [];
746
+ for (let i = 0; i < SPARKS; i++) spark[i] = { t: 0, life: 0, speed: 0 };
747
+ const DASH_FLOW = [4, 4]; // one shared dash array, reused every frame
748
+ let knobX = 18, flowT = 0;
749
+
750
+ function spawn(t) {
751
+ for (let i = 0; i < SPARKS; i++) {
752
+ const s = spark[i];
753
+ if (s.life <= 0) { s.t = t; s.life = 1; s.speed = rng.range(0.3, 0.8); return; }
754
+ }
755
+ }
756
+
673
757
  return {
674
758
  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) });
759
+ if (checked) for (let i = 0; i < 8; i++) spawn(rng.range(0, 1));
676
760
  },
677
761
  tick(ctx, dt, now, st) {
678
762
  knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 12);
@@ -683,24 +767,27 @@ export function CircuitToggle({ seed = 42 } = {}) {
683
767
 
684
768
  // Circuit path
685
769
  ctx.strokeStyle = st.toggled ? '#22d3ee' : 'rgba(255,255,255,.06)';
686
- ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.lineDashOffset = -flowT * 16;
770
+ ctx.lineWidth = 2; ctx.setLineDash(DASH_FLOW); ctx.lineDashOffset = -flowT * 16;
687
771
  ctx.beginPath(); ctx.moveTo(8, st.h / 2); ctx.lineTo(st.w - 8, st.h / 2); ctx.stroke();
688
- ctx.setLineDash([]); ctx.lineDashOffset = 0;
772
+ ctx.setLineDash(DASH_NONE); ctx.lineDashOffset = 0;
689
773
 
690
- // Flowing sparks
774
+ // Flowing sparks -- fixed pool, per-spark alpha via globalAlpha
691
775
  if (st.toggled) {
692
776
  ctx.fillStyle = '#22d3ee';
693
- for (let i = sparks.length - 1; i >= 0; i--) {
694
- const s = sparks[i];
777
+ let alive = 0;
778
+ for (let i = 0; i < SPARKS; i++) {
779
+ const s = spark[i];
780
+ if (s.life <= 0) continue;
695
781
  s.t += s.speed * dt; s.life -= dt * 0.5;
696
- if (s.life <= 0 || s.t > 1) { sparks.splice(i, 1); continue; }
782
+ if (s.life <= 0 || s.t > 1) { s.life = 0; continue; }
783
+ alive++;
697
784
  const sx = 8 + s.t * (st.w - 16);
698
785
  ctx.globalAlpha = s.life;
699
786
  ctx.beginPath(); ctx.arc(sx, st.h / 2, 2, 0, Math.PI * 2); ctx.fill();
700
787
  }
701
788
  ctx.globalAlpha = 1;
702
789
  // Continuous spawn
703
- if (sparks.length < 12) sparks.push({ t: 0, life: 1, speed: rng.range(0.3, 0.8) });
790
+ if (alive < 12) spawn(0);
704
791
  }
705
792
 
706
793
  ctx.fillStyle = st.toggled ? '#22d3ee' : '#9999b8';
@@ -762,10 +849,20 @@ export function DNAToggle() {
762
849
  const t = i / 20, x = 6 + t * (st.w - 12);
763
850
  const y1 = cy + Math.sin(phase + t * Math.PI * 3) * 8;
764
851
  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();
852
+ // Const strand colors, per-dot alpha via globalAlpha (was two
853
+ // `rgba(...,${...})` templates built per dot per frame).
854
+ if (st.toggled) {
855
+ ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.3 + Math.sin(phase + t * 6) * 0.2;
856
+ ctx.beginPath(); ctx.arc(x, y1, 2, 0, Math.PI * 2); ctx.fill();
857
+ ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.3 + Math.cos(phase + t * 6) * 0.2;
858
+ ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
859
+ ctx.globalAlpha = 1;
860
+ } else {
861
+ ctx.fillStyle = 'rgba(255,255,255,.08)';
862
+ ctx.beginPath(); ctx.arc(x, y1, 2, 0, Math.PI * 2); ctx.fill();
863
+ ctx.fillStyle = 'rgba(255,255,255,.06)';
864
+ ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
865
+ }
769
866
  if (i % 3 === 0) {
770
867
  ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
771
868
  ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke();
@@ -788,14 +885,25 @@ export function DNAToggle() {
788
885
  /** 5. Heartbeat Button -- Heart icon pumps with particle burst on click. */
789
886
  export function HeartbeatButton({ seed = 42 } = {}) {
790
887
  const rng = new Random(seed);
791
- let scale = 1, particles = [], beatPhase = 0;
888
+ // Fixed particle pool (life <= 0 == dead) -- no push/splice on the hot path.
889
+ const PARTS = 32;
890
+ const part = [];
891
+ for (let i = 0; i < PARTS; i++) part[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
892
+ let scale = 1, beatPhase = 0;
893
+ function spawn(x, y) {
894
+ for (let i = 0; i < PARTS; i++) {
895
+ const p = part[i];
896
+ if (p.life <= 0) {
897
+ const a = rng.range(0, Math.PI * 2), v = rng.range(40, 120);
898
+ p.x = x; p.y = y; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
899
+ return;
900
+ }
901
+ }
902
+ }
792
903
  return {
793
904
  onClick(x, y) {
794
905
  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
- }
906
+ for (let i = 0; i < 12; i++) spawn(x || 80, y || 24);
799
907
  },
800
908
  tick(ctx, dt, now, st) {
801
909
  scale = lerp(scale, 1, dt * 8);
@@ -815,15 +923,18 @@ export function HeartbeatButton({ seed = 42 } = {}) {
815
923
  ctx.bezierCurveTo(20, -2, 10, -6, 0, 4);
816
924
  ctx.fill(); ctx.restore();
817
925
 
818
- // Particles
926
+ // Particles -- fixed pool, const color, alpha via globalAlpha
819
927
  ctx.globalCompositeOperation = 'screen';
820
- for (let i = particles.length - 1; i >= 0; i--) {
821
- const p = particles[i];
928
+ ctx.fillStyle = '#ff3c64';
929
+ for (let i = 0; i < PARTS; i++) {
930
+ const p = part[i];
931
+ if (p.life <= 0) continue;
822
932
  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})`;
933
+ if (p.life <= 0) continue;
934
+ ctx.globalAlpha = p.life;
825
935
  ctx.beginPath(); ctx.arc(p.x, p.y, 2, 0, Math.PI * 2); ctx.fill();
826
936
  }
937
+ ctx.globalAlpha = 1;
827
938
  ctx.globalCompositeOperation = 'source-over';
828
939
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
829
940
  },
@@ -832,6 +943,13 @@ export function HeartbeatButton({ seed = 42 } = {}) {
832
943
 
833
944
  /** 6. Breathing Button -- Inhale/exhale pulse with particle halo. */
834
945
  export function BreathingButton() {
946
+ // Alpha-graded green LUT (same RGB #6ee7b6, quantized alpha) built once, so
947
+ // the pulsing glow is a const-string lookup instead of three
948
+ // `rgba(110,231,182,${...})` templates per frame. shadowColor's alpha is not
949
+ // globalAlpha, so a LUT (not globalAlpha) is the zero-alloc fit here.
950
+ const GREEN = [];
951
+ for (let i = 0; i <= 64; i++) GREEN[i] = 'rgba(110,231,182,' + (i / 64).toFixed(3) + ')';
952
+ function greenA(a) { const i = a <= 0 ? 0 : a >= 1 ? 64 : (a * 64) | 0; return GREEN[i]; }
835
953
  let phase = 0;
836
954
  return {
837
955
  tick(ctx, dt, now, st) {
@@ -840,13 +958,13 @@ export function BreathingButton() {
840
958
  const radius = 4 + breath * 6;
841
959
 
842
960
  // 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})`;
961
+ ctx.shadowBlur = 10 + breath * 15; ctx.shadowColor = greenA(0.2 + breath * 0.3);
962
+ ctx.fillStyle = greenA(0.05 + breath * 0.08);
845
963
  roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
846
964
  ctx.shadowBlur = 0;
847
965
 
848
966
  // Halo ring
849
- ctx.strokeStyle = `rgba(110,231,182,${0.1 + breath * 0.2})`;
967
+ ctx.strokeStyle = greenA(0.1 + breath * 0.2);
850
968
  ctx.lineWidth = 1;
851
969
  ctx.beginPath(); ctx.arc(st.w / 2, st.h / 2, st.w / 2 + radius, 0, Math.PI * 2); ctx.stroke();
852
970
 
@@ -861,15 +979,26 @@ export function BreathingButton() {
861
979
  /** 7. Ink Splash Button -- Calligraphy ink splatter on click. */
862
980
  export function InkSplashButton({ seed = 42 } = {}) {
863
981
  const rng = new Random(seed);
864
- let splats = [], pressScale = 1;
982
+ // Fixed splat pool (life <= 0 == dead).
983
+ const SPLATS = 32;
984
+ const splat = [];
985
+ for (let i = 0; i < SPLATS; i++) splat[i] = { x: 0, y: 0, r: 0, life: 0 };
986
+ let pressScale = 1;
987
+ function spawn(cx, cy) {
988
+ for (let i = 0; i < SPLATS; i++) {
989
+ const s = splat[i];
990
+ if (s.life <= 0) {
991
+ const a = rng.range(0, Math.PI * 2), d = rng.range(5, 35);
992
+ s.x = cx + Math.cos(a) * d; s.y = cy + Math.sin(a) * d; s.r = rng.range(2, 8); s.life = 1;
993
+ return;
994
+ }
995
+ }
996
+ }
865
997
  return {
866
998
  onClick(x, y) {
867
999
  pressScale = 0.88;
868
1000
  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
- }
1001
+ for (let i = 0; i < 20; i++) spawn(cx, cy);
873
1002
  },
874
1003
  tick(ctx, dt, now, st) {
875
1004
  pressScale = lerp(pressScale, 1, dt * 8);
@@ -880,12 +1009,17 @@ export function InkSplashButton({ seed = 42 } = {}) {
880
1009
  ctx.fillText('INK', st.w / 2, st.h / 2);
881
1010
  ctx.restore();
882
1011
 
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})`;
1012
+ // Splats -- fixed pool, const color, alpha via globalAlpha
1013
+ ctx.fillStyle = '#1e1e32';
1014
+ for (let i = 0; i < SPLATS; i++) {
1015
+ const s = splat[i];
1016
+ if (s.life <= 0) continue;
1017
+ s.life -= 1.5 * dt;
1018
+ if (s.life <= 0) continue;
1019
+ ctx.globalAlpha = s.life * 0.7;
887
1020
  ctx.beginPath(); ctx.arc(s.x, s.y, s.r * easeOut(1 - s.life), 0, Math.PI * 2); ctx.fill();
888
1021
  }
1022
+ ctx.globalAlpha = 1;
889
1023
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
890
1024
  },
891
1025
  };
@@ -941,12 +1075,36 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
941
1075
  /** 9. Firework Button -- Shoots fireworks upward on click. */
942
1076
  export function FireworkButton({ seed = 42 } = {}) {
943
1077
  const rng = new Random(seed);
944
- let rockets = [], sparks = [], pressScale = 1;
945
1078
  const colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff6bcb'];
1079
+ // Fixed pools (life <= 0 == dead) -- no push/splice on the hot path.
1080
+ const ROCKETS = 16, SPARKS = 256;
1081
+ const rocket = [];
1082
+ for (let i = 0; i < ROCKETS; i++) rocket[i] = { x: 0, y: 0, vy: 0, life: 0, color: '#fff' };
1083
+ const spark = [];
1084
+ for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0, color: '#fff' };
1085
+ let pressScale = 1;
1086
+
1087
+ function spawnRocket(x, y) {
1088
+ for (let i = 0; i < ROCKETS; i++) {
1089
+ const r = rocket[i];
1090
+ if (r.life <= 0) { r.x = x; r.y = y; r.vy = -rng.range(150, 250); r.life = 1; r.color = rng.pick(colors); return; }
1091
+ }
1092
+ }
1093
+ function spawnSpark(x, y, color) {
1094
+ for (let i = 0; i < SPARKS; i++) {
1095
+ const s = spark[i];
1096
+ if (s.life <= 0) {
1097
+ const a = rng.range(0, Math.PI * 2), v = rng.range(30, 100);
1098
+ s.x = x; s.y = y; s.vx = Math.cos(a) * v; s.vy = Math.sin(a) * v; s.life = 1; s.color = color;
1099
+ return;
1100
+ }
1101
+ }
1102
+ }
1103
+
946
1104
  return {
947
1105
  onClick(x, y, st) {
948
1106
  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) });
1107
+ spawnRocket(x || st.w / 2, st.h);
950
1108
  },
951
1109
  tick(ctx, dt, now, st) {
952
1110
  pressScale = lerp(pressScale, 1, dt * 10);
@@ -956,24 +1114,25 @@ export function FireworkButton({ seed = 42 } = {}) {
956
1114
  ctx.fillText('\u{1F386} FIRE', st.w / 2, st.h / 2);
957
1115
  ctx.restore();
958
1116
 
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;
1117
+ // Rockets -- fixed pool; on death, burst into sparks
1118
+ for (let i = 0; i < ROCKETS; i++) {
1119
+ const r = rocket[i];
1120
+ if (r.life <= 0) continue;
1121
+ r.y += r.vy * dt; r.vy += 80 * dt; r.life -= dt;
962
1122
  ctx.fillStyle = r.color; ctx.beginPath(); ctx.arc(r.x, r.y, 3, 0, Math.PI * 2); ctx.fill();
963
1123
  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);
1124
+ for (let j = 0; j < 20; j++) spawnSpark(r.x, r.y, r.color);
1125
+ r.life = 0;
969
1126
  }
970
1127
  }
971
1128
 
972
- // Sparks
1129
+ // Sparks -- fixed pool, const color per spark, alpha via globalAlpha
973
1130
  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; }
1131
+ for (let i = 0; i < SPARKS; i++) {
1132
+ const s = spark[i];
1133
+ if (s.life <= 0) continue;
1134
+ s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 60 * dt; s.life -= 1.5 * dt;
1135
+ if (s.life <= 0) continue;
977
1136
  ctx.fillStyle = s.color; ctx.globalAlpha = s.life;
978
1137
  ctx.beginPath(); ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2); ctx.fill();
979
1138
  }
@@ -990,24 +1149,40 @@ export function FireworkButton({ seed = 42 } = {}) {
990
1149
 
991
1150
  /** 10. Aurora Slider -- Northern lights colors flow along the filled track. */
992
1151
  export function AuroraSlider() {
993
- let time = 0;
1152
+ const REF = 100, PHASES = 12;
994
1153
  const colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#c084fc'];
1154
+ let time = 0;
1155
+ // The shimmer animated the gradient stops per frame. Pre-build one gradient
1156
+ // per animation phase in init (all 0..REF) and cycle them by time; scale to
1157
+ // tx/REF at paint. Bounded, const set -> no per-frame createLinearGradient.
1158
+ const beams = [];
995
1159
  return {
1160
+ init(ctx) {
1161
+ for (let p = 0; p < PHASES; p++) {
1162
+ const t = p / PHASES * PI2;
1163
+ const g = ctx.createLinearGradient(0, 0, REF, 0);
1164
+ for (let i = 0; i < 4; i++) {
1165
+ const pos = clamp(i / 3 + Math.sin(t + i) * 0.15, 0, 1);
1166
+ g.addColorStop(pos, colors[i]);
1167
+ }
1168
+ beams[p] = g;
1169
+ }
1170
+ },
996
1171
  tick(ctx, dt, now, st) {
997
1172
  time += dt;
998
1173
  ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
999
1174
  const tx = st.val * st.w;
1000
1175
  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;
1176
+ const beam = beams[((time * 2) | 0) % PHASES] || beams[0];
1177
+ ctx.save();
1178
+ ctx.scale(tx / REF, 1);
1179
+ ctx.fillStyle = beam; ctx.shadowBlur = 8; ctx.shadowColor = '#6ee7b6';
1180
+ roundRect(ctx, 0, 12, REF, 4, 2); ctx.fill();
1181
+ ctx.restore();
1182
+ ctx.shadowBlur = 0;
1008
1183
  }
1009
1184
  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);
1185
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1011
1186
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1012
1187
  },
1013
1188
  };
@@ -1029,7 +1204,7 @@ export function WaveSlider({ seed = 42 } = {}) {
1029
1204
  ctx.fillRect(bx + 1, 14 - h / 2, bw - 2, h);
1030
1205
  }
1031
1206
  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);
1207
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1033
1208
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1034
1209
  },
1035
1210
  };
@@ -1053,7 +1228,7 @@ export function ElasticBandSlider() {
1053
1228
  ctx.stroke();
1054
1229
 
1055
1230
  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);
1231
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1057
1232
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1058
1233
  },
1059
1234
  };
@@ -1081,7 +1256,7 @@ export function GravitySlider() {
1081
1256
  ctx.fillStyle = '#c084fc'; ctx.beginPath();
1082
1257
  ctx.arc(tx, 14 + sag * Math.sin(Math.PI * st.val), 10, 0, Math.PI * 2); ctx.fill();
1083
1258
 
1084
- label(ctx, `${Math.round(st.val * 100)}%`, st.w / 2, st.h + 14);
1259
+ label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 14);
1085
1260
  if (st.focused) focusRing(ctx, st.w, st.h + 8, 4);
1086
1261
  },
1087
1262
  };
@@ -1105,14 +1280,15 @@ export function OrbitLoader() {
1105
1280
  if (st.toggled) phase += dt;
1106
1281
  const cx = st.w / 2, cy = st.h / 2;
1107
1282
 
1108
- // Orbits
1109
- planets.forEach(p => {
1283
+ // Orbits (plain loop -- forEach's arrow was a per-frame closure alloc)
1284
+ for (let i = 0; i < planets.length; i++) {
1285
+ const p = planets[i];
1110
1286
  ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
1111
1287
  ctx.beginPath(); ctx.arc(cx, cy, p.r, 0, Math.PI * 2); ctx.stroke();
1112
1288
  const a = phase * p.speed;
1113
1289
  ctx.fillStyle = p.color;
1114
1290
  ctx.beginPath(); ctx.arc(cx + Math.cos(a) * p.r, cy + Math.sin(a) * p.r, p.size, 0, Math.PI * 2); ctx.fill();
1115
- });
1291
+ }
1116
1292
 
1117
1293
  // Sun
1118
1294
  ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
@@ -1136,14 +1312,17 @@ export function HelixLoader() {
1136
1312
  const x2 = cx - Math.cos(a) * 16, y2 = cy - Math.sin(a) * 6;
1137
1313
  const depth = (Math.sin(a) + 1) / 2;
1138
1314
 
1139
- ctx.fillStyle = `rgba(167,139,250,${0.2 + depth * 0.6})`;
1315
+ // Const strand colors, per-node alpha via globalAlpha (was three
1316
+ // `rgba(...,${...})` templates per node per frame).
1317
+ ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.2 + depth * 0.6;
1140
1318
  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})`;
1319
+ ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.2 + (1 - depth) * 0.6;
1142
1320
  ctx.beginPath(); ctx.arc(x2, y2, 2 + (1 - depth), 0, Math.PI * 2); ctx.fill();
1143
1321
 
1144
- ctx.strokeStyle = `rgba(255,255,255,${0.03 + depth * 0.04})`;
1322
+ ctx.strokeStyle = '#ffffff'; ctx.globalAlpha = 0.03 + depth * 0.04;
1145
1323
  ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
1146
1324
  }
1325
+ ctx.globalAlpha = 1;
1147
1326
  label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
1148
1327
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
1149
1328
  },
@@ -1157,9 +1336,18 @@ export function HelixLoader() {
1157
1336
 
1158
1337
  /** 16. Ripple Checkbox -- Material-style ripple ring + morphing checkmark. */
1159
1338
  export function RippleCheck() {
1160
- let ripples = [], checkT = 0;
1339
+ // Fixed ripple pool (life <= 0 == dead) -- no push/splice on the hot path.
1340
+ const RIPPLES = 8;
1341
+ const ripR = new Float64Array(RIPPLES);
1342
+ const ripLife = new Float64Array(RIPPLES);
1343
+ let checkT = 0;
1344
+ function spawn() {
1345
+ for (let i = 0; i < RIPPLES; i++) {
1346
+ if (ripLife[i] <= 0) { ripR[i] = 0; ripLife[i] = 1; return; }
1347
+ }
1348
+ }
1161
1349
  return {
1162
- onToggle(checked) { if (checked) ripples.push({ r: 0, life: 1 }); },
1350
+ onToggle(checked) { if (checked) spawn(); },
1163
1351
  tick(ctx, dt, now, st) {
1164
1352
  checkT = lerp(checkT, st.toggled ? 1 : 0, dt * 10);
1165
1353
  const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
@@ -1168,13 +1356,16 @@ export function RippleCheck() {
1168
1356
  ctx.fillStyle = st.toggled ? '#6ee7b6' : 'rgba(255,255,255,.06)';
1169
1357
  roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1170
1358
 
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();
1359
+ // Ripples -- fixed pool, const color, alpha via globalAlpha
1360
+ ctx.strokeStyle = '#6ee7b6'; ctx.lineWidth = 2;
1361
+ for (let i = 0; i < RIPPLES; i++) {
1362
+ if (ripLife[i] <= 0) continue;
1363
+ ripR[i] += 50 * dt; ripLife[i] -= 2 * dt;
1364
+ if (ripLife[i] <= 0) continue;
1365
+ ctx.globalAlpha = ripLife[i];
1366
+ ctx.beginPath(); ctx.arc(cx, cy, ripR[i], 0, Math.PI * 2); ctx.stroke();
1177
1367
  }
1368
+ ctx.globalAlpha = 1;
1178
1369
 
1179
1370
  // Checkmark (animated draw)
1180
1371
  if (checkT > 0.01) {
@@ -1201,8 +1392,15 @@ export function MorphCheck() {
1201
1392
  t = lerp(t, st.toggled ? 1 : 0, dt * 8);
1202
1393
  const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
1203
1394
 
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();
1395
+ // Box bg: faint white early, blue (alpha via globalAlpha) as t rises.
1396
+ if (lerp(0.06, 0.15, t) < 0.1) {
1397
+ ctx.fillStyle = 'rgba(255,255,255,.06)';
1398
+ roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1399
+ } else {
1400
+ ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = lerp(0, 0.2, t);
1401
+ roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1402
+ ctx.globalAlpha = 1;
1403
+ }
1206
1404
 
1207
1405
  ctx.strokeStyle = t > 0.5 ? '#38bdf8' : '#9999b8'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
1208
1406
 
@@ -1241,34 +1439,50 @@ export function MorphCheck() {
1241
1439
  /** 18. Flame Counter -- Number with rising heat particles driven by slider value. */
1242
1440
  export function FlameCounter({ seed = 42 } = {}) {
1243
1441
  const rng = new Random(seed);
1244
- let embers = [];
1442
+ // Fixed ember pool + heat-color LUT (rgb quantized by value), so neither the
1443
+ // pool nor the per-ember color allocates. The number string is rebuilt only
1444
+ // when the displayed integer changes.
1445
+ const EMBERS = 64;
1446
+ const ember = [];
1447
+ for (let i = 0; i < EMBERS; i++) ember[i] = { x: 0, y: 0, vy: 0, life: 0, size: 0 };
1448
+ const HEAT = [];
1449
+ for (let i = 0; i <= 32; i++) HEAT[i] = 'rgb(' + Math.round(200 + (i / 32) * 55) + ',' + Math.round(60 + (i / 32) * 80) + ',20)';
1450
+ let numStr = '0', lastNum = -1;
1451
+ function spawn(st) {
1452
+ for (let i = 0; i < EMBERS; i++) {
1453
+ const e = ember[i];
1454
+ if (e.life <= 0) { e.x = rng.range(20, st.w - 20); e.y = st.h - 5; e.vy = -rng.range(20, 60); e.life = 1; e.size = rng.range(1, 3); return; }
1455
+ }
1456
+ }
1245
1457
  return {
1246
1458
  tick(ctx, dt, now, st) {
1247
1459
  const val = Math.round(st.val * 999);
1460
+ if (val !== lastNum) { lastNum = val; numStr = String(val); }
1248
1461
 
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
- }
1462
+ // Spawn embers proportional to value (pool self-caps at EMBERS)
1463
+ if (st.val > 0.1) spawn(st);
1253
1464
 
1254
1465
  // Background
1255
1466
  ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1256
1467
 
1257
- // Embers
1468
+ // Embers -- fixed pool, heat color via LUT, alpha via globalAlpha
1258
1469
  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})`;
1470
+ ctx.fillStyle = HEAT[(st.val * 32) | 0] || HEAT[32];
1471
+ for (let i = 0; i < EMBERS; i++) {
1472
+ const e = ember[i];
1473
+ if (e.life <= 0) continue;
1474
+ e.y += e.vy * dt; e.life -= dt * 0.8;
1475
+ if (e.life <= 0) continue;
1476
+ ctx.globalAlpha = e.life;
1264
1477
  ctx.beginPath(); ctx.arc(e.x, e.y, e.size, 0, Math.PI * 2); ctx.fill();
1265
1478
  }
1479
+ ctx.globalAlpha = 1;
1266
1480
  ctx.globalCompositeOperation = 'source-over';
1267
1481
 
1268
1482
  // Number
1269
1483
  ctx.fillStyle = st.val > 0.7 ? '#ff6b6b' : st.val > 0.3 ? '#fbbf24' : '#9999b8';
1270
1484
  ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1271
- ctx.fillText(String(val), st.w / 2, st.h / 2);
1485
+ ctx.fillText(numStr, st.w / 2, st.h / 2);
1272
1486
 
1273
1487
  label(ctx, 'HEAT', st.w / 2, st.h + 12);
1274
1488
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
@@ -1279,17 +1493,20 @@ export function FlameCounter({ seed = 42 } = {}) {
1279
1493
  /** 19. Glitch Counter -- Number glitches and jitters as slider value increases. */
1280
1494
  export function GlitchCounter({ seed = 42 } = {}) {
1281
1495
  const rng = new Random(seed);
1496
+ let numStr = '0', lastNum = -1;
1282
1497
  return {
1283
1498
  tick(ctx, dt, now, st) {
1284
1499
  const val = Math.round(st.val * 999);
1500
+ if (val !== lastNum) { lastNum = val; numStr = String(val); }
1285
1501
  const intensity = st.val;
1286
1502
 
1287
1503
  ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1288
1504
 
1289
- // Scanlines
1505
+ // Scanlines -- const white, alpha via globalAlpha
1290
1506
  if (intensity > 0.2) {
1291
- ctx.fillStyle = `rgba(255,255,255,${intensity * 0.03})`;
1507
+ ctx.fillStyle = '#ffffff'; ctx.globalAlpha = intensity * 0.03;
1292
1508
  for (let y = 0; y < st.h; y += 3) ctx.fillRect(0, y, st.w, 1);
1509
+ ctx.globalAlpha = 1;
1293
1510
  }
1294
1511
 
1295
1512
  // Glitched number
@@ -1298,17 +1515,20 @@ export function GlitchCounter({ seed = 42 } = {}) {
1298
1515
 
1299
1516
  if (intensity > 0.3 && rng.next() > 0.6) {
1300
1517
  ctx.globalCompositeOperation = 'screen';
1301
- ctx.fillStyle = `rgba(255,0,85,${intensity * 0.4})`;
1302
1518
  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);
1519
+ // RGB split -- const channel colors, shared alpha via globalAlpha
1520
+ ctx.globalAlpha = intensity * 0.4;
1521
+ ctx.fillStyle = '#ff0055';
1522
+ ctx.fillText(numStr, st.w / 2 + jx - 2, st.h / 2 + jy);
1523
+ ctx.fillStyle = '#00ffcc';
1524
+ ctx.fillText(numStr, st.w / 2 + jx + 2, st.h / 2 + jy);
1525
+ ctx.globalAlpha = 1;
1306
1526
  ctx.globalCompositeOperation = 'source-over';
1307
1527
  }
1308
1528
 
1309
1529
  ctx.fillStyle = '#e2e2f0';
1310
1530
  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);
1531
+ ctx.fillText(numStr, st.w / 2 + jx, st.h / 2 + jy);
1312
1532
 
1313
1533
  label(ctx, 'SIGNAL', st.w / 2, st.h + 12);
1314
1534
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
@@ -1324,9 +1544,27 @@ export function GlitchCounter({ seed = 42 } = {}) {
1324
1544
  /** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
1325
1545
  export function BubbleRating({ seed = 42 } = {}) {
1326
1546
  const rng = new Random(seed);
1327
- let pops = [], sizes = new Float32Array(5);
1547
+ const R5 = ['0 / 5', '1 / 5', '2 / 5', '3 / 5', '4 / 5', '5 / 5']; // const labels
1548
+ const POPS = 32;
1549
+ const pop = [];
1550
+ for (let i = 0; i < POPS; i++) pop[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
1551
+ const sizes = new Float32Array(5);
1552
+ let lastW = 200;
1553
+ function spawnPops(cx) {
1554
+ for (let j = 0; j < 5; j++) {
1555
+ for (let i = 0; i < POPS; i++) {
1556
+ const p = pop[i];
1557
+ if (p.life <= 0) {
1558
+ const a = rng.range(0, Math.PI * 2), v = rng.range(20, 50);
1559
+ p.x = cx; p.y = 14; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
1560
+ break;
1561
+ }
1562
+ }
1563
+ }
1564
+ }
1328
1565
  return {
1329
1566
  tick(ctx, dt, now, st) {
1567
+ lastW = st.w;
1330
1568
  const rating = Math.round(st.val * 5);
1331
1569
  const gap = st.w / 5;
1332
1570
 
@@ -1336,9 +1574,11 @@ export function BubbleRating({ seed = 42 } = {}) {
1336
1574
  sizes[i] = lerp(sizes[i] || 6, targetSz, dt * 8);
1337
1575
  const cx = gap * i + gap / 2, cy = st.h / 2;
1338
1576
 
1339
- // Bubble
1340
- ctx.fillStyle = active ? `rgba(56,189,248,${0.3 + sizes[i] / 20})` : 'rgba(255,255,255,.05)';
1577
+ // Bubble -- const color, active alpha via globalAlpha
1578
+ if (active) { ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
1579
+ else { ctx.fillStyle = 'rgba(255,255,255,.05)'; ctx.globalAlpha = 1; }
1341
1580
  ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.fill();
1581
+ ctx.globalAlpha = 1;
1342
1582
  ctx.strokeStyle = active ? '#38bdf8' : 'rgba(255,255,255,.08)';
1343
1583
  ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.stroke();
1344
1584
 
@@ -1349,25 +1589,27 @@ export function BubbleRating({ seed = 42 } = {}) {
1349
1589
  }
1350
1590
  }
1351
1591
 
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();
1592
+ // Pop particles -- fixed pool, const color, alpha via globalAlpha
1593
+ ctx.fillStyle = '#38bdf8';
1594
+ for (let i = 0; i < POPS; i++) {
1595
+ const p = pop[i];
1596
+ if (p.life <= 0) continue;
1597
+ p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 3 * dt;
1598
+ if (p.life <= 0) continue;
1599
+ ctx.globalAlpha = p.life;
1600
+ ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2); ctx.fill();
1357
1601
  }
1602
+ ctx.globalAlpha = 1;
1358
1603
 
1359
- label(ctx, `${rating} / 5`, st.w / 2, st.h + 12, rating >= 4 ? '#38bdf8' : '#9999b8');
1604
+ label(ctx, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? '#38bdf8' : '#9999b8');
1360
1605
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1361
1606
  },
1362
1607
  onDrag(val) {
1363
1608
  const rating = Math.round(val * 5);
1364
- // Pop the newly activated bubble
1365
- const gap = 200 / 5;
1609
+ // Pop the newly activated bubble (position derived from st.w via lastW)
1610
+ const gap = lastW / 5;
1366
1611
  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
- }
1612
+ spawnPops(cx);
1371
1613
  },
1372
1614
  };
1373
1615
  }
@@ -1379,8 +1621,14 @@ export function BubbleRating({ seed = 42 } = {}) {
1379
1621
 
1380
1622
  /** 1. Volume Knob -- Rotary dial with tick marks and arc indicator. */
1381
1623
  export function VolumeKnob() {
1382
- let displayVal = 0;
1624
+ let displayVal = 0, grad = null;
1625
+ let numStr = '0', lastNum = -1;
1383
1626
  return {
1627
+ init(c, w, h) {
1628
+ // Value-arc gradient depends only on w/h (fixed per mount) -> build once.
1629
+ grad = c.createLinearGradient(0, h, w, 0);
1630
+ grad.addColorStop(0, '#6ee7b6'); grad.addColorStop(1, '#38bdf8');
1631
+ },
1384
1632
  tick(c,dt,now,st) {
1385
1633
  displayVal = lerp(displayVal, st.val, dt * 12);
1386
1634
  const cx=st.w/2, cy=st.h/2, R=Math.min(cx,cy)-4;
@@ -1390,11 +1638,9 @@ export function VolumeKnob() {
1390
1638
  c.strokeStyle='rgba(255,255,255,.06)'; c.lineWidth=4;
1391
1639
  c.beginPath(); c.arc(cx,cy,R,startA,endA); c.stroke();
1392
1640
 
1393
- // Value arc
1641
+ // Value arc (gradient built once in init)
1394
1642
  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;
1643
+ c.strokeStyle = grad || '#38bdf8'; c.lineWidth=4;
1398
1644
  c.beginPath(); c.arc(cx,cy,R,startA,valA); c.stroke();
1399
1645
 
1400
1646
  // Tick marks
@@ -1416,9 +1662,11 @@ export function VolumeKnob() {
1416
1662
  c.fillStyle='rgba(255,255,255,.04)';
1417
1663
  c.beginPath(); c.arc(cx,cy,R*0.4,0,PI2); c.fill();
1418
1664
 
1419
- // Value text
1665
+ // Value text (rebuilt only when the integer changes)
1666
+ const v = Math.round(displayVal * 100);
1667
+ if (v !== lastNum) { lastNum = v; numStr = String(v); }
1420
1668
  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);
1669
+ c.fillText(numStr,cx,cy);
1422
1670
  lbl(c,'VOLUME',cx,st.h+10);
1423
1671
  if(st.focused)fr(c,st.w,st.h,st.h/2);
1424
1672
  },
@@ -1427,7 +1675,10 @@ export function VolumeKnob() {
1427
1675
 
1428
1676
  /** 2. Compass Knob -- Needle points based on slider value (0=N, 0.5=S, 1=N). */
1429
1677
  export function CompassKnob() {
1430
- let needleA=0;
1678
+ const dirs = ['N', 'E', 'S', 'W']; // hoisted out of tick
1679
+ const cols = ['#ff6b6b', '#9999b8', '#9999b8', '#9999b8']; // (were per-frame arrays)
1680
+ let needleA = 0;
1681
+ let degStr = '0\u00B0', lastDeg = -1;
1431
1682
  return {
1432
1683
  tick(c,dt,now,st) {
1433
1684
  const targetA = st.val * PI2;
@@ -1439,7 +1690,6 @@ export function CompassKnob() {
1439
1690
  c.beginPath(); c.arc(cx,cy,R,0,PI2); c.stroke();
1440
1691
 
1441
1692
  // Cardinal marks
1442
- const dirs=['N','E','S','W'], cols=['#ff6b6b','#9999b8','#9999b8','#9999b8'];
1443
1693
  for(let i=0;i<4;i++){
1444
1694
  const a=-Math.PI/2+i*Math.PI/2;
1445
1695
  c.fillStyle=cols[i]; c.font="600 9px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
@@ -1458,7 +1708,9 @@ export function CompassKnob() {
1458
1708
  c.fillStyle='#333'; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.fill();
1459
1709
  c.strokeStyle='rgba(255,255,255,.1)'; c.lineWidth=1; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.stroke();
1460
1710
 
1461
- lbl(c,Math.round(st.val*360)+'\u00B0',cx,st.h+10);
1711
+ const deg = Math.round(st.val * 360);
1712
+ if (deg !== lastDeg) { lastDeg = deg; degStr = deg + '\u00B0'; }
1713
+ lbl(c, degStr, cx, st.h + 10);
1462
1714
  if(st.focused)fr(c,st.w,st.h,st.h/2);
1463
1715
  },
1464
1716
  };
@@ -1472,8 +1724,31 @@ export function CompassKnob() {
1472
1724
  /** 3. Ring Progress -- Circular progress with animated fill and particles at the tip. */
1473
1725
  export function RingProgress({seed=42}={}) {
1474
1726
  const rng=new Random(seed);
1475
- let displayVal=0, sparks=[];
1727
+ // Fixed spark pool + a bounded set of conic gradients (one per progress
1728
+ // level) built in init, so the green stop still tracks the arc tip with zero
1729
+ // per-frame gradient construction.
1730
+ const SPARKS = 24;
1731
+ const spark = [];
1732
+ for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
1733
+ const GPHASES = 16;
1734
+ const grads = [];
1735
+ let displayVal = 0;
1736
+ function spawn(x, y) {
1737
+ for (let i = 0; i < SPARKS; i++) {
1738
+ const s = spark[i];
1739
+ if (s.life <= 0) { s.x = x; s.y = y; s.vx = rng.range(-20, 20); s.vy = rng.range(-20, 20); s.life = 1; return; }
1740
+ }
1741
+ }
1476
1742
  return {
1743
+ init(c, w, h) {
1744
+ for (let p = 0; p < GPHASES; p++) {
1745
+ const g = c.createConicGradient(-Math.PI / 2, w / 2, h / 2);
1746
+ g.addColorStop(0, '#a78bfa');
1747
+ g.addColorStop((p / (GPHASES - 1)) * 0.95, '#6ee7b6');
1748
+ g.addColorStop(1, 'rgba(110,231,182,.2)');
1749
+ grads[p] = g;
1750
+ }
1751
+ },
1477
1752
  tick(c,dt,now,st) {
1478
1753
  displayVal=lerp(displayVal,st.val,dt*6);
1479
1754
  const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-6,lw=6;
@@ -1482,27 +1757,30 @@ export function RingProgress({seed=42}={}) {
1482
1757
  c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=lw;
1483
1758
  c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
1484
1759
 
1485
- // Fill arc
1760
+ // Fill arc (conic gradient from init; the green stop tracks the tip)
1486
1761
  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;
1762
+ c.strokeStyle = grads[(displayVal * (GPHASES - 1)) | 0] || '#6ee7b6';c.lineWidth=lw;
1490
1763
  c.beginPath();c.arc(cx,cy,R,a,ea);c.stroke();
1491
1764
 
1492
- // Tip sparks
1765
+ // Tip sparks -- fixed pool, const color, alpha via globalAlpha
1493
1766
  if(displayVal>0.02){
1494
1767
  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});
1768
+ spawn(tx, ty);
1496
1769
  }
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();
1770
+ c.fillStyle = '#6ee7b6';
1771
+ for(let i=0;i<SPARKS;i++){
1772
+ const s=spark[i];
1773
+ if(s.life<=0)continue;
1774
+ s.x+=s.vx*dt;s.y+=s.vy*dt;s.life-=2*dt;
1775
+ if(s.life<=0)continue;
1776
+ c.globalAlpha=s.life;
1777
+ c.beginPath();c.arc(s.x,s.y,1.5,0,PI2);c.fill();
1501
1778
  }
1779
+ c.globalAlpha=1;
1502
1780
 
1503
1781
  // Percentage
1504
1782
  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);
1783
+ c.fillText(PCT[Math.round(displayVal*100)],cx,cy);
1506
1784
  if(st.focused)fr(c,st.w,st.h,st.h/2);
1507
1785
  },
1508
1786
  };
@@ -1543,7 +1821,7 @@ export function BatteryGauge() {
1543
1821
  }
1544
1822
 
1545
1823
  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);
1824
+ c.fillText(PCT[Math.round(displayVal*100)],bx+bw/2,by+bh/2);
1547
1825
  if(st.focused)fr(c,st.w+4,st.h,r);
1548
1826
  },
1549
1827
  };
@@ -1654,6 +1932,7 @@ export function Stepper() {
1654
1932
  export function RadioOrbit() {
1655
1933
  let selectedGlow=new Float32Array(4);
1656
1934
  const names=['A','B','C','D'],colors=['#ff6b6b','#fbbf24','#6ee7b6','#38bdf8'];
1935
+ const OPTS=['OPTION A','OPTION B','OPTION C','OPTION D']; // const (was 'OPTION '+name concat)
1657
1936
  return {
1658
1937
  tick(c,dt,now,st) {
1659
1938
  const sel=Math.round(st.val*3);
@@ -1668,9 +1947,9 @@ export function RadioOrbit() {
1668
1947
  const active=i===sel;
1669
1948
  selectedGlow[i]=lerp(selectedGlow[i],active?1:0,dt*10);
1670
1949
 
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();
1950
+ // Orbit line -- const white, alpha via globalAlpha
1951
+ c.strokeStyle='#ffffff';c.globalAlpha=.03+selectedGlow[i]*.05;c.lineWidth=1;
1952
+ c.beginPath();c.moveTo(cx,cy);c.lineTo(ox,oy);c.stroke();c.globalAlpha=1;
1674
1953
 
1675
1954
  // Node
1676
1955
  const sz=6+selectedGlow[i]*4;
@@ -1681,7 +1960,7 @@ export function RadioOrbit() {
1681
1960
  c.fillStyle=active?'#fff':'#9999b8';c.font="600 9px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
1682
1961
  c.fillText(names[i],ox,oy);
1683
1962
  }
1684
- lbl(c,'OPTION '+names[sel],cx,st.h+10,colors[sel]);
1963
+ lbl(c,OPTS[sel],cx,st.h+10,colors[sel]);
1685
1964
  if(st.focused)fr(c,st.w,st.h,4);
1686
1965
  },
1687
1966
  };
@@ -1752,7 +2031,7 @@ export function WaterLevel() {
1752
2031
  c.restore();
1753
2032
 
1754
2033
  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);
2034
+ c.fillText(PCT[Math.round(displayVal*100)],st.w/2,st.h/2);
1756
2035
  if(st.focused)fr(c,st.w,st.h,6);
1757
2036
  },
1758
2037
  };
@@ -1763,6 +2042,10 @@ export function HeatMap({seed=42}={}) {
1763
2042
  const rng=new Random(seed);
1764
2043
  const N=15,thresholds=new Float32Array(N);
1765
2044
  let vals=new Float32Array(N);
2045
+ // Heat-color LUT (rgb quantized by heat): a const lookup + globalAlpha
2046
+ // instead of a per-cell rgb() template every frame.
2047
+ const HEATC=[];
2048
+ for(let i=0;i<=32;i++)HEATC[i]='rgb('+Math.round(100+i/32*155)+','+Math.round(60+i/32*20)+','+Math.round(60-i/32*40)+')';
1766
2049
  return {
1767
2050
  init(){for(let i=0;i<N;i++)thresholds[i]=rng.range(0,1)},
1768
2051
  tick(c,dt,now,st) {
@@ -1775,11 +2058,12 @@ export function HeatMap({seed=42}={}) {
1775
2058
  const col=i%cols,row=(i/cols)|0;
1776
2059
  const x=col*(cw+gap),y=row*(ch+gap);
1777
2060
  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)';
2061
+ if(heat>.1){c.fillStyle=HEATC[(heat*32)|0]||HEATC[32];c.globalAlpha=.15+heat*.4;}
2062
+ else{c.fillStyle='rgba(255,255,255,.04)';c.globalAlpha=1;}
1780
2063
  rr(c,x,y,cw,ch,3);c.fill();
2064
+ c.globalAlpha=1;
1781
2065
  }
1782
- lbl(c,Math.round(st.val*100)+'%',st.w/2,st.h+10);
2066
+ lbl(c,PCT[Math.round(st.val*100)],st.w/2,st.h+10);
1783
2067
  if(st.focused)fr(c,st.w,st.h,3);
1784
2068
  },
1785
2069
  };
@@ -1793,23 +2077,31 @@ export function HeatMap({seed=42}={}) {
1793
2077
  /** 12. Day/Night Toggle -- Sun/moon transition with star particles. */
1794
2078
  export function DayNightToggle({seed=42}={}) {
1795
2079
  const rng=new Random(seed);
1796
- let t=0, stars=[];
2080
+ // Sky-color LUT (day->night rgb quantized by t): a const lookup, not an
2081
+ // rgb() template per frame. Stars are a fixed pool filled in init.
2082
+ const SKY=[];
2083
+ for(let i=0;i<=32;i++){const u=i/32;SKY[i]='rgb('+Math.round(lerp(135,10,u))+','+Math.round(lerp(206,10,u))+','+Math.round(lerp(250,30,u))+')';}
2084
+ const STARS=30;
2085
+ const star=[];
2086
+ let t=0;
1797
2087
  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)})},
2088
+ init(ctx,w,h){for(let i=0;i<STARS;i++)star[i]={x:rng.range(4,w-4),y:rng.range(4,h-4),twinkle:rng.range(0,PI2)}},
1799
2089
  tick(c,dt,now,st) {
1800
2090
  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
2091
 
1803
- // Sky background
1804
- c.fillStyle=`rgb(${skyR},${skyG},${skyB})`;rr(c,0,0,st.w,st.h,st.h/2);c.fill();
2092
+ // Sky background (LUT)
2093
+ c.fillStyle=SKY[(t*32)|0]||SKY[32];rr(c,0,0,st.w,st.h,st.h/2);c.fill();
1805
2094
 
1806
- // Stars (night only)
2095
+ // Stars (night only) -- const white, per-star alpha via globalAlpha
1807
2096
  if(t>.3){
1808
- for(const s of stars){
2097
+ c.fillStyle='#ffffff';
2098
+ for(let i=0;i<STARS;i++){
2099
+ const s=star[i];
1809
2100
  const tw=Math.sin(now/400+s.twinkle)*.5+.5;
1810
- c.fillStyle=`rgba(255,255,255,${(t-.3)/.7*tw*.6})`;
2101
+ c.globalAlpha=(t-.3)/.7*tw*.6;
1811
2102
  c.fillRect(s.x,s.y,1,1);
1812
2103
  }
2104
+ c.globalAlpha=1;
1813
2105
  }
1814
2106
 
1815
2107
  // Sun/Moon
@@ -1817,16 +2109,18 @@ export function DayNightToggle({seed=42}={}) {
1817
2109
  if(t<.5){
1818
2110
  // Sun
1819
2111
  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;
2112
+ // Rays -- const color, alpha via globalAlpha
2113
+ c.strokeStyle='#fbbf24';c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
1822
2114
  for(let i=0;i<8;i++){const a=now/800+i*Math.PI/4;c.beginPath();c.moveTo(orbX+Math.cos(a)*12,st.h/2+Math.sin(a)*12);c.lineTo(orbX+Math.cos(a)*16,st.h/2+Math.sin(a)*16);c.stroke()}
2115
+ c.globalAlpha=1;
1823
2116
  } else {
1824
2117
  // Moon
1825
2118
  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})`;
2119
+ // Crater shadows -- const black, alpha via globalAlpha
2120
+ c.fillStyle='#000000';c.globalAlpha=(t-.5)*2*.15;
1828
2121
  c.beginPath();c.arc(orbX-3,st.h/2-2,3,0,PI2);c.fill();
1829
2122
  c.beginPath();c.arc(orbX+4,st.h/2+3,2,0,PI2);c.fill();
2123
+ c.globalAlpha=1;
1830
2124
  }
1831
2125
 
1832
2126
  lbl(c,st.toggled?'NIGHT':'DAY',st.w/2,st.h+14,st.toggled?'#9999b8':'#fbbf24');
@@ -1839,6 +2133,9 @@ export function DayNightToggle({seed=42}={}) {
1839
2133
  export function ReactionPicker() {
1840
2134
  let sizes=new Float32Array(5), selected=-1;
1841
2135
  const emojis=['\u{1F610}','\u{1F642}','\u{1F60A}','\u{1F604}','\u{1F929}'],colors=['#9999b8','#fbbf24','#fb923c','#f472b6','#ff6b6b'];
2136
+ const colors30=colors.map((col)=>col+'30'); // active fill (was `${color}30`)
2137
+ const REACT_LABELS=['MEH','OK','NICE','GREAT','LOVE']; // was a per-frame array literal
2138
+ const FONTS=[]; for(let i=0;i<=48;i++)FONTS[i]=i+'px sans-serif'; // emoji font by rounded size
1842
2139
  return {
1843
2140
  tick(c,dt,now,st,ptr) {
1844
2141
  const gap=st.w/5;
@@ -1850,11 +2147,11 @@ export function ReactionPicker() {
1850
2147
  const cx=gap*i+gap/2,cy=st.h/2;
1851
2148
 
1852
2149
  // Circle
1853
- c.fillStyle=active?`${colors[i]}30`:'rgba(255,255,255,.04)';
2150
+ c.fillStyle=active?colors30[i]:'rgba(255,255,255,.04)';
1854
2151
  c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();
1855
2152
 
1856
- // Emoji face (simplified)
1857
- c.font=`${Math.round(sizes[i]*1.2)}px sans-serif`;c.textAlign='center';c.textBaseline='middle';
2153
+ // Emoji face (simplified) -- font from a const-string LUT
2154
+ c.font=FONTS[Math.round(sizes[i]*1.2)]||FONTS[48];c.textAlign='center';c.textBaseline='middle';
1858
2155
  c.fillText(emojis[i],cx,cy-sizes[i]+10);
1859
2156
  }
1860
2157
 
@@ -1865,7 +2162,7 @@ export function ReactionPicker() {
1865
2162
  c.fillRect(sx-10,st.h-4,20,2);
1866
2163
  }
1867
2164
 
1868
- lbl(c,hoverIdx>=0?['MEH','OK','NICE','GREAT','LOVE'][hoverIdx]:'REACT',st.w/2,st.h+10,hoverIdx>=0?colors[hoverIdx]:'#9999b8');
2165
+ lbl(c,hoverIdx>=0?REACT_LABELS[hoverIdx]:'REACT',st.w/2,st.h+10,hoverIdx>=0?colors[hoverIdx]:'#9999b8');
1869
2166
  if(st.focused)fr(c,st.w,st.h,4);
1870
2167
  },
1871
2168
  };
@@ -1874,9 +2171,10 @@ export function ReactionPicker() {
1874
2171
  /** 14. Notification Bell -- Bell icon with bounce and count badge. */
1875
2172
  export function NotificationBell() {
1876
2173
  let count=0, bellAngle=0, bellVel=0, badgeScale=0;
2174
+ let badgeStr='0'; // rebuilt only on click (count change), not per frame
1877
2175
  return {
1878
2176
  onClick() {
1879
- count++;bellVel=6;badgeScale=1.5;
2177
+ count++;bellVel=6;badgeScale=1.5;badgeStr=count>99?'99+':String(count);
1880
2178
  },
1881
2179
  tick(c,dt,now,st) {
1882
2180
  bellVel+=(0-bellAngle)*20*dt;bellVel*=0.9;bellAngle+=bellVel*dt;
@@ -1897,7 +2195,7 @@ export function NotificationBell() {
1897
2195
  c.save();c.translate(bx,by);c.scale(badgeScale,badgeScale);
1898
2196
  c.fillStyle='#ff6b6b';c.beginPath();c.arc(0,0,8,0,PI2);c.fill();
1899
2197
  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);
2198
+ c.fillText(badgeStr,0,0);
1901
2199
  c.restore();
1902
2200
  }
1903
2201
 
@@ -1916,6 +2214,7 @@ export function NotificationBell() {
1916
2214
  export function TypewriterField() {
1917
2215
  const text='Hello World';
1918
2216
  let charIdx=0, timer=0, cursorBlink=0, typing=false;
2217
+ let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
1919
2218
  return {
1920
2219
  onToggle(checked){typing=checked;if(checked){charIdx=0;timer=0}},
1921
2220
  tick(c,dt,now,st) {
@@ -1925,14 +2224,14 @@ export function TypewriterField() {
1925
2224
  c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
1926
2225
  c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
1927
2226
 
1928
- const display=text.substring(0,charIdx);
1929
2227
  c.fillStyle='#6ee7b6';c.font="500 13px 'JetBrains Mono',monospace";c.textAlign='left';c.textBaseline='middle';
2228
+ // Rebuild the visible substring + its width only when a char is added.
2229
+ if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
1930
2230
  c.fillText(display,8,st.h/2);
1931
2231
 
1932
2232
  // Cursor
1933
2233
  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);
2234
+ c.fillStyle='#6ee7b6';c.fillRect(9+dispW,st.h/2-8,1.5,16);
1936
2235
  }
1937
2236
 
1938
2237
  lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?'#6ee7b6':'#8888aa');
@@ -1994,7 +2293,7 @@ export function UploadProgress() {
1994
2293
  // Fill
1995
2294
  const fillH=fh*displayVal;
1996
2295
  const col=displayVal>=1?'#6ee7b6':'#38bdf8';
1997
- c.fillStyle=`${col}30`;
2296
+ c.fillStyle=displayVal>=1?'#6ee7b630':'#38bdf830'; // was `${col}30`
1998
2297
  c.save();c.beginPath();c.rect(fx+1,fy+fh-fillH,fw-1,fillH);c.clip();
1999
2298
  c.beginPath();c.moveTo(fx,fy);c.lineTo(fx+fw-fold,fy);c.lineTo(fx+fw,fy+fold);c.lineTo(fx+fw,fy+fh);c.lineTo(fx,fy+fh);c.closePath();c.fill();
2000
2299
  c.restore();
@@ -2009,7 +2308,7 @@ export function UploadProgress() {
2009
2308
  }
2010
2309
  c.lineCap='butt';
2011
2310
 
2012
- lbl(c,displayVal>=.99?'DONE':Math.round(displayVal*100)+'%',cx,st.h+10,displayVal>=.99?'#6ee7b6':'#9999b8');
2311
+ lbl(c,displayVal>=.99?'DONE':PCT[Math.round(displayVal*100)],cx,st.h+10,displayVal>=.99?'#6ee7b6':'#9999b8');
2013
2312
  if(st.focused)fr(c,st.w,st.h,4);
2014
2313
  },
2015
2314
  };
@@ -2023,29 +2322,45 @@ export function UploadProgress() {
2023
2322
  /** 18. Scratch Reveal -- Drag to erase a mask and reveal the prize beneath. */
2024
2323
  export function ScratchReveal({seed=42}={}) {
2025
2324
  const rng=new Random(seed);
2026
- let revealed=0, scratches=[];
2325
+ // Fixed scratch pool (holes persist). Once all SCR slots are used the scratch
2326
+ // stops adding holes -- existing marks are never overwritten, so earlier
2327
+ // scratches never "heal". Spawn position derives from st.w via lastW.
2328
+ const SCR=64;
2329
+ const scr=[];
2330
+ for(let i=0;i<SCR;i++)scr[i]={x:0,y:0,r:0,live:false};
2331
+ let revealed=0, lastW=200;
2332
+ function spawn(val){
2333
+ for(let i=0;i<SCR;i++){
2334
+ const s=scr[i];
2335
+ if(!s.live){ s.x=val*lastW; s.y=rng.range(2,26); s.r=rng.range(6,14); s.live=true; return; }
2336
+ }
2337
+ }
2027
2338
  return {
2028
2339
  onDrag(val,vx) {
2029
2340
  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)});
2341
+ for(let i=0;i<3;i++)spawn(val);
2031
2342
  revealed=Math.min(1,revealed+0.015);
2032
2343
  }
2033
2344
  },
2034
2345
  tick(c,dt,now,st) {
2346
+ lastW=st.w;
2035
2347
  // Prize background
2036
2348
  c.fillStyle='rgba(110,231,182,.08)';rr(c,0,0,st.w,st.h,8);c.fill();
2037
2349
 
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';
2350
+ // Prize text -- const color, alpha via globalAlpha
2351
+ c.fillStyle='#6ee7b6';c.globalAlpha=.1+revealed*.6;c.font="700 14px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2040
2352
  c.fillText(revealed>.6?'\u{1F389} WINNER!':'? ? ?',st.w/2,st.h/2);
2353
+ c.globalAlpha=1;
2041
2354
 
2042
2355
  // Scratch mask (gets holes)
2043
2356
  if(revealed<.95){
2044
2357
  c.fillStyle='rgba(40,40,60,.85)';rr(c,0,0,st.w,st.h,8);c.fill();
2045
2358
 
2046
- // Cut holes
2359
+ // Cut holes -- fixed ring, indexed loop
2047
2360
  c.globalCompositeOperation='destination-out';
2048
- for(const s of scratches){
2361
+ for(let i=0;i<SCR;i++){
2362
+ const s=scr[i];
2363
+ if(!s.live)continue;
2049
2364
  c.beginPath();c.arc(s.x,s.y,s.r,0,PI2);c.fill();
2050
2365
  }
2051
2366
  c.globalCompositeOperation='source-over';
@@ -2065,6 +2380,7 @@ export function ScratchReveal({seed=42}={}) {
2065
2380
  /** 19. Timer Countdown -- Circular countdown timer. Toggle starts/stops. */
2066
2381
  export function TimerCountdown() {
2067
2382
  let timeLeft=10, running=false, flashAlpha=0;
2383
+ let timeStr='10.0s', lastTenths=-1; // rebuilt at ~10 Hz, not per frame
2068
2384
  return {
2069
2385
  onToggle(checked) { running=checked; if(checked)timeLeft=10; },
2070
2386
  tick(c,dt,now,st) {
@@ -2075,7 +2391,7 @@ export function TimerCountdown() {
2075
2391
  const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-4;
2076
2392
 
2077
2393
  // 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();}
2394
+ if(flashAlpha>0){c.fillStyle='#ff6464';c.globalAlpha=flashAlpha*.1;c.beginPath();c.arc(cx,cy,R+4,0,PI2);c.fill();c.globalAlpha=1;}
2079
2395
 
2080
2396
  // Background ring
2081
2397
  c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=4;c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
@@ -2087,8 +2403,10 @@ export function TimerCountdown() {
2087
2403
  c.beginPath();c.arc(cx,cy,R,-Math.PI/2,-Math.PI/2+progress*PI2,false);c.stroke();
2088
2404
 
2089
2405
  // Time text
2406
+ const tenths=Math.round(timeLeft*10);
2407
+ if(tenths!==lastTenths){lastTenths=tenths;timeStr=(tenths/10).toFixed(1)+'s';}
2090
2408
  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);
2409
+ c.fillText(timeStr,cx,cy);
2092
2410
 
2093
2411
  lbl(c,running?(timeLeft>0?'RUNNING':'TIME UP!'):'TOGGLE TO START',cx,st.h+10,running?col:'#8888aa');
2094
2412
  if(st.focused)fr(c,st.w,st.h,R);
@@ -2117,9 +2435,10 @@ export function PullRefresh() {
2117
2435
  // Spinner arcs
2118
2436
  for(let i=0;i<3;i++){
2119
2437
  const a=spinAngle+i*PI2/3;
2120
- c.strokeStyle=`rgba(56,189,248,${.3+i*.2})`;c.lineWidth=3;
2438
+ c.strokeStyle='#38bdf8';c.globalAlpha=.3+i*.2;c.lineWidth=3;
2121
2439
  c.beginPath();c.arc(cx,cy,16,a,a+.8);c.stroke();
2122
2440
  }
2441
+ c.globalAlpha=1;
2123
2442
  lbl(c,'LOADING...',cx,st.h+10,'#38bdf8');
2124
2443
  } else {
2125
2444
  // Arrow that stretches with pull