@zakkster/lite-ui-fx 1.3.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
@@ -100,6 +100,81 @@ function fr(c,w,h,r){c.strokeStyle='rgba(110,231,182,.5)';c.lineWidth=2;c.setLin
100
100
  const PI2=Math.PI*2;
101
101
 
102
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
+
103
178
 
104
179
  // ===========================================================
105
180
  // TOGGLE RECIPES
@@ -110,7 +185,10 @@ const PI2=Math.PI*2;
110
185
  * On toggle, they explode outward then regroup at the new position.
111
186
  * Uses sunflower phyllotaxis for the packed formation.
112
187
  */
113
- 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)';
114
192
  const rng = new Random(seed);
115
193
  const N = count;
116
194
  const px = new Float32Array(N), py = new Float32Array(N);
@@ -142,19 +220,19 @@ export function SwarmToggle({ seed = 42, count = 150 } = {}) {
142
220
 
143
221
  tick(ctx, dt, now, st) {
144
222
  // Track
145
- 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)';
146
224
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
147
225
  ctx.fill();
148
226
 
149
227
  // State label
150
228
  drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
151
- st.toggled ? '#6ee7b6' : '#8888aa');
229
+ st.toggled ? P.accent : P.dim2);
152
230
 
153
231
  // Knob target position
154
232
  const tx = st.toggled ? st.w - 18 : 18;
155
233
 
156
234
  // Render particles (spring toward formation)
157
- ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
235
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
158
236
  for (let i = 0; i < N; i++) {
159
237
  vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
160
238
  vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
@@ -179,7 +257,9 @@ export function SwarmToggle({ seed = 42, count = 150 } = {}) {
179
257
  * Liquid Toggle -- Metaball-style stretching knob.
180
258
  * The knob elongates in the direction of motion, squashes perpendicular.
181
259
  */
182
- 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)';
183
263
  let knobX = 18;
184
264
 
185
265
  return {
@@ -189,19 +269,19 @@ export function LiquidToggle() {
189
269
  const stretch = Math.abs(knobX - tx) * 0.5;
190
270
 
191
271
  // Track
192
- 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)';
193
273
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
194
274
  ctx.fill();
195
275
 
196
276
  // Knob (stretched ellipse)
197
- ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
277
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
198
278
  ctx.beginPath();
199
279
  ctx.ellipse(knobX, st.h / 2, 14 + stretch, 14 - stretch * 0.2, 0, 0, Math.PI * 2);
200
280
  ctx.fill();
201
281
 
202
282
  // State label
203
283
  drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
204
- st.toggled ? '#6ee7b6' : '#8888aa');
284
+ st.toggled ? P.accent : P.dim2);
205
285
 
206
286
  if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
207
287
  },
@@ -212,7 +292,9 @@ export function LiquidToggle() {
212
292
  /**
213
293
  * Neon Pulse Toggle -- Expanding shockwave rings on toggle.
214
294
  */
215
- export function NeonPulseToggle() {
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)';
216
298
  // Fixed ring pool (dead when life <= 0) -- no push/splice on the hot path.
217
299
  const RINGS = 8;
218
300
  const ringR = new Float64Array(RINGS);
@@ -238,13 +320,13 @@ export function NeonPulseToggle() {
238
320
  knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 15);
239
321
 
240
322
  // Track
241
- 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)';
242
324
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
243
325
  ctx.fill();
244
326
 
245
327
  // Shockwave rings -- const stroke color, per-ring alpha via globalAlpha
246
328
  // (was `rgba(56,189,248,${life})` built per ring per frame).
247
- ctx.strokeStyle = '#38bdf8';
329
+ ctx.strokeStyle = P.accent;
248
330
  ctx.lineWidth = 2;
249
331
  for (let i = 0; i < RINGS; i++) {
250
332
  if (ringLife[i] <= 0) continue;
@@ -259,13 +341,13 @@ export function NeonPulseToggle() {
259
341
  ctx.globalAlpha = 1;
260
342
 
261
343
  // Knob
262
- ctx.fillStyle = st.toggled ? '#38bdf8' : '#9999b8';
344
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
263
345
  ctx.beginPath();
264
346
  ctx.arc(knobX, st.h / 2, 14, 0, Math.PI * 2);
265
347
  ctx.fill();
266
348
 
267
349
  drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
268
- st.toggled ? '#38bdf8' : '#8888aa');
350
+ st.toggled ? P.accent : P.dim2);
269
351
 
270
352
  if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
271
353
  },
@@ -281,7 +363,11 @@ export function NeonPulseToggle() {
281
363
  * Magnetic Button -- The entire button follows the cursor with spring physics.
282
364
  * Squashes on click, springs back.
283
365
  */
284
- 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');
285
371
  let bx = 0, by = 0, textScale = 1;
286
372
 
287
373
  return {
@@ -297,19 +383,19 @@ export function MagneticButton({ maxPull = 15 } = {}) {
297
383
  ctx.translate(bx, by);
298
384
 
299
385
  // Button body
300
- ctx.fillStyle = st.hover ? '#1e1e2f' : 'rgba(255,255,255,.05)';
301
- 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)';
302
388
  ctx.lineWidth = 1;
303
389
  roundRect(ctx, 0, 0, st.w, st.h, 10);
304
390
  ctx.fill(); ctx.stroke();
305
391
 
306
392
  // Label
307
- ctx.fillStyle = st.hover ? '#a78bfa' : '#e2e2f0';
308
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
393
+ ctx.fillStyle = st.hover ? P.accent : P.dim;
394
+ ctx.font = FONT;
309
395
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
310
396
  ctx.translate(st.w / 2, st.h / 2);
311
397
  ctx.scale(textScale, textScale);
312
- ctx.fillText('MAGNETIC', 0, 0);
398
+ ctx.fillText(TXT, 0, 0);
313
399
 
314
400
  if (st.focused) {
315
401
  ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
@@ -324,7 +410,12 @@ export function MagneticButton({ maxPull = 15 } = {}) {
324
410
  /**
325
411
  * Shatter Button -- Click explodes into falling shards, reforms after 1.5s.
326
412
  */
327
- 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');
328
419
  const rng = new Random(seed);
329
420
  // Fixed shard pool (live flag) -- no push/splice on the hot path.
330
421
  const SHARDS = 32;
@@ -360,16 +451,16 @@ export function ShatterButton({ seed = 42 } = {}) {
360
451
  ctx.fillStyle = st.active ? 'rgba(255,255,255,.15)' : 'rgba(255,255,255,.08)';
361
452
  roundRect(ctx, 0, 0, st.w, st.h, 10);
362
453
  ctx.fill();
363
- ctx.fillStyle = '#e2e2f0';
364
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
454
+ ctx.fillStyle = P.dim;
455
+ ctx.font = FONT;
365
456
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
366
- ctx.fillText('SHATTER', st.w / 2, st.h / 2);
457
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
367
458
 
368
459
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
369
460
  }
370
461
 
371
462
  // Falling shards -- fixed pool
372
- ctx.fillStyle = 'rgba(255,255,255,.5)';
463
+ ctx.fillStyle = shardColor;
373
464
  for (let i = 0; i < SHARDS; i++) {
374
465
  const s = shard[i];
375
466
  if (!s.live) continue;
@@ -390,7 +481,15 @@ export function ShatterButton({ seed = 42 } = {}) {
390
481
  /**
391
482
  * Confetti Button -- 3D tumbling confetti burst from click point.
392
483
  */
393
- 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');
394
493
  const rng = new Random(seed);
395
494
  // Fixed confetti pool (live flag) -- no push/splice on the hot path.
396
495
  const CONF = 80;
@@ -425,13 +524,13 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
425
524
  ctx.translate(-st.w / 2, -st.h / 2);
426
525
 
427
526
  // Button body
428
- ctx.fillStyle = '#DC143C';
527
+ ctx.fillStyle = bodyColor;
429
528
  roundRect(ctx, 0, 0, st.w, st.h, 10);
430
529
  ctx.fill();
431
530
  ctx.fillStyle = '#fff';
432
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
531
+ ctx.font = FONT;
433
532
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
434
- ctx.fillText('CONFETTI', st.w / 2, st.h / 2);
533
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
435
534
 
436
535
  // Tumbling confetti -- fixed pool
437
536
  for (let i = 0; i < CONF; i++) {
@@ -462,7 +561,11 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
462
561
  /**
463
562
  * Glitch Button -- RGB channel split on hover. Random slice displacement.
464
563
  */
465
- 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');
466
569
  const rng = new Random(seed);
467
570
  let glitchIntensity = 0;
468
571
 
@@ -475,9 +578,9 @@ export function GlitchButton({ seed = 42 } = {}) {
475
578
  ctx.fill();
476
579
  ctx.globalAlpha = 1;
477
580
  ctx.fillStyle = '#fff';
478
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
581
+ ctx.font = FONT;
479
582
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
480
- ctx.fillText('GLITCH', st.w / 2 + ox, st.h / 2 + oy);
583
+ ctx.fillText(TXT, st.w / 2 + ox, st.h / 2 + oy);
481
584
  }
482
585
 
483
586
  return {
@@ -489,8 +592,8 @@ export function GlitchButton({ seed = 42 } = {}) {
489
592
  const off = rng.range(2, 6) * glitchIntensity;
490
593
  // Alternate between split and normal for flicker
491
594
  if (rng.next() > 0.4) {
492
- drawBase(ctx, st, -off, 0, '#ff0055', 0.6 * glitchIntensity);
493
- drawBase(ctx, st, off, 0, '#00ffcc', 0.6 * glitchIntensity);
595
+ drawBase(ctx, st, -off, 0, P.accent, 0.6 * glitchIntensity);
596
+ drawBase(ctx, st, off, 0, P.accent2, 0.6 * glitchIntensity);
494
597
  } else {
495
598
  drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.1)', 1);
496
599
  }
@@ -513,7 +616,10 @@ export function GlitchButton({ seed = 42 } = {}) {
513
616
  * Spark Slider -- Emits directional sparks based on drag velocity.
514
617
  * Sparks fly opposite to drag direction with motion-blur stretch.
515
618
  */
516
- 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' });
517
623
  const rng = new Random(seed);
518
624
  // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
519
625
  const SPARKS = 64;
@@ -552,7 +658,7 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
552
658
 
553
659
  // Filled track
554
660
  const tx = st.val * st.w;
555
- ctx.fillStyle = color;
661
+ ctx.fillStyle = P.accent;
556
662
  roundRect(ctx, 0, 12, tx, 4, 2);
557
663
  ctx.fill();
558
664
 
@@ -562,12 +668,12 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
562
668
  ctx.fill();
563
669
 
564
670
  // Value label
565
- drawStateLabel(ctx, PCT[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);
566
672
 
567
673
  // Sparks -- fixed pool, themed color, alpha via globalAlpha
568
674
  // (was `rgba(251,191,36,${life})` built per spark per frame).
569
675
  ctx.globalCompositeOperation = 'screen';
570
- ctx.fillStyle = color;
676
+ ctx.fillStyle = P.accent;
571
677
  for (let i = 0; i < SPARKS; i++) {
572
678
  const s = spark[i];
573
679
  if (s.life <= 0) continue;
@@ -596,7 +702,9 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
596
702
  * Cosmic Slider -- Thumb is a black hole that sucks in background dust.
597
703
  * Particles respawn when consumed.
598
704
  */
599
- 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' });
600
708
  const rng = new Random(seed);
601
709
  const dust = [];
602
710
 
@@ -637,13 +745,13 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
637
745
  }
638
746
 
639
747
  // Black hole thumb with glow
640
- ctx.shadowBlur = 10; ctx.shadowColor = '#a78bfa';
641
- 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;
642
750
  ctx.beginPath(); ctx.arc(tx, 14, 12, 0, Math.PI * 2);
643
751
  ctx.fill(); ctx.stroke();
644
752
  ctx.shadowBlur = 0;
645
753
 
646
- drawStateLabel(ctx, PCT[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);
647
755
 
648
756
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
649
757
  },
@@ -654,7 +762,8 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
654
762
  /**
655
763
  * Laser Slider -- Energy beam traces the filled track. Pulsing plasma thumb.
656
764
  */
657
- export function LaserSlider() {
765
+ export function LaserSlider(o = {}) {
766
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8' });
658
767
  const REF = 100; // gradient reference width; scaled to tx at paint
659
768
  let pulseTime = 0, beam = null;
660
769
 
@@ -665,7 +774,7 @@ export function LaserSlider() {
665
774
  // no per-frame createLinearGradient.
666
775
  beam = ctx.createLinearGradient(0, 0, REF, 0);
667
776
  beam.addColorStop(0, 'transparent');
668
- beam.addColorStop(0.8, '#38bdf8');
777
+ beam.addColorStop(0.8, P.accent);
669
778
  beam.addColorStop(1, '#fff');
670
779
  },
671
780
  tick(ctx, dt, now, st) {
@@ -682,22 +791,22 @@ export function LaserSlider() {
682
791
  if (tx > 2) {
683
792
  ctx.save();
684
793
  ctx.scale(tx / REF, 1);
685
- ctx.fillStyle = beam || '#38bdf8';
686
- ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
794
+ ctx.fillStyle = beam || P.accent;
795
+ ctx.shadowBlur = 10; ctx.shadowColor = P.accent;
687
796
  roundRect(ctx, 0, 12, REF, 4, 2);
688
797
  ctx.fill();
689
798
  ctx.restore();
690
799
  }
691
800
 
692
801
  // Plasma thumb (pulsing) -- keep the glow the beam left on
693
- ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
802
+ ctx.shadowBlur = 10; ctx.shadowColor = P.accent;
694
803
  ctx.fillStyle = '#fff';
695
804
  ctx.beginPath();
696
805
  ctx.arc(tx, 14, 8 + Math.sin(pulseTime) * 2, 0, Math.PI * 2);
697
806
  ctx.fill();
698
807
  ctx.shadowBlur = 0;
699
808
 
700
- drawStateLabel(ctx, PCT[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);
701
810
 
702
811
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
703
812
  },
@@ -710,7 +819,11 @@ export function LaserSlider() {
710
819
  // ===========================================================
711
820
 
712
821
  /** 1. Pendulum Toggle -- The knob swings like a pendulum with overshoot. */
713
- 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)';
714
827
  let angle = -0.4, velocity = 0;
715
828
  const STIFFNESS = 12, DAMPING = 3.5;
716
829
  return {
@@ -721,24 +834,26 @@ export function PendulumToggle() {
721
834
  velocity *= (1 - DAMPING * dt);
722
835
  angle += velocity * dt;
723
836
 
724
- 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)';
725
838
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
726
839
 
727
840
  const cx = st.w / 2, cy = -10, len = st.h / 2 + 14;
728
841
  const bx = cx + Math.sin(angle) * len, by = cy + Math.cos(angle) * len;
729
842
 
730
- 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)';
731
844
  ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(bx, by); ctx.stroke();
732
- ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
845
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
733
846
  ctx.beginPath(); ctx.arc(bx, by, 12, 0, Math.PI * 2); ctx.fill();
734
- label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#6ee7b6' : '#8888aa');
847
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent : P.dim2);
735
848
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
736
849
  },
737
850
  };
738
851
  }
739
852
 
740
853
  /** 2. Circuit Toggle -- Electricity flows through a circuit path when ON. */
741
- 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' });
742
857
  const rng = new Random(seed);
743
858
  // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
744
859
  const SPARKS = 16;
@@ -766,14 +881,14 @@ export function CircuitToggle({ seed = 42 } = {}) {
766
881
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
767
882
 
768
883
  // Circuit path
769
- ctx.strokeStyle = st.toggled ? '#22d3ee' : 'rgba(255,255,255,.06)';
884
+ ctx.strokeStyle = st.toggled ? P.accent : 'rgba(255,255,255,.06)';
770
885
  ctx.lineWidth = 2; ctx.setLineDash(DASH_FLOW); ctx.lineDashOffset = -flowT * 16;
771
886
  ctx.beginPath(); ctx.moveTo(8, st.h / 2); ctx.lineTo(st.w - 8, st.h / 2); ctx.stroke();
772
887
  ctx.setLineDash(DASH_NONE); ctx.lineDashOffset = 0;
773
888
 
774
889
  // Flowing sparks -- fixed pool, per-spark alpha via globalAlpha
775
890
  if (st.toggled) {
776
- ctx.fillStyle = '#22d3ee';
891
+ ctx.fillStyle = P.accent;
777
892
  let alive = 0;
778
893
  for (let i = 0; i < SPARKS; i++) {
779
894
  const s = spark[i];
@@ -790,16 +905,21 @@ export function CircuitToggle({ seed = 42 } = {}) {
790
905
  if (alive < 12) spawn(0);
791
906
  }
792
907
 
793
- ctx.fillStyle = st.toggled ? '#22d3ee' : '#9999b8';
908
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
794
909
  ctx.beginPath(); ctx.arc(knobX, st.h / 2, 12, 0, Math.PI * 2); ctx.fill();
795
- label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#22d3ee' : '#8888aa');
910
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent : P.dim2);
796
911
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
797
912
  },
798
913
  };
799
914
  }
800
915
 
801
916
  /** 3. Lightning Toggle -- Electric arc between endpoints. */
802
- 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)';
803
923
  const rng = new Random(seed);
804
924
  let knobX = 18, arcTime = 0;
805
925
  function bolt(ctx, x1, y1, x2, y2, depth) {
@@ -814,27 +934,28 @@ export function LightningToggle({ seed = 42 } = {}) {
814
934
  knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 14);
815
935
  arcTime += dt;
816
936
 
817
- 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)';
818
938
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
819
939
 
820
940
  if (st.toggled && ((arcTime * 8) | 0) % 3 !== 0) {
821
941
  rng.reset(((now / 80) | 0) * 7 + 1);
822
- ctx.strokeStyle = 'rgba(251,191,36,.7)'; ctx.lineWidth = 1.5;
942
+ ctx.strokeStyle = arcColor; ctx.lineWidth = 1.5;
823
943
  ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
824
944
  ctx.strokeStyle = 'rgba(255,255,255,.3)'; ctx.lineWidth = 0.5;
825
945
  ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
826
946
  }
827
947
 
828
- ctx.fillStyle = st.toggled ? '#fbbf24' : '#9999b8';
948
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
829
949
  ctx.beginPath(); ctx.arc(knobX, st.h / 2, 12, 0, Math.PI * 2); ctx.fill();
830
- label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#fbbf24' : '#8888aa');
950
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent : P.dim2);
831
951
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
832
952
  },
833
953
  };
834
954
  }
835
955
 
836
956
  /** 4. DNA Toggle -- Double helix wraps around the track. */
837
- export function DNAToggle() {
957
+ export function DNAToggle(o = {}) {
958
+ const P = resolveTheme(o, { accent: '#a78bfa', accent2: '#f472b6', accent3: '#c084fc', dim: '#9999b8', dim2: '#8888aa' });
838
959
  let knobX = 18, phase = 0;
839
960
  return {
840
961
  tick(ctx, dt, now, st) {
@@ -852,9 +973,9 @@ export function DNAToggle() {
852
973
  // Const strand colors, per-dot alpha via globalAlpha (was two
853
974
  // `rgba(...,${...})` templates built per dot per frame).
854
975
  if (st.toggled) {
855
- ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.3 + Math.sin(phase + t * 6) * 0.2;
976
+ ctx.fillStyle = P.accent; ctx.globalAlpha = 0.3 + Math.sin(phase + t * 6) * 0.2;
856
977
  ctx.beginPath(); ctx.arc(x, y1, 2, 0, Math.PI * 2); ctx.fill();
857
- ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.3 + Math.cos(phase + t * 6) * 0.2;
978
+ ctx.fillStyle = P.accent2; ctx.globalAlpha = 0.3 + Math.cos(phase + t * 6) * 0.2;
858
979
  ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
859
980
  ctx.globalAlpha = 1;
860
981
  } else {
@@ -869,9 +990,9 @@ export function DNAToggle() {
869
990
  }
870
991
  }
871
992
 
872
- ctx.fillStyle = st.toggled ? '#c084fc' : '#9999b8';
993
+ ctx.fillStyle = st.toggled ? P.accent3 : P.dim;
873
994
  ctx.beginPath(); ctx.arc(knobX, cy, 12, 0, Math.PI * 2); ctx.fill();
874
- label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
995
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent3 : P.dim2);
875
996
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
876
997
  },
877
998
  };
@@ -883,7 +1004,12 @@ export function DNAToggle() {
883
1004
  // ===========================================================
884
1005
 
885
1006
  /** 5. Heartbeat Button -- Heart icon pumps with particle burst on click. */
886
- 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)';
887
1013
  const rng = new Random(seed);
888
1014
  // Fixed particle pool (life <= 0 == dead) -- no push/splice on the hot path.
889
1015
  const PARTS = 32;
@@ -910,12 +1036,12 @@ export function HeartbeatButton({ seed = 42 } = {}) {
910
1036
  beatPhase += dt * 5;
911
1037
  const pulse = 1 + Math.sin(beatPhase) * 0.03;
912
1038
 
913
- ctx.fillStyle = st.active ? 'rgba(220,20,60,.2)' : 'rgba(220,20,60,.08)';
1039
+ ctx.fillStyle = st.active ? tintOn : tintOff;
914
1040
  roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
915
1041
 
916
1042
  // Heart shape
917
1043
  ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(scale * pulse, scale * pulse);
918
- ctx.fillStyle = '#DC143C';
1044
+ ctx.fillStyle = P.accent;
919
1045
  ctx.beginPath();
920
1046
  ctx.moveTo(0, 4); ctx.bezierCurveTo(-10, -6, -20, -2, -20, 4);
921
1047
  ctx.bezierCurveTo(-20, 14, 0, 20, 0, 20);
@@ -925,7 +1051,7 @@ export function HeartbeatButton({ seed = 42 } = {}) {
925
1051
 
926
1052
  // Particles -- fixed pool, const color, alpha via globalAlpha
927
1053
  ctx.globalCompositeOperation = 'screen';
928
- ctx.fillStyle = '#ff3c64';
1054
+ ctx.fillStyle = P.accent2;
929
1055
  for (let i = 0; i < PARTS; i++) {
930
1056
  const p = part[i];
931
1057
  if (p.life <= 0) continue;
@@ -942,13 +1068,17 @@ export function HeartbeatButton({ seed = 42 } = {}) {
942
1068
  }
943
1069
 
944
1070
  /** 6. Breathing Button -- Inhale/exhale pulse with particle halo. */
945
- export function BreathingButton() {
946
- // Alpha-graded green LUT (same RGB #6ee7b6, quantized alpha) built once, so
947
- // the pulsing glow is a const-string lookup instead of three
948
- // `rgba(110,231,182,${...})` templates per frame. shadowColor's alpha is not
949
- // globalAlpha, so a LUT (not globalAlpha) is the zero-alloc fit here.
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.
950
1080
  const GREEN = [];
951
- for (let i = 0; i <= 64; i++) GREEN[i] = 'rgba(110,231,182,' + (i / 64).toFixed(3) + ')';
1081
+ for (let i = 0; i <= 64; i++) GREEN[i] = 'rgba(' + RGB + ',' + (i / 64).toFixed(3) + ')';
952
1082
  function greenA(a) { const i = a <= 0 ? 0 : a >= 1 ? 64 : (a * 64) | 0; return GREEN[i]; }
953
1083
  let phase = 0;
954
1084
  return {
@@ -968,16 +1098,20 @@ export function BreathingButton() {
968
1098
  ctx.lineWidth = 1;
969
1099
  ctx.beginPath(); ctx.arc(st.w / 2, st.h / 2, st.w / 2 + radius, 0, Math.PI * 2); ctx.stroke();
970
1100
 
971
- ctx.fillStyle = '#6ee7b6';
972
- ctx.font = "600 12px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
973
- ctx.fillText('BREATHE', st.w / 2, st.h / 2);
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);
974
1104
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
975
1105
  },
976
1106
  };
977
1107
  }
978
1108
 
979
1109
  /** 7. Ink Splash Button -- Calligraphy ink splatter on click. */
980
- 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');
981
1115
  const rng = new Random(seed);
982
1116
  // Fixed splat pool (life <= 0 == dead).
983
1117
  const SPLATS = 32;
@@ -1005,12 +1139,12 @@ export function InkSplashButton({ seed = 42 } = {}) {
1005
1139
  ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
1006
1140
 
1007
1141
  ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1008
- ctx.fillStyle = '#e2e2f0'; ctx.font = "600 13px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1009
- ctx.fillText('INK', st.w / 2, st.h / 2);
1142
+ ctx.fillStyle = P.dim; ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1143
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
1010
1144
  ctx.restore();
1011
1145
 
1012
1146
  // Splats -- fixed pool, const color, alpha via globalAlpha
1013
- ctx.fillStyle = '#1e1e32';
1147
+ ctx.fillStyle = P.accent;
1014
1148
  for (let i = 0; i < SPLATS; i++) {
1015
1149
  const s = splat[i];
1016
1150
  if (s.life <= 0) continue;
@@ -1026,7 +1160,12 @@ export function InkSplashButton({ seed = 42 } = {}) {
1026
1160
  }
1027
1161
 
1028
1162
  /** 8. Pixel Dissolve Button -- Hover breaks into floating pixels, reforms on leave. */
1029
- 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');
1030
1169
  const rng = new Random(seed);
1031
1170
  const N = cols * rows;
1032
1171
  const ox = new Float32Array(N), oy = new Float32Array(N);
@@ -1047,7 +1186,7 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1047
1186
  tick(ctx, dt, now, st) {
1048
1187
  dissolveT = lerp(dissolveT, st.hover ? 1 : 0, dt * 6);
1049
1188
 
1050
- 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)';
1051
1190
  const pw = st.w / cols, ph = st.h / rows;
1052
1191
 
1053
1192
  for (let i = 0; i < N; i++) {
@@ -1061,10 +1200,10 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1061
1200
  ctx.globalAlpha = 1;
1062
1201
 
1063
1202
  if (dissolveT < 0.3) {
1064
- ctx.fillStyle = '#e2e2f0'; ctx.font = "600 12px 'Space Grotesk',sans-serif";
1203
+ ctx.fillStyle = P.dim; ctx.font = FONT;
1065
1204
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1066
1205
  ctx.globalAlpha = 1 - dissolveT / 0.3;
1067
- ctx.fillText('DISSOLVE', st.w / 2, st.h / 2);
1206
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
1068
1207
  ctx.globalAlpha = 1;
1069
1208
  }
1070
1209
  if (st.focused) focusRing(ctx, st.w, st.h, 0);
@@ -1073,9 +1212,16 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1073
1212
  }
1074
1213
 
1075
1214
  /** 9. Firework Button -- Shoots fireworks upward on click. */
1076
- 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');
1077
1224
  const rng = new Random(seed);
1078
- const colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff6bcb'];
1079
1225
  // Fixed pools (life <= 0 == dead) -- no push/splice on the hot path.
1080
1226
  const ROCKETS = 16, SPARKS = 256;
1081
1227
  const rocket = [];
@@ -1110,8 +1256,8 @@ export function FireworkButton({ seed = 42 } = {}) {
1110
1256
  pressScale = lerp(pressScale, 1, dt * 10);
1111
1257
  ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
1112
1258
  ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
1113
- ctx.fillStyle = '#fbbf24'; ctx.font = "600 13px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1114
- ctx.fillText('\u{1F386} FIRE', st.w / 2, st.h / 2);
1259
+ ctx.fillStyle = labelColor; ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1260
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
1115
1261
  ctx.restore();
1116
1262
 
1117
1263
  // Rockets -- fixed pool; on death, burst into sparks
@@ -1148,9 +1294,12 @@ export function FireworkButton({ seed = 42 } = {}) {
1148
1294
  // ===========================================================
1149
1295
 
1150
1296
  /** 10. Aurora Slider -- Northern lights colors flow along the filled track. */
1151
- export function AuroraSlider() {
1297
+ export function AuroraSlider(o = {}) {
1152
1298
  const REF = 100, PHASES = 12;
1153
- const colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#c084fc'];
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';
1154
1303
  let time = 0;
1155
1304
  // The shimmer animated the gradient stops per frame. Pre-build one gradient
1156
1305
  // per animation phase in init (all 0..REF) and cycle them by time; scale to
@@ -1163,7 +1312,7 @@ export function AuroraSlider() {
1163
1312
  const g = ctx.createLinearGradient(0, 0, REF, 0);
1164
1313
  for (let i = 0; i < 4; i++) {
1165
1314
  const pos = clamp(i / 3 + Math.sin(t + i) * 0.15, 0, 1);
1166
- g.addColorStop(pos, colors[i]);
1315
+ g.addColorStop(pos, colors[i % colors.length]);
1167
1316
  }
1168
1317
  beams[p] = g;
1169
1318
  }
@@ -1176,7 +1325,7 @@ export function AuroraSlider() {
1176
1325
  const beam = beams[((time * 2) | 0) % PHASES] || beams[0];
1177
1326
  ctx.save();
1178
1327
  ctx.scale(tx / REF, 1);
1179
- ctx.fillStyle = beam; ctx.shadowBlur = 8; ctx.shadowColor = '#6ee7b6';
1328
+ ctx.fillStyle = beam; ctx.shadowBlur = 8; ctx.shadowColor = glowColor;
1180
1329
  roundRect(ctx, 0, 12, REF, 4, 2); ctx.fill();
1181
1330
  ctx.restore();
1182
1331
  ctx.shadowBlur = 0;
@@ -1189,7 +1338,9 @@ export function AuroraSlider() {
1189
1338
  }
1190
1339
 
1191
1340
  /** 11. Wave Slider -- Audio waveform visualization on the filled track. */
1192
- export function WaveSlider({ seed = 42 } = {}) {
1341
+ export function WaveSlider(o = {}) {
1342
+ const { seed = 42 } = o;
1343
+ const P = resolveTheme(o, { accent: '#f472b6' });
1193
1344
  const rng = new Random(seed);
1194
1345
  const bars = new Float32Array(40);
1195
1346
  return {
@@ -1200,10 +1351,10 @@ export function WaveSlider({ seed = 42 } = {}) {
1200
1351
  for (let i = 0; i < 40; i++) {
1201
1352
  const bx = i * bw, active = bx < tx;
1202
1353
  const h = bars[i] * 16 * (0.5 + Math.sin(now / 300 + i * 0.3) * 0.3);
1203
- ctx.fillStyle = active ? '#f472b6' : 'rgba(255,255,255,.06)';
1354
+ ctx.fillStyle = active ? P.accent : 'rgba(255,255,255,.06)';
1204
1355
  ctx.fillRect(bx + 1, 14 - h / 2, bw - 2, h);
1205
1356
  }
1206
- ctx.fillStyle = '#f472b6'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
1357
+ ctx.fillStyle = P.accent; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
1207
1358
  label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1208
1359
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1209
1360
  },
@@ -1211,7 +1362,8 @@ export function WaveSlider({ seed = 42 } = {}) {
1211
1362
  }
1212
1363
 
1213
1364
  /** 12. Elastic Band Slider -- Track rubber-bands ahead of thumb, snaps back. */
1214
- export function ElasticBandSlider() {
1365
+ export function ElasticBandSlider(o = {}) {
1366
+ const P = resolveTheme(o, { accent: '#fb923c' });
1215
1367
  let leadX = 0;
1216
1368
  return {
1217
1369
  tick(ctx, dt, now, st) {
@@ -1222,12 +1374,12 @@ export function ElasticBandSlider() {
1222
1374
  ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
1223
1375
 
1224
1376
  // Elastic band (curves toward target)
1225
- ctx.strokeStyle = '#fb923c'; ctx.lineWidth = 3;
1377
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 3;
1226
1378
  ctx.beginPath(); ctx.moveTo(0, 14);
1227
1379
  ctx.quadraticCurveTo(leadX + stretch, 14 + stretch * 0.2, tx, 14);
1228
1380
  ctx.stroke();
1229
1381
 
1230
- ctx.fillStyle = '#fb923c'; ctx.beginPath(); ctx.arc(tx, 14, 9, 0, Math.PI * 2); ctx.fill();
1382
+ ctx.fillStyle = P.accent; ctx.beginPath(); ctx.arc(tx, 14, 9, 0, Math.PI * 2); ctx.fill();
1231
1383
  label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1232
1384
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1233
1385
  },
@@ -1235,7 +1387,8 @@ export function ElasticBandSlider() {
1235
1387
  }
1236
1388
 
1237
1389
  /** 13. Gravity Slider -- Track sags under the weight of the thumb. */
1238
- export function GravitySlider() {
1390
+ export function GravitySlider(o = {}) {
1391
+ const P = resolveTheme(o, { accent: '#c084fc' });
1239
1392
  return {
1240
1393
  tick(ctx, dt, now, st) {
1241
1394
  const tx = st.val * st.w, sag = 8;
@@ -1247,13 +1400,13 @@ export function GravitySlider() {
1247
1400
  ctx.stroke();
1248
1401
 
1249
1402
  // Filled portion follows the sag
1250
- ctx.strokeStyle = '#c084fc'; ctx.lineWidth = 3;
1403
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 3;
1251
1404
  ctx.beginPath(); ctx.moveTo(0, 14);
1252
1405
  const midSag = sag * (tx / st.w);
1253
1406
  ctx.quadraticCurveTo(tx / 2, 14 + midSag, tx, 14 + sag * Math.sin(Math.PI * st.val));
1254
1407
  ctx.stroke();
1255
1408
 
1256
- ctx.fillStyle = '#c084fc'; ctx.beginPath();
1409
+ ctx.fillStyle = P.accent; ctx.beginPath();
1257
1410
  ctx.arc(tx, 14 + sag * Math.sin(Math.PI * st.val), 10, 0, Math.PI * 2); ctx.fill();
1258
1411
 
1259
1412
  label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 14);
@@ -1268,12 +1421,13 @@ export function GravitySlider() {
1268
1421
  // ===========================================================
1269
1422
 
1270
1423
  /** 14. Orbit Loader -- Planets orbit a sun. Toggle starts/stops. */
1271
- export function OrbitLoader() {
1424
+ export function OrbitLoader(o = {}) {
1425
+ const P = resolveTheme(o, { accent: '#38bdf8', accent2: '#6ee7b6', accent3: '#fbbf24', dim2: '#8888aa' });
1272
1426
  let phase = 0;
1273
1427
  const planets = [
1274
- { r: 14, speed: 2.0, size: 3, color: '#38bdf8' },
1275
- { r: 22, speed: 1.2, size: 2.5, color: '#6ee7b6' },
1276
- { r: 30, speed: 0.7, size: 2, color: '#fbbf24' },
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 },
1277
1431
  ];
1278
1432
  return {
1279
1433
  tick(ctx, dt, now, st) {
@@ -1291,15 +1445,16 @@ export function OrbitLoader() {
1291
1445
  }
1292
1446
 
1293
1447
  // Sun
1294
- ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
1295
- label(ctx, st.toggled ? 'LOADING...' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#fbbf24' : '#8888aa');
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);
1296
1450
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
1297
1451
  },
1298
1452
  };
1299
1453
  }
1300
1454
 
1301
1455
  /** 15. Helix Loader -- DNA double helix spinning. */
1302
- export function HelixLoader() {
1456
+ export function HelixLoader(o = {}) {
1457
+ const P = resolveTheme(o, { accent: '#a78bfa', accent2: '#f472b6', accent3: '#c084fc', dim2: '#8888aa' });
1303
1458
  let phase = 0;
1304
1459
  return {
1305
1460
  tick(ctx, dt, now, st) {
@@ -1314,16 +1469,16 @@ export function HelixLoader() {
1314
1469
 
1315
1470
  // Const strand colors, per-node alpha via globalAlpha (was three
1316
1471
  // `rgba(...,${...})` templates per node per frame).
1317
- ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.2 + depth * 0.6;
1472
+ ctx.fillStyle = P.accent; ctx.globalAlpha = 0.2 + depth * 0.6;
1318
1473
  ctx.beginPath(); ctx.arc(x1, y1, 2 + depth, 0, Math.PI * 2); ctx.fill();
1319
- ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.2 + (1 - depth) * 0.6;
1474
+ ctx.fillStyle = P.accent2; ctx.globalAlpha = 0.2 + (1 - depth) * 0.6;
1320
1475
  ctx.beginPath(); ctx.arc(x2, y2, 2 + (1 - depth), 0, Math.PI * 2); ctx.fill();
1321
1476
 
1322
1477
  ctx.strokeStyle = '#ffffff'; ctx.globalAlpha = 0.03 + depth * 0.04;
1323
1478
  ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
1324
1479
  }
1325
1480
  ctx.globalAlpha = 1;
1326
- label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
1481
+ label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? P.accent3 : P.dim2);
1327
1482
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
1328
1483
  },
1329
1484
  };
@@ -1335,7 +1490,8 @@ export function HelixLoader() {
1335
1490
  // ===========================================================
1336
1491
 
1337
1492
  /** 16. Ripple Checkbox -- Material-style ripple ring + morphing checkmark. */
1338
- export function RippleCheck() {
1493
+ export function RippleCheck(o = {}) {
1494
+ const P = resolveTheme(o, { accent: '#6ee7b6', surface: '#000', dim2: '#8888aa' });
1339
1495
  // Fixed ripple pool (life <= 0 == dead) -- no push/splice on the hot path.
1340
1496
  const RIPPLES = 8;
1341
1497
  const ripR = new Float64Array(RIPPLES);
@@ -1353,11 +1509,11 @@ export function RippleCheck() {
1353
1509
  const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
1354
1510
 
1355
1511
  // Box
1356
- ctx.fillStyle = st.toggled ? '#6ee7b6' : 'rgba(255,255,255,.06)';
1512
+ ctx.fillStyle = st.toggled ? P.accent : 'rgba(255,255,255,.06)';
1357
1513
  roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1358
1514
 
1359
1515
  // Ripples -- fixed pool, const color, alpha via globalAlpha
1360
- ctx.strokeStyle = '#6ee7b6'; ctx.lineWidth = 2;
1516
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 2;
1361
1517
  for (let i = 0; i < RIPPLES; i++) {
1362
1518
  if (ripLife[i] <= 0) continue;
1363
1519
  ripR[i] += 50 * dt; ripLife[i] -= 2 * dt;
@@ -1369,7 +1525,7 @@ export function RippleCheck() {
1369
1525
 
1370
1526
  // Checkmark (animated draw)
1371
1527
  if (checkT > 0.01) {
1372
- ctx.strokeStyle = '#000'; ctx.lineWidth = 3; ctx.lineCap = 'round';
1528
+ ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
1373
1529
  ctx.beginPath();
1374
1530
  const p1 = clamp(checkT * 2, 0, 1); // First stroke
1375
1531
  const p2 = clamp(checkT * 2 - 1, 0, 1); // Second stroke
@@ -1378,14 +1534,15 @@ export function RippleCheck() {
1378
1534
  if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
1379
1535
  ctx.stroke(); ctx.lineCap = 'butt';
1380
1536
  }
1381
- 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);
1382
1538
  if (st.focused) focusRing(ctx, sz, sz, 6);
1383
1539
  },
1384
1540
  };
1385
1541
  }
1386
1542
 
1387
1543
  /** 17. Morph Checkbox -- X morphs into checkmark smoothly. */
1388
- export function MorphCheck() {
1544
+ export function MorphCheck(o = {}) {
1545
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8', dim2: '#8888aa' });
1389
1546
  let t = 0;
1390
1547
  return {
1391
1548
  tick(ctx, dt, now, st) {
@@ -1397,12 +1554,12 @@ export function MorphCheck() {
1397
1554
  ctx.fillStyle = 'rgba(255,255,255,.06)';
1398
1555
  roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1399
1556
  } else {
1400
- ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = lerp(0, 0.2, t);
1557
+ ctx.fillStyle = P.accent; ctx.globalAlpha = lerp(0, 0.2, t);
1401
1558
  roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1402
1559
  ctx.globalAlpha = 1;
1403
1560
  }
1404
1561
 
1405
- 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';
1406
1563
 
1407
1564
  // Morph: X -> checkmark by interpolating endpoints
1408
1565
  const x1a = cx - 8, y1a = cy - 8; // X top-left
@@ -1425,7 +1582,7 @@ export function MorphCheck() {
1425
1582
  ctx.stroke();
1426
1583
  ctx.lineCap = 'butt';
1427
1584
 
1428
- 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);
1429
1586
  if (st.focused) focusRing(ctx, sz, sz, 6);
1430
1587
  },
1431
1588
  };
@@ -1437,7 +1594,11 @@ export function MorphCheck() {
1437
1594
  // ===========================================================
1438
1595
 
1439
1596
  /** 18. Flame Counter -- Number with rising heat particles driven by slider value. */
1440
- 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');
1441
1602
  const rng = new Random(seed);
1442
1603
  // Fixed ember pool + heat-color LUT (rgb quantized by value), so neither the
1443
1604
  // pool nor the per-ember color allocates. The number string is rebuilt only
@@ -1480,18 +1641,22 @@ export function FlameCounter({ seed = 42 } = {}) {
1480
1641
  ctx.globalCompositeOperation = 'source-over';
1481
1642
 
1482
1643
  // Number
1483
- ctx.fillStyle = st.val > 0.7 ? '#ff6b6b' : st.val > 0.3 ? '#fbbf24' : '#9999b8';
1484
- ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
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';
1485
1646
  ctx.fillText(numStr, st.w / 2, st.h / 2);
1486
1647
 
1487
- label(ctx, 'HEAT', st.w / 2, st.h + 12);
1648
+ label(ctx, TXT, st.w / 2, st.h + 12);
1488
1649
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
1489
1650
  },
1490
1651
  };
1491
1652
  }
1492
1653
 
1493
1654
  /** 19. Glitch Counter -- Number glitches and jitters as slider value increases. */
1494
- 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');
1495
1660
  const rng = new Random(seed);
1496
1661
  let numStr = '0', lastNum = -1;
1497
1662
  return {
@@ -1515,22 +1680,22 @@ export function GlitchCounter({ seed = 42 } = {}) {
1515
1680
 
1516
1681
  if (intensity > 0.3 && rng.next() > 0.6) {
1517
1682
  ctx.globalCompositeOperation = 'screen';
1518
- ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1683
+ ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1519
1684
  // RGB split -- const channel colors, shared alpha via globalAlpha
1520
1685
  ctx.globalAlpha = intensity * 0.4;
1521
- ctx.fillStyle = '#ff0055';
1686
+ ctx.fillStyle = P.accent;
1522
1687
  ctx.fillText(numStr, st.w / 2 + jx - 2, st.h / 2 + jy);
1523
- ctx.fillStyle = '#00ffcc';
1688
+ ctx.fillStyle = P.accent2;
1524
1689
  ctx.fillText(numStr, st.w / 2 + jx + 2, st.h / 2 + jy);
1525
1690
  ctx.globalAlpha = 1;
1526
1691
  ctx.globalCompositeOperation = 'source-over';
1527
1692
  }
1528
1693
 
1529
- ctx.fillStyle = '#e2e2f0';
1530
- ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1694
+ ctx.fillStyle = P.dim;
1695
+ ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1531
1696
  ctx.fillText(numStr, st.w / 2 + jx, st.h / 2 + jy);
1532
1697
 
1533
- label(ctx, 'SIGNAL', st.w / 2, st.h + 12);
1698
+ label(ctx, TXT, st.w / 2, st.h + 12);
1534
1699
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
1535
1700
  },
1536
1701
  };
@@ -1542,7 +1707,9 @@ export function GlitchCounter({ seed = 42 } = {}) {
1542
1707
  // ===========================================================
1543
1708
 
1544
1709
  /** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
1545
- export function BubbleRating({ seed = 42 } = {}) {
1710
+ export function BubbleRating(o = {}) {
1711
+ const { seed = 42 } = o;
1712
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8' });
1546
1713
  const rng = new Random(seed);
1547
1714
  const R5 = ['0 / 5', '1 / 5', '2 / 5', '3 / 5', '4 / 5', '5 / 5']; // const labels
1548
1715
  const POPS = 32;
@@ -1575,11 +1742,11 @@ export function BubbleRating({ seed = 42 } = {}) {
1575
1742
  const cx = gap * i + gap / 2, cy = st.h / 2;
1576
1743
 
1577
1744
  // Bubble -- const color, active alpha via globalAlpha
1578
- if (active) { ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
1745
+ if (active) { ctx.fillStyle = P.accent; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
1579
1746
  else { ctx.fillStyle = 'rgba(255,255,255,.05)'; ctx.globalAlpha = 1; }
1580
1747
  ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.fill();
1581
1748
  ctx.globalAlpha = 1;
1582
- ctx.strokeStyle = active ? '#38bdf8' : 'rgba(255,255,255,.08)';
1749
+ ctx.strokeStyle = active ? P.accent : 'rgba(255,255,255,.08)';
1583
1750
  ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.stroke();
1584
1751
 
1585
1752
  // Highlight
@@ -1590,7 +1757,7 @@ export function BubbleRating({ seed = 42 } = {}) {
1590
1757
  }
1591
1758
 
1592
1759
  // Pop particles -- fixed pool, const color, alpha via globalAlpha
1593
- ctx.fillStyle = '#38bdf8';
1760
+ ctx.fillStyle = P.accent;
1594
1761
  for (let i = 0; i < POPS; i++) {
1595
1762
  const p = pop[i];
1596
1763
  if (p.life <= 0) continue;
@@ -1601,7 +1768,7 @@ export function BubbleRating({ seed = 42 } = {}) {
1601
1768
  }
1602
1769
  ctx.globalAlpha = 1;
1603
1770
 
1604
- label(ctx, R5[rating], 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);
1605
1772
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1606
1773
  },
1607
1774
  onDrag(val) {
@@ -1620,14 +1787,18 @@ export function BubbleRating({ seed = 42 } = {}) {
1620
1787
  // ===========================================================
1621
1788
 
1622
1789
  /** 1. Volume Knob -- Rotary dial with tick marks and arc indicator. */
1623
- export function VolumeKnob() {
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');
1624
1795
  let displayVal = 0, grad = null;
1625
1796
  let numStr = '0', lastNum = -1;
1626
1797
  return {
1627
1798
  init(c, w, h) {
1628
1799
  // Value-arc gradient depends only on w/h (fixed per mount) -> build once.
1629
1800
  grad = c.createLinearGradient(0, h, w, 0);
1630
- grad.addColorStop(0, '#6ee7b6'); grad.addColorStop(1, '#38bdf8');
1801
+ grad.addColorStop(0, P.accent); grad.addColorStop(1, P.accent2);
1631
1802
  },
1632
1803
  tick(c,dt,now,st) {
1633
1804
  displayVal = lerp(displayVal, st.val, dt * 12);
@@ -1647,7 +1818,7 @@ export function VolumeKnob() {
1647
1818
  for(let i=0;i<=10;i++){
1648
1819
  const a=startA+i/10*range;
1649
1820
  const inner=R-6, outer=R+2;
1650
- 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)';
1651
1822
  c.lineWidth=1; c.beginPath();
1652
1823
  c.moveTo(cx+Math.cos(a)*inner,cy+Math.sin(a)*inner);
1653
1824
  c.lineTo(cx+Math.cos(a)*outer,cy+Math.sin(a)*outer);
@@ -1665,18 +1836,19 @@ export function VolumeKnob() {
1665
1836
  // Value text (rebuilt only when the integer changes)
1666
1837
  const v = Math.round(displayVal * 100);
1667
1838
  if (v !== lastNum) { lastNum = v; numStr = String(v); }
1668
- c.fillStyle='#e2e2f0'; c.font="700 14px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
1839
+ c.fillStyle=P.dim; c.font=FONT; c.textAlign='center'; c.textBaseline='middle';
1669
1840
  c.fillText(numStr,cx,cy);
1670
- lbl(c,'VOLUME',cx,st.h+10);
1841
+ lbl(c,TXT,cx,st.h+10);
1671
1842
  if(st.focused)fr(c,st.w,st.h,st.h/2);
1672
1843
  },
1673
1844
  };
1674
1845
  }
1675
1846
 
1676
1847
  /** 2. Compass Knob -- Needle points based on slider value (0=N, 0.5=S, 1=N). */
1677
- export function CompassKnob() {
1848
+ export function CompassKnob(o = {}) {
1849
+ const P = resolveTheme(o, { accent: '#ff6b6b', dim: '#9999b8', surface: '#333' });
1678
1850
  const dirs = ['N', 'E', 'S', 'W']; // hoisted out of tick
1679
- const cols = ['#ff6b6b', '#9999b8', '#9999b8', '#9999b8']; // (were per-frame arrays)
1851
+ const cols = [P.accent, P.dim, P.dim, P.dim]; // (were per-frame arrays)
1680
1852
  let needleA = 0;
1681
1853
  let degStr = '0\u00B0', lastDeg = -1;
1682
1854
  return {
@@ -1698,14 +1870,14 @@ export function CompassKnob() {
1698
1870
 
1699
1871
  // Needle
1700
1872
  c.save(); c.translate(cx,cy); c.rotate(needleA-Math.PI/2);
1701
- c.fillStyle='#ff6b6b';
1873
+ c.fillStyle=P.accent;
1702
1874
  c.beginPath(); c.moveTo(0,-R+18); c.lineTo(-4,4); c.lineTo(4,4); c.closePath(); c.fill();
1703
1875
  c.fillStyle='rgba(255,255,255,.15)';
1704
1876
  c.beginPath(); c.moveTo(0,R-18); c.lineTo(-4,-4); c.lineTo(4,-4); c.closePath(); c.fill();
1705
1877
  c.restore();
1706
1878
 
1707
1879
  // Center pin
1708
- 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();
1709
1881
  c.strokeStyle='rgba(255,255,255,.1)'; c.lineWidth=1; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.stroke();
1710
1882
 
1711
1883
  const deg = Math.round(st.val * 360);
@@ -1722,7 +1894,11 @@ export function CompassKnob() {
1722
1894
  // ===========================================================
1723
1895
 
1724
1896
  /** 3. Ring Progress -- Circular progress with animated fill and particles at the tip. */
1725
- 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");
1726
1902
  const rng=new Random(seed);
1727
1903
  // Fixed spark pool + a bounded set of conic gradients (one per progress
1728
1904
  // level) built in init, so the green stop still tracks the arc tip with zero
@@ -1743,9 +1919,9 @@ export function RingProgress({seed=42}={}) {
1743
1919
  init(c, w, h) {
1744
1920
  for (let p = 0; p < GPHASES; p++) {
1745
1921
  const g = c.createConicGradient(-Math.PI / 2, w / 2, h / 2);
1746
- g.addColorStop(0, '#a78bfa');
1747
- g.addColorStop((p / (GPHASES - 1)) * 0.95, '#6ee7b6');
1748
- g.addColorStop(1, 'rgba(110,231,182,.2)');
1922
+ g.addColorStop(0, P.accent2);
1923
+ g.addColorStop((p / (GPHASES - 1)) * 0.95, P.accent);
1924
+ g.addColorStop(1, tail);
1749
1925
  grads[p] = g;
1750
1926
  }
1751
1927
  },
@@ -1767,7 +1943,7 @@ export function RingProgress({seed=42}={}) {
1767
1943
  const tx=cx+Math.cos(ea)*R,ty=cy+Math.sin(ea)*R;
1768
1944
  spawn(tx, ty);
1769
1945
  }
1770
- c.fillStyle = '#6ee7b6';
1946
+ c.fillStyle = P.accent;
1771
1947
  for(let i=0;i<SPARKS;i++){
1772
1948
  const s=spark[i];
1773
1949
  if(s.life<=0)continue;
@@ -1779,7 +1955,7 @@ export function RingProgress({seed=42}={}) {
1779
1955
  c.globalAlpha=1;
1780
1956
 
1781
1957
  // Percentage
1782
- c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
1958
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1783
1959
  c.fillText(PCT[Math.round(displayVal*100)],cx,cy);
1784
1960
  if(st.focused)fr(c,st.w,st.h,st.h/2);
1785
1961
  },
@@ -1787,7 +1963,9 @@ export function RingProgress({seed=42}={}) {
1787
1963
  }
1788
1964
 
1789
1965
  /** 4. Battery Gauge -- Battery icon that fills and changes color. */
1790
- 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");
1791
1969
  let displayVal=0;
1792
1970
  return {
1793
1971
  tick(c,dt,now,st) {
@@ -1803,7 +1981,7 @@ export function BatteryGauge() {
1803
1981
 
1804
1982
  // Fill
1805
1983
  const fillW=Math.max(0,(bw-6)*displayVal);
1806
- const col=displayVal<.2?'#ff6b6b':displayVal<.5?'#fbbf24':'#6ee7b6';
1984
+ const col=displayVal<.2?P.accent3:displayVal<.5?P.accent2:P.accent;
1807
1985
  c.fillStyle=col;
1808
1986
  rr(c,bx+3,by+3,fillW,bh-6,2);c.fill();
1809
1987
 
@@ -1820,7 +1998,7 @@ export function BatteryGauge() {
1820
1998
  c.fillStyle='rgba(255,80,80,.15)';rr(c,bx,by,bw,bh,r);c.fill();
1821
1999
  }
1822
2000
 
1823
- c.fillStyle='#e2e2f0';c.font="600 10px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2001
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1824
2002
  c.fillText(PCT[Math.round(displayVal*100)],bx+bw/2,by+bh/2);
1825
2003
  if(st.focused)fr(c,st.w+4,st.h,r);
1826
2004
  },
@@ -1828,7 +2006,8 @@ export function BatteryGauge() {
1828
2006
  }
1829
2007
 
1830
2008
  /** 5. Signal Meter -- WiFi-style signal bars. */
1831
- export function SignalMeter() {
2009
+ export function SignalMeter(o = {}) {
2010
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#fbbf24', accent3: '#ff6b6b', dim: '#9999b8' });
1832
2011
  let bars=[0,0,0,0,0];
1833
2012
  return {
1834
2013
  tick(c,dt,now,st) {
@@ -1844,10 +2023,10 @@ export function SignalMeter() {
1844
2023
  const maxH=8+i*6, h=maxH*bars[i]+2;
1845
2024
  const x=ox+i*(bw+gap), y=st.h-6-h;
1846
2025
  const active=bars[i]>.5;
1847
- 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)';
1848
2027
  rr(c,x,y,bw,h,2);c.fill();
1849
2028
  }
1850
- 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);
1851
2030
  if(st.focused)fr(c,st.w,st.h,4);
1852
2031
  },
1853
2032
  };
@@ -1859,7 +2038,12 @@ export function SignalMeter() {
1859
2038
  // ===========================================================
1860
2039
 
1861
2040
  /** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
1862
- 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");
1863
2047
  let indicatorX=0, indicatorW=0, selected=0;
1864
2048
  const labels=['Alpha','Beta','Gamma'];
1865
2049
  return {
@@ -1877,13 +2061,13 @@ export function PillTabs() {
1877
2061
  c.fillStyle='rgba(255,255,255,.03)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
1878
2062
 
1879
2063
  // Indicator
1880
- 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;
1881
2065
  rr(c,indicatorX+2,2,indicatorW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
1882
2066
 
1883
2067
  // Labels
1884
- c.font="600 11px 'Space Grotesk',sans-serif";c.textAlign='center';c.textBaseline='middle';
2068
+ c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1885
2069
  for(let i=0;i<3;i++){
1886
- c.fillStyle=i===selected?'#c4b5fd':'#9999b8';
2070
+ c.fillStyle=i===selected?P.accent:P.dim;
1887
2071
  c.fillText(labels[i],tw*i+tw/2,st.h/2);
1888
2072
  }
1889
2073
  if(st.focused)fr(c,st.w,st.h,st.h/2);
@@ -1892,7 +2076,12 @@ export function PillTabs() {
1892
2076
  }
1893
2077
 
1894
2078
  /** 7. Stepper -- +/- buttons with spring counter. */
1895
- 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");
1896
2085
  let count=0,displayCount=0,flashDir=0,flashTimer=0;
1897
2086
  return {
1898
2087
  onClick(x,y,st) {
@@ -1909,18 +2098,18 @@ export function Stepper() {
1909
2098
 
1910
2099
  // Minus zone
1911
2100
  const third=st.w/3;
1912
- 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)';
1913
2102
  rr(c,2,2,third-4,st.h-4,8);c.fill();
1914
- c.fillStyle='#ff6b6b';c.font="700 18px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2103
+ c.fillStyle=P.accent2;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1915
2104
  c.fillText('\u2212',third/2,st.h/2);
1916
2105
 
1917
2106
  // Plus zone
1918
- 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)';
1919
2108
  rr(c,third*2+2,2,third-4,st.h-4,8);c.fill();
1920
- c.fillStyle='#6ee7b6';c.fillText('+',third*2+third/2,st.h/2);
2109
+ c.fillStyle=P.accent;c.fillText('+',third*2+third/2,st.h/2);
1921
2110
 
1922
2111
  // Counter
1923
- c.fillStyle='#e2e2f0';c.font="700 20px 'JetBrains Mono',monospace";
2112
+ c.fillStyle=P.dim;c.font="700 20px 'JetBrains Mono',monospace";
1924
2113
  c.fillText(Math.round(displayCount),st.w/2,st.h/2);
1925
2114
 
1926
2115
  if(st.focused)fr(c,st.w,st.h,10);
@@ -1929,9 +2118,14 @@ export function Stepper() {
1929
2118
  }
1930
2119
 
1931
2120
  /** 8. Radio Orbit -- 4 options arranged in a circle. Slider picks one. */
1932
- export function RadioOrbit() {
2121
+ export function RadioOrbit(o = {}) {
1933
2122
  let selectedGlow=new Float32Array(4);
1934
- 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]];
1935
2129
  const OPTS=['OPTION A','OPTION B','OPTION C','OPTION D']; // const (was 'OPTION '+name concat)
1936
2130
  return {
1937
2131
  tick(c,dt,now,st) {
@@ -1972,9 +2166,14 @@ export function RadioOrbit() {
1972
2166
  // ===========================================================
1973
2167
 
1974
2168
  /** 9. Password Strength -- Segmented bar with color progression and label. */
1975
- export function PasswordStrength() {
2169
+ export function PasswordStrength(o = {}) {
1976
2170
  let segs=[0,0,0,0];
1977
- 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';
1978
2177
  return {
1979
2178
  tick(c,dt,now,st) {
1980
2179
  const level=Math.ceil(st.val*4);
@@ -1993,14 +2192,23 @@ export function PasswordStrength() {
1993
2192
  }
1994
2193
 
1995
2194
  const idx=clamp(level-1,0,3);
1996
- lbl(c,level>0?labels[idx]:'NONE',st.w/2,st.h/2+16,level>0?colors[idx]:'#666');
2195
+ lbl(c,level>0?labels[idx]:'NONE',st.w/2,st.h/2+16,level>0?colors[idx]:noneColor);
1997
2196
  if(st.focused)fr(c,st.w,st.h,4);
1998
2197
  },
1999
2198
  };
2000
2199
  }
2001
2200
 
2002
2201
  /** 10. Water Level -- Animated wave surface inside a container. */
2003
- 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");
2004
2212
  let displayVal=0, wavePhase=0;
2005
2213
  return {
2006
2214
  tick(c,dt,now,st) {
@@ -2016,7 +2224,7 @@ export function WaterLevel() {
2016
2224
  c.save();c.beginPath();rr(c,bx+1,by+1,bw-2,bh-2,5);c.clip();
2017
2225
 
2018
2226
  // Wave surface
2019
- 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;
2020
2228
  c.beginPath();c.moveTo(bx,by+bh);
2021
2229
  for(let x=0;x<=bw;x++){
2022
2230
  const wave=Math.sin(wavePhase+x*0.08)*3+Math.sin(wavePhase*1.5+x*0.12)*2;
@@ -2025,12 +2233,12 @@ export function WaterLevel() {
2025
2233
  c.lineTo(bx+bw,by+bh);c.closePath();c.fill();
2026
2234
 
2027
2235
  // Deeper water
2028
- 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;
2029
2237
  c.fillRect(bx,waterY+5,bw,bh);
2030
2238
 
2031
2239
  c.restore();
2032
2240
 
2033
- c.fillStyle='#e2e2f0';c.font="700 12px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2241
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2034
2242
  c.fillText(PCT[Math.round(displayVal*100)],st.w/2,st.h/2);
2035
2243
  if(st.focused)fr(c,st.w,st.h,6);
2036
2244
  },
@@ -2038,14 +2246,21 @@ export function WaterLevel() {
2038
2246
  }
2039
2247
 
2040
2248
  /** 11. Heat Map -- 5×3 grid of cells that heat up based on slider. */
2041
- export function HeatMap({seed=42}={}) {
2249
+ export function HeatMap(o = {}) {
2250
+ const {seed=42}=o;
2251
+ const P = resolveTheme(o, { accent: '#ff5014', dim: '#9999b8' });
2042
2252
  const rng=new Random(seed);
2043
2253
  const N=15,thresholds=new Float32Array(N);
2044
2254
  let vals=new Float32Array(N);
2045
- // Heat-color LUT (rgb quantized by heat): a const lookup + globalAlpha
2046
- // instead of a per-cell rgb() template every frame.
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;
2047
2259
  const HEATC=[];
2048
- for(let i=0;i<=32;i++)HEATC[i]='rgb('+Math.round(100+i/32*155)+','+Math.round(60+i/32*20)+','+Math.round(60-i/32*40)+')';
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
+ }
2049
2264
  return {
2050
2265
  init(){for(let i=0;i<N;i++)thresholds[i]=rng.range(0,1)},
2051
2266
  tick(c,dt,now,st) {
@@ -2063,7 +2278,7 @@ export function HeatMap({seed=42}={}) {
2063
2278
  rr(c,x,y,cw,ch,3);c.fill();
2064
2279
  c.globalAlpha=1;
2065
2280
  }
2066
- lbl(c,PCT[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);
2067
2282
  if(st.focused)fr(c,st.w,st.h,3);
2068
2283
  },
2069
2284
  };
@@ -2075,7 +2290,9 @@ export function HeatMap({seed=42}={}) {
2075
2290
  // ===========================================================
2076
2291
 
2077
2292
  /** 12. Day/Night Toggle -- Sun/moon transition with star particles. */
2078
- 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' });
2079
2296
  const rng=new Random(seed);
2080
2297
  // Sky-color LUT (day->night rgb quantized by t): a const lookup, not an
2081
2298
  // rgb() template per frame. Stars are a fixed pool filled in init.
@@ -2108,14 +2325,14 @@ export function DayNightToggle({seed=42}={}) {
2108
2325
  const orbX=lerp(18,st.w-18,t);
2109
2326
  if(t<.5){
2110
2327
  // Sun
2111
- c.fillStyle='#fbbf24';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2328
+ c.fillStyle=P.accent;c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2112
2329
  // Rays -- const color, alpha via globalAlpha
2113
- c.strokeStyle='#fbbf24';c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
2330
+ c.strokeStyle=P.accent;c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
2114
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()}
2115
2332
  c.globalAlpha=1;
2116
2333
  } else {
2117
2334
  // Moon
2118
- c.fillStyle='#d4d4e8';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2335
+ c.fillStyle=P.surface;c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2119
2336
  // Crater shadows -- const black, alpha via globalAlpha
2120
2337
  c.fillStyle='#000000';c.globalAlpha=(t-.5)*2*.15;
2121
2338
  c.beginPath();c.arc(orbX-3,st.h/2-2,3,0,PI2);c.fill();
@@ -2123,16 +2340,20 @@ export function DayNightToggle({seed=42}={}) {
2123
2340
  c.globalAlpha=1;
2124
2341
  }
2125
2342
 
2126
- 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);
2127
2344
  if(st.focused)fr(c,st.w,st.h,st.h/2);
2128
2345
  },
2129
2346
  };
2130
2347
  }
2131
2348
 
2132
2349
  /** 13. Reaction Picker -- 5 emoji-style circles that inflate on hover region. */
2133
- export function ReactionPicker() {
2350
+ export function ReactionPicker(o = {}) {
2134
2351
  let sizes=new Float32Array(5), selected=-1;
2135
- const emojis=['\u{1F610}','\u{1F642}','\u{1F60A}','\u{1F604}','\u{1F929}'],colors=['#9999b8','#fbbf24','#fb923c','#f472b6','#ff6b6b'];
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]);
2136
2357
  const colors30=colors.map((col)=>col+'30'); // active fill (was `${color}30`)
2137
2358
  const REACT_LABELS=['MEH','OK','NICE','GREAT','LOVE']; // was a per-frame array literal
2138
2359
  const FONTS=[]; for(let i=0;i<=48;i++)FONTS[i]=i+'px sans-serif'; // emoji font by rounded size
@@ -2169,7 +2390,9 @@ export function ReactionPicker() {
2169
2390
  }
2170
2391
 
2171
2392
  /** 14. Notification Bell -- Bell icon with bounce and count badge. */
2172
- export function NotificationBell() {
2393
+ export function NotificationBell(o = {}) {
2394
+ const P = resolveTheme(o, { accent: '#fbbf24', accent2: '#ff6b6b' });
2395
+ const TXT = pickText(o, 'NOTIFY');
2173
2396
  let count=0, bellAngle=0, bellVel=0, badgeScale=0;
2174
2397
  let badgeStr='0'; // rebuilt only on click (count change), not per frame
2175
2398
  return {
@@ -2183,23 +2406,23 @@ export function NotificationBell() {
2183
2406
 
2184
2407
  // Bell body
2185
2408
  c.save();c.translate(cx,cy-4);c.rotate(bellAngle*0.3);
2186
- c.fillStyle='#fbbf24';
2409
+ c.fillStyle=P.accent;
2187
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();
2188
2411
  // Clapper
2189
- 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();
2190
2413
  c.restore();
2191
2414
 
2192
2415
  // Badge
2193
2416
  if(count>0){
2194
2417
  const bx=cx+10,by=cy-16;
2195
2418
  c.save();c.translate(bx,by);c.scale(badgeScale,badgeScale);
2196
- c.fillStyle='#ff6b6b';c.beginPath();c.arc(0,0,8,0,PI2);c.fill();
2419
+ c.fillStyle=P.accent2;c.beginPath();c.arc(0,0,8,0,PI2);c.fill();
2197
2420
  c.fillStyle='#fff';c.font="700 8px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2198
2421
  c.fillText(badgeStr,0,0);
2199
2422
  c.restore();
2200
2423
  }
2201
2424
 
2202
- lbl(c,'NOTIFY',cx,st.h+10);
2425
+ lbl(c,TXT,cx,st.h+10);
2203
2426
  if(st.focused)fr(c,st.w,st.h,4);
2204
2427
  },
2205
2428
  };
@@ -2211,8 +2434,10 @@ export function NotificationBell() {
2211
2434
  // ===========================================================
2212
2435
 
2213
2436
  /** 15. Typewriter Field -- Characters appear one by one with cursor blink. */
2214
- export function TypewriterField() {
2215
- 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');
2216
2441
  let charIdx=0, timer=0, cursorBlink=0, typing=false;
2217
2442
  let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
2218
2443
  return {
@@ -2224,24 +2449,26 @@ export function TypewriterField() {
2224
2449
  c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
2225
2450
  c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
2226
2451
 
2227
- 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';
2228
2453
  // Rebuild the visible substring + its width only when a char is added.
2229
2454
  if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
2230
2455
  c.fillText(display,8,st.h/2);
2231
2456
 
2232
2457
  // Cursor
2233
2458
  if(cursorBlink<1){
2234
- c.fillStyle='#6ee7b6';c.fillRect(9+dispW,st.h/2-8,1.5,16);
2459
+ c.fillStyle=P.accent;c.fillRect(9+dispW,st.h/2-8,1.5,16);
2235
2460
  }
2236
2461
 
2237
- 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);
2238
2463
  if(st.focused)fr(c,st.w,st.h,6);
2239
2464
  },
2240
2465
  };
2241
2466
  }
2242
2467
 
2243
2468
  /** 16. Sound Wave Button -- Oscillating waveform on press, static on idle. */
2244
- 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");
2245
2472
  let intensity=0, phase=0;
2246
2473
  return {
2247
2474
  onClick(){intensity=1},
@@ -2253,7 +2480,7 @@ export function SoundWaveBtn() {
2253
2480
 
2254
2481
  // Waveform
2255
2482
  const cy=st.h/2, amp=10*intensity;
2256
- 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;
2257
2484
  c.beginPath();
2258
2485
  for(let x=0;x<=st.w;x++){
2259
2486
  const freq=x*0.1;
@@ -2268,7 +2495,7 @@ export function SoundWaveBtn() {
2268
2495
  c.beginPath();c.moveTo(10,cy);c.lineTo(st.w-10,cy);c.stroke();
2269
2496
  }
2270
2497
 
2271
- 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';
2272
2499
  c.fillText(intensity>.1?'\u25CF REC':'RECORD',st.w/2,st.h/2);
2273
2500
  if(st.focused)fr(c,st.w,st.h,10);
2274
2501
  },
@@ -2276,7 +2503,8 @@ export function SoundWaveBtn() {
2276
2503
  }
2277
2504
 
2278
2505
  /** 17. Upload Progress -- File icon fills from bottom as slider increases. */
2279
- export function UploadProgress() {
2506
+ export function UploadProgress(o = {}) {
2507
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#38bdf8', dim: '#9999b8' });
2280
2508
  let displayVal=0;
2281
2509
  return {
2282
2510
  tick(c,dt,now,st) {
@@ -2292,8 +2520,8 @@ export function UploadProgress() {
2292
2520
 
2293
2521
  // Fill
2294
2522
  const fillH=fh*displayVal;
2295
- const col=displayVal>=1?'#6ee7b6':'#38bdf8';
2296
- c.fillStyle=displayVal>=1?'#6ee7b630':'#38bdf830'; // was `${col}30`
2523
+ const col=displayVal>=1?P.accent:P.accent2;
2524
+ c.fillStyle=col+'30'; // was `${col}30`
2297
2525
  c.save();c.beginPath();c.rect(fx+1,fy+fh-fillH,fw-1,fillH);c.clip();
2298
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();
2299
2527
  c.restore();
@@ -2308,7 +2536,7 @@ export function UploadProgress() {
2308
2536
  }
2309
2537
  c.lineCap='butt';
2310
2538
 
2311
- lbl(c,displayVal>=.99?'DONE':PCT[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);
2312
2540
  if(st.focused)fr(c,st.w,st.h,4);
2313
2541
  },
2314
2542
  };
@@ -2320,7 +2548,11 @@ export function UploadProgress() {
2320
2548
  // ===========================================================
2321
2549
 
2322
2550
  /** 18. Scratch Reveal -- Drag to erase a mask and reveal the prize beneath. */
2323
- 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");
2324
2556
  const rng=new Random(seed);
2325
2557
  // Fixed scratch pool (holes persist). Once all SCR slots are used the scratch
2326
2558
  // stops adding holes -- existing marks are never overwritten, so earlier
@@ -2345,10 +2577,10 @@ export function ScratchReveal({seed=42}={}) {
2345
2577
  tick(c,dt,now,st) {
2346
2578
  lastW=st.w;
2347
2579
  // Prize background
2348
- 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();
2349
2581
 
2350
2582
  // Prize text -- const color, alpha via globalAlpha
2351
- c.fillStyle='#6ee7b6';c.globalAlpha=.1+revealed*.6;c.font="700 14px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2583
+ c.fillStyle=P.accent;c.globalAlpha=.1+revealed*.6;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2352
2584
  c.fillText(revealed>.6?'\u{1F389} WINNER!':'? ? ?',st.w/2,st.h/2);
2353
2585
  c.globalAlpha=1;
2354
2586
 
@@ -2371,14 +2603,16 @@ export function ScratchReveal({seed=42}={}) {
2371
2603
  c.fillRect(shimX-2,0,4,st.h);
2372
2604
  }
2373
2605
 
2374
- 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);
2375
2607
  if(st.focused)fr(c,st.w,st.h,8);
2376
2608
  },
2377
2609
  };
2378
2610
  }
2379
2611
 
2380
2612
  /** 19. Timer Countdown -- Circular countdown timer. Toggle starts/stops. */
2381
- 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");
2382
2616
  let timeLeft=10, running=false, flashAlpha=0;
2383
2617
  let timeStr='10.0s', lastTenths=-1; // rebuilt at ~10 Hz, not per frame
2384
2618
  return {
@@ -2398,24 +2632,25 @@ export function TimerCountdown() {
2398
2632
 
2399
2633
  // Progress arc
2400
2634
  const progress=timeLeft/10;
2401
- const col=timeLeft>3?'#6ee7b6':timeLeft>1?'#fbbf24':'#ff6b6b';
2635
+ const col=timeLeft>3?P.accent:timeLeft>1?P.accent2:P.accent3;
2402
2636
  c.strokeStyle=col;c.lineWidth=4;
2403
2637
  c.beginPath();c.arc(cx,cy,R,-Math.PI/2,-Math.PI/2+progress*PI2,false);c.stroke();
2404
2638
 
2405
2639
  // Time text
2406
2640
  const tenths=Math.round(timeLeft*10);
2407
2641
  if(tenths!==lastTenths){lastTenths=tenths;timeStr=(tenths/10).toFixed(1)+'s';}
2408
- c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2642
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2409
2643
  c.fillText(timeStr,cx,cy);
2410
2644
 
2411
- 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);
2412
2646
  if(st.focused)fr(c,st.w,st.h,R);
2413
2647
  },
2414
2648
  };
2415
2649
  }
2416
2650
 
2417
2651
  /** 20. Pull Refresh -- Drag down to charge, release to spin. */
2418
- export function PullRefresh() {
2652
+ export function PullRefresh(o = {}) {
2653
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#38bdf8', dim: '#9999b8' });
2419
2654
  let pullAmt=0, spinning=false, spinAngle=0, spinTimer=0;
2420
2655
  return {
2421
2656
  onDrag(val) { if(!spinning) pullAmt=val; },
@@ -2435,25 +2670,25 @@ export function PullRefresh() {
2435
2670
  // Spinner arcs
2436
2671
  for(let i=0;i<3;i++){
2437
2672
  const a=spinAngle+i*PI2/3;
2438
- c.strokeStyle='#38bdf8';c.globalAlpha=.3+i*.2;c.lineWidth=3;
2673
+ c.strokeStyle=P.accent2;c.globalAlpha=.3+i*.2;c.lineWidth=3;
2439
2674
  c.beginPath();c.arc(cx,cy,16,a,a+.8);c.stroke();
2440
2675
  }
2441
2676
  c.globalAlpha=1;
2442
- lbl(c,'LOADING...',cx,st.h+10,'#38bdf8');
2677
+ lbl(c,'LOADING...',cx,st.h+10,P.accent2);
2443
2678
  } else {
2444
2679
  // Arrow that stretches with pull
2445
2680
  const stretch=pullAmt*14;
2446
- 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';
2447
2682
  c.beginPath();c.moveTo(cx,cy-8-stretch);c.lineTo(cx,cy+4);c.stroke();
2448
2683
  c.beginPath();c.moveTo(cx-5,cy+0);c.lineTo(cx,cy+4);c.lineTo(cx+5,cy+0);c.stroke();
2449
2684
  c.lineCap='butt';
2450
2685
 
2451
2686
  // Fill ring
2452
2687
  if(pullAmt>0){
2453
- 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;
2454
2689
  c.beginPath();c.arc(cx,cy,20,-Math.PI/2,-Math.PI/2+pullAmt*PI2);c.stroke();
2455
2690
  }
2456
- 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);
2457
2692
  }
2458
2693
  if(st.focused)fr(c,st.w,st.h,24);
2459
2694
  },
@@ -2619,60 +2854,60 @@ export const RECIPES = Object.assign(Object.create(null), {
2619
2854
  *
2620
2855
  * type 'toggle' | 'button' | 'slider' -- the native element it mounts on
2621
2856
  * family display grouping (Toggles, Buttons, Sliders, Knobs, ...)
2622
- * themeable accepts { colors, theme } (false for all until a later pass)
2623
- * motionSafe inherently-calm under prefers-reduced-motion (false for all)
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)
2624
2859
  */
2625
2860
  export const RECIPE_META = [
2626
- { id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2627
- { id: 'liquidToggle', name: 'Liquid Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2628
- { id: 'neonPulseToggle', name: 'Neon Pulse Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2629
- { id: 'magneticButton', name: 'Magnetic Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2630
- { id: 'shatterButton', name: 'Shatter Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2631
- { id: 'confettiButton', name: 'Confetti Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2632
- { id: 'glitchButton', name: 'Glitch Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2633
- { id: 'sparkSlider', name: 'Spark Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2634
- { id: 'cosmicSlider', name: 'Cosmic Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2635
- { id: 'laserSlider', name: 'Laser Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2636
- { id: 'pendulumToggle', name: 'Pendulum Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2637
- { id: 'circuitToggle', name: 'Circuit Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2638
- { id: 'lightningToggle', name: 'Lightning Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2639
- { id: 'dnaToggle', name: 'Dna Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
2640
- { id: 'heartbeatButton', name: 'Heartbeat Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2641
- { id: 'breathingButton', name: 'Breathing Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2642
- { id: 'inkSplashButton', name: 'Ink Splash Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2643
- { id: 'pixelDissolveButton', name: 'Pixel Dissolve Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2644
- { id: 'fireworkButton', name: 'Firework Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
2645
- { id: 'auroraSlider', name: 'Aurora Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2646
- { id: 'waveSlider', name: 'Wave Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2647
- { id: 'elasticBandSlider', name: 'Elastic Band Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2648
- { id: 'gravitySlider', name: 'Gravity Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
2649
- { id: 'orbitLoader', name: 'Orbit Loader', type: 'toggle', family: 'Loaders', themeable: false, motionSafe: false },
2650
- { id: 'helixLoader', name: 'Helix Loader', type: 'toggle', family: 'Loaders', themeable: false, motionSafe: false },
2651
- { id: 'rippleCheck', name: 'Ripple Check', type: 'toggle', family: 'Checkboxes', themeable: false, motionSafe: false },
2652
- { id: 'morphCheck', name: 'Morph Check', type: 'toggle', family: 'Checkboxes', themeable: false, motionSafe: false },
2653
- { id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: false, motionSafe: false },
2654
- { id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: false, motionSafe: false },
2655
- { id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: false, motionSafe: false },
2656
- { id: 'volumeKnob', name: 'Volume Knob', type: 'slider', family: 'Knobs', themeable: false, motionSafe: false },
2657
- { id: 'compassKnob', name: 'Compass Knob', type: 'slider', family: 'Knobs', themeable: false, motionSafe: false },
2658
- { id: 'ringProgress', name: 'Ring Progress', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
2659
- { id: 'batteryGauge', name: 'Battery Gauge', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
2660
- { id: 'signalMeter', name: 'Signal Meter', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
2661
- { id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: false, motionSafe: false },
2662
- { id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: false, motionSafe: false },
2663
- { id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: false, motionSafe: false },
2664
- { id: 'passwordStrength', name: 'Password Strength', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
2665
- { id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
2666
- { id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
2667
- { id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: false, motionSafe: false },
2668
- { id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: false, motionSafe: false },
2669
- { id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: false, motionSafe: false },
2670
- { id: 'typewriterField', name: 'Typewriter Field', type: 'toggle', family: 'Feedback', themeable: false, motionSafe: false },
2671
- { id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: false, motionSafe: false },
2672
- { id: 'uploadProgress', name: 'Upload Progress', type: 'slider', family: 'Feedback', themeable: false, motionSafe: false },
2673
- { id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: false, motionSafe: false },
2674
- { id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: false, motionSafe: false },
2675
- { id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: false, motionSafe: false },
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 },
2676
2911
  ];
2677
2912
 
2678
2913
  /** Names of every built-in recipe (the keys of RECIPES at load time). */