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