@zakkster/lite-ui-fx 1.3.0 → 1.5.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
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @zakkster/lite-ui-fx -- Recipe Collection (all 50)
2
+ * @zakkster/lite-ui-fx -- Recipe Collection (all 53)
3
3
  *
4
4
  * The three recipe volumes consolidated into one shipped, typed, versioned
5
- * module, exposed as the ./recipes subpath export. See
6
- * decisions/0001-recipes-position.md.
5
+ * module, exposed as the ./recipes subpath export, plus the U4a additions for
6
+ * the new native element types. See decisions/0001-recipes-position.md.
7
7
  *
8
8
  * Vol.1 (10): SwarmToggle, LiquidToggle, NeonPulseToggle, MagneticButton,
9
9
  * ShatterButton, ConfettiButton, GlitchButton, SparkSlider,
@@ -18,6 +18,7 @@
18
18
  * HeatMap, DayNightToggle, ReactionPicker, NotificationBell,
19
19
  * TypewriterField, SoundWaveBtn, UploadProgress, ScratchReveal,
20
20
  * TimerCountdown, PullRefresh
21
+ * U4a (3): TickDraw, IndeterminateScan (CHECKBOX), LiquidFill (PROGRESS)
21
22
  *
22
23
  * Registry: RECIPES (null-prototype), RECIPE_META (live), RECIPE_NAMES,
23
24
  * registerRecipe(id, factory, meta?), mountRecipe(container, id, options?).
@@ -100,6 +101,81 @@ function fr(c,w,h,r){c.strokeStyle='rgba(110,231,182,.5)';c.lineWidth=2;c.setLin
100
101
  const PI2=Math.PI*2;
101
102
 
102
103
 
104
+ // ---------------------------------------------------------
105
+ // THEME RESOLUTION (U3b, decisions/0002) -- COLD, once per instance in the
106
+ // factory / init. The hot tick() reads only the const strings resolved here.
107
+ // ---------------------------------------------------------
108
+
109
+ /**
110
+ * Resolve a recipe palette. DEFAULT is a role map of the recipe's historical
111
+ * literals ({ accent, dim, surface, ...extras }), so a bare mount is
112
+ * byte-identical to 1.3.0. options overlay it: theme { light, mid, dark } maps to
113
+ * { accent, dim, surface }; colors[] overrides positionally over
114
+ * Object.keys(DEFAULT) (accent, dim, surface, extras...), applied AFTER theme so
115
+ * colors win. Returns a fresh per-instance object -- DEFAULT is never mutated.
116
+ */
117
+ function resolveTheme(o, DEFAULT) {
118
+ const p = Object.assign({}, DEFAULT);
119
+ if (o) {
120
+ const t = o.theme;
121
+ if (t) {
122
+ // Prefix roles so a recipe with two muted variants (dim, dim2) or two
123
+ // accents (accent, accent2) maps them all from one theme anchor while
124
+ // its DEFAULT keeps the distinct literals for byte-parity.
125
+ for (const k in p) {
126
+ if (k.indexOf('accent') === 0) p[k] = t.light;
127
+ else if (k.indexOf('dim') === 0) p[k] = t.mid;
128
+ else if (k.indexOf('surface') === 0) p[k] = t.dark;
129
+ }
130
+ }
131
+ const c = o.colors;
132
+ if (c) {
133
+ const ks = Object.keys(DEFAULT);
134
+ for (let i = 0; i < c.length && i < ks.length; i++) {
135
+ if (c[i] != null) p[ks[i]] = c[i];
136
+ }
137
+ }
138
+ }
139
+ return p;
140
+ }
141
+
142
+ /** Hex (#rgb / #rrggbb) -> "r,g,b" for building rgba() ramps. Returns `def` for a
143
+ * non-hex color (e.g. 'oklch(...)'), so a caller degrades gracefully rather than
144
+ * emitting NaN channels. COLD. */
145
+ function rgbTriplet(hex, def) {
146
+ if (typeof hex !== 'string' || hex[0] !== '#') return def;
147
+ let h = hex.slice(1);
148
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
149
+ if (h.length !== 6) return def;
150
+ const n = parseInt(h, 16);
151
+ if (!Number.isFinite(n)) return def;
152
+ return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
153
+ }
154
+
155
+ /** Hex -> "rgba(r,g,b,a)". COLD and only ever called on the themed path -- a bare
156
+ * mount keeps its historical rgba() literal, so this string's exact formatting is
157
+ * never compared for byte-parity. Falls back to the input untouched for a non-hex
158
+ * theme color, degrading a derived tint to the solid rather than emitting NaN. */
159
+ function rgbaOf(hex, a) {
160
+ const t = rgbTriplet(hex, null);
161
+ return t === null ? hex : 'rgba(' + t + ',' + a + ')';
162
+ }
163
+
164
+ /** Resolve a recipe's visible canvas label: options.text, else the accessible
165
+ * options.label, else the recipe default -- so a mount that sets only `label`
166
+ * still drives the visible text (WCAG 2.5.3 label-in-name by construction). */
167
+ function pickText(o, def) {
168
+ if (o) {
169
+ if (o.text != null) return o.text;
170
+ if (o.label != null && o.label !== '') return o.label;
171
+ }
172
+ return def;
173
+ }
174
+
175
+ /** Resolve a font string: options.font, else the recipe's historical literal. */
176
+ function pickFont(o, def) { return (o && o.font) || def; }
177
+
178
+
103
179
 
104
180
  // ===========================================================
105
181
  // TOGGLE RECIPES
@@ -110,7 +186,10 @@ const PI2=Math.PI*2;
110
186
  * On toggle, they explode outward then regroup at the new position.
111
187
  * Uses sunflower phyllotaxis for the packed formation.
112
188
  */
113
- export function SwarmToggle({ seed = 42, count = 150 } = {}) {
189
+ export function SwarmToggle(o = {}) {
190
+ const { seed = 42, count = 150 } = o;
191
+ const P = resolveTheme(o, { accent: '#6ee7b6', dim: '#9999b8', dim2: '#8888aa' });
192
+ const onTint = (o.theme || o.colors) ? rgbaOf(P.accent, .2) : 'rgba(110,231,182,.2)';
114
193
  const rng = new Random(seed);
115
194
  const N = count;
116
195
  const px = new Float32Array(N), py = new Float32Array(N);
@@ -142,19 +221,19 @@ export function SwarmToggle({ seed = 42, count = 150 } = {}) {
142
221
 
143
222
  tick(ctx, dt, now, st) {
144
223
  // Track
145
- ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
224
+ ctx.fillStyle = st.toggled ? onTint : 'rgba(255,255,255,.06)';
146
225
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
147
226
  ctx.fill();
148
227
 
149
228
  // State label
150
229
  drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
151
- st.toggled ? '#6ee7b6' : '#8888aa');
230
+ st.toggled ? P.accent : P.dim2);
152
231
 
153
232
  // Knob target position
154
233
  const tx = st.toggled ? st.w - 18 : 18;
155
234
 
156
235
  // Render particles (spring toward formation)
157
- ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
236
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
158
237
  for (let i = 0; i < N; i++) {
159
238
  vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
160
239
  vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
@@ -179,7 +258,9 @@ export function SwarmToggle({ seed = 42, count = 150 } = {}) {
179
258
  * Liquid Toggle -- Metaball-style stretching knob.
180
259
  * The knob elongates in the direction of motion, squashes perpendicular.
181
260
  */
182
- export function LiquidToggle() {
261
+ export function LiquidToggle(o = {}) {
262
+ const P = resolveTheme(o, { accent: '#6ee7b6', dim: '#9999b8', dim2: '#8888aa' });
263
+ const onTint = (o.theme || o.colors) ? rgbaOf(P.accent, .2) : 'rgba(110,231,182,.2)';
183
264
  let knobX = 18;
184
265
 
185
266
  return {
@@ -189,19 +270,19 @@ export function LiquidToggle() {
189
270
  const stretch = Math.abs(knobX - tx) * 0.5;
190
271
 
191
272
  // Track
192
- ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
273
+ ctx.fillStyle = st.toggled ? onTint : 'rgba(255,255,255,.06)';
193
274
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
194
275
  ctx.fill();
195
276
 
196
277
  // Knob (stretched ellipse)
197
- ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
278
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
198
279
  ctx.beginPath();
199
280
  ctx.ellipse(knobX, st.h / 2, 14 + stretch, 14 - stretch * 0.2, 0, 0, Math.PI * 2);
200
281
  ctx.fill();
201
282
 
202
283
  // State label
203
284
  drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
204
- st.toggled ? '#6ee7b6' : '#8888aa');
285
+ st.toggled ? P.accent : P.dim2);
205
286
 
206
287
  if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
207
288
  },
@@ -212,7 +293,9 @@ export function LiquidToggle() {
212
293
  /**
213
294
  * Neon Pulse Toggle -- Expanding shockwave rings on toggle.
214
295
  */
215
- export function NeonPulseToggle() {
296
+ export function NeonPulseToggle(o = {}) {
297
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8', dim2: '#8888aa' });
298
+ const onTint = (o.theme || o.colors) ? rgbaOf(P.accent, .2) : 'rgba(56,189,248,.2)';
216
299
  // Fixed ring pool (dead when life <= 0) -- no push/splice on the hot path.
217
300
  const RINGS = 8;
218
301
  const ringR = new Float64Array(RINGS);
@@ -238,13 +321,13 @@ export function NeonPulseToggle() {
238
321
  knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 15);
239
322
 
240
323
  // Track
241
- ctx.fillStyle = st.toggled ? 'rgba(56,189,248,.2)' : 'rgba(255,255,255,.06)';
324
+ ctx.fillStyle = st.toggled ? onTint : 'rgba(255,255,255,.06)';
242
325
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
243
326
  ctx.fill();
244
327
 
245
328
  // Shockwave rings -- const stroke color, per-ring alpha via globalAlpha
246
329
  // (was `rgba(56,189,248,${life})` built per ring per frame).
247
- ctx.strokeStyle = '#38bdf8';
330
+ ctx.strokeStyle = P.accent;
248
331
  ctx.lineWidth = 2;
249
332
  for (let i = 0; i < RINGS; i++) {
250
333
  if (ringLife[i] <= 0) continue;
@@ -259,13 +342,13 @@ export function NeonPulseToggle() {
259
342
  ctx.globalAlpha = 1;
260
343
 
261
344
  // Knob
262
- ctx.fillStyle = st.toggled ? '#38bdf8' : '#9999b8';
345
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
263
346
  ctx.beginPath();
264
347
  ctx.arc(knobX, st.h / 2, 14, 0, Math.PI * 2);
265
348
  ctx.fill();
266
349
 
267
350
  drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
268
- st.toggled ? '#38bdf8' : '#8888aa');
351
+ st.toggled ? P.accent : P.dim2);
269
352
 
270
353
  if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
271
354
  },
@@ -281,7 +364,11 @@ export function NeonPulseToggle() {
281
364
  * Magnetic Button -- The entire button follows the cursor with spring physics.
282
365
  * Squashes on click, springs back.
283
366
  */
284
- export function MagneticButton({ maxPull = 15 } = {}) {
367
+ export function MagneticButton(o = {}) {
368
+ const { maxPull = 15 } = o;
369
+ const P = resolveTheme(o, { accent: '#a78bfa', surface: '#1e1e2f', dim: '#e2e2f0' });
370
+ const FONT = pickFont(o, "600 13px 'Space Grotesk',sans-serif");
371
+ const TXT = pickText(o, 'MAGNETIC');
285
372
  let bx = 0, by = 0, textScale = 1;
286
373
 
287
374
  return {
@@ -297,19 +384,19 @@ export function MagneticButton({ maxPull = 15 } = {}) {
297
384
  ctx.translate(bx, by);
298
385
 
299
386
  // Button body
300
- ctx.fillStyle = st.hover ? '#1e1e2f' : 'rgba(255,255,255,.05)';
301
- ctx.strokeStyle = st.hover ? '#a78bfa' : 'rgba(255,255,255,.1)';
387
+ ctx.fillStyle = st.hover ? P.surface : 'rgba(255,255,255,.05)';
388
+ ctx.strokeStyle = st.hover ? P.accent : 'rgba(255,255,255,.1)';
302
389
  ctx.lineWidth = 1;
303
390
  roundRect(ctx, 0, 0, st.w, st.h, 10);
304
391
  ctx.fill(); ctx.stroke();
305
392
 
306
393
  // Label
307
- ctx.fillStyle = st.hover ? '#a78bfa' : '#e2e2f0';
308
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
394
+ ctx.fillStyle = st.hover ? P.accent : P.dim;
395
+ ctx.font = FONT;
309
396
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
310
397
  ctx.translate(st.w / 2, st.h / 2);
311
398
  ctx.scale(textScale, textScale);
312
- ctx.fillText('MAGNETIC', 0, 0);
399
+ ctx.fillText(TXT, 0, 0);
313
400
 
314
401
  if (st.focused) {
315
402
  ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
@@ -324,7 +411,12 @@ export function MagneticButton({ maxPull = 15 } = {}) {
324
411
  /**
325
412
  * Shatter Button -- Click explodes into falling shards, reforms after 1.5s.
326
413
  */
327
- export function ShatterButton({ seed = 42 } = {}) {
414
+ export function ShatterButton(o = {}) {
415
+ const { seed = 42 } = o;
416
+ const P = resolveTheme(o, { accent: '#ffffff', dim: '#e2e2f0' });
417
+ const shardColor = (o.theme || o.colors) ? rgbaOf(P.accent, .5) : 'rgba(255,255,255,.5)';
418
+ const FONT = pickFont(o, "600 13px 'Space Grotesk',sans-serif");
419
+ const TXT = pickText(o, 'SHATTER');
328
420
  const rng = new Random(seed);
329
421
  // Fixed shard pool (live flag) -- no push/splice on the hot path.
330
422
  const SHARDS = 32;
@@ -360,16 +452,16 @@ export function ShatterButton({ seed = 42 } = {}) {
360
452
  ctx.fillStyle = st.active ? 'rgba(255,255,255,.15)' : 'rgba(255,255,255,.08)';
361
453
  roundRect(ctx, 0, 0, st.w, st.h, 10);
362
454
  ctx.fill();
363
- ctx.fillStyle = '#e2e2f0';
364
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
455
+ ctx.fillStyle = P.dim;
456
+ ctx.font = FONT;
365
457
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
366
- ctx.fillText('SHATTER', st.w / 2, st.h / 2);
458
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
367
459
 
368
460
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
369
461
  }
370
462
 
371
463
  // Falling shards -- fixed pool
372
- ctx.fillStyle = 'rgba(255,255,255,.5)';
464
+ ctx.fillStyle = shardColor;
373
465
  for (let i = 0; i < SHARDS; i++) {
374
466
  const s = shard[i];
375
467
  if (!s.live) continue;
@@ -390,7 +482,15 @@ export function ShatterButton({ seed = 42 } = {}) {
390
482
  /**
391
483
  * Confetti Button -- 3D tumbling confetti burst from click point.
392
484
  */
393
- export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#fbbf24', '#f43f5e'] } = {}) {
485
+ export function ConfettiButton(o = {}) {
486
+ const { seed = 42 } = o;
487
+ // `colors` is the confetti palette (legacy option, still honored); `theme`
488
+ // drives the crimson body and, absent colors, seeds the palette too.
489
+ const colors = o.colors ? o.colors
490
+ : (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark] : ['#6ee7b6', '#38bdf8', '#a78bfa', '#fbbf24', '#f43f5e']);
491
+ const bodyColor = (o.theme && o.theme.light) || '#DC143C';
492
+ const FONT = pickFont(o, "600 13px 'Space Grotesk',sans-serif");
493
+ const TXT = pickText(o, 'CONFETTI');
394
494
  const rng = new Random(seed);
395
495
  // Fixed confetti pool (live flag) -- no push/splice on the hot path.
396
496
  const CONF = 80;
@@ -425,13 +525,13 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
425
525
  ctx.translate(-st.w / 2, -st.h / 2);
426
526
 
427
527
  // Button body
428
- ctx.fillStyle = '#DC143C';
528
+ ctx.fillStyle = bodyColor;
429
529
  roundRect(ctx, 0, 0, st.w, st.h, 10);
430
530
  ctx.fill();
431
531
  ctx.fillStyle = '#fff';
432
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
532
+ ctx.font = FONT;
433
533
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
434
- ctx.fillText('CONFETTI', st.w / 2, st.h / 2);
534
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
435
535
 
436
536
  // Tumbling confetti -- fixed pool
437
537
  for (let i = 0; i < CONF; i++) {
@@ -462,7 +562,11 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
462
562
  /**
463
563
  * Glitch Button -- RGB channel split on hover. Random slice displacement.
464
564
  */
465
- export function GlitchButton({ seed = 42 } = {}) {
565
+ export function GlitchButton(o = {}) {
566
+ const { seed = 42 } = o;
567
+ const P = resolveTheme(o, { accent: '#ff0055', accent2: '#00ffcc' });
568
+ const FONT = pickFont(o, "600 13px 'Space Grotesk',sans-serif");
569
+ const TXT = pickText(o, 'GLITCH');
466
570
  const rng = new Random(seed);
467
571
  let glitchIntensity = 0;
468
572
 
@@ -475,9 +579,9 @@ export function GlitchButton({ seed = 42 } = {}) {
475
579
  ctx.fill();
476
580
  ctx.globalAlpha = 1;
477
581
  ctx.fillStyle = '#fff';
478
- ctx.font = "600 13px 'Space Grotesk',sans-serif";
582
+ ctx.font = FONT;
479
583
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
480
- ctx.fillText('GLITCH', st.w / 2 + ox, st.h / 2 + oy);
584
+ ctx.fillText(TXT, st.w / 2 + ox, st.h / 2 + oy);
481
585
  }
482
586
 
483
587
  return {
@@ -489,8 +593,8 @@ export function GlitchButton({ seed = 42 } = {}) {
489
593
  const off = rng.range(2, 6) * glitchIntensity;
490
594
  // Alternate between split and normal for flicker
491
595
  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);
596
+ drawBase(ctx, st, -off, 0, P.accent, 0.6 * glitchIntensity);
597
+ drawBase(ctx, st, off, 0, P.accent2, 0.6 * glitchIntensity);
494
598
  } else {
495
599
  drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.1)', 1);
496
600
  }
@@ -513,7 +617,10 @@ export function GlitchButton({ seed = 42 } = {}) {
513
617
  * Spark Slider -- Emits directional sparks based on drag velocity.
514
618
  * Sparks fly opposite to drag direction with motion-blur stretch.
515
619
  */
516
- export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
620
+ export function SparkSlider(o = {}) {
621
+ const { seed = 42 } = o;
622
+ // `color` (legacy singular) seeds the default accent; theme/colors override it.
623
+ const P = resolveTheme(o, { accent: o.color || '#fbbf24', dim: '#9999b8' });
517
624
  const rng = new Random(seed);
518
625
  // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
519
626
  const SPARKS = 64;
@@ -552,7 +659,7 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
552
659
 
553
660
  // Filled track
554
661
  const tx = st.val * st.w;
555
- ctx.fillStyle = color;
662
+ ctx.fillStyle = P.accent;
556
663
  roundRect(ctx, 0, 12, tx, 4, 2);
557
664
  ctx.fill();
558
665
 
@@ -562,12 +669,12 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
562
669
  ctx.fill();
563
670
 
564
671
  // Value label
565
- drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
672
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, P.dim);
566
673
 
567
674
  // Sparks -- fixed pool, themed color, alpha via globalAlpha
568
675
  // (was `rgba(251,191,36,${life})` built per spark per frame).
569
676
  ctx.globalCompositeOperation = 'screen';
570
- ctx.fillStyle = color;
677
+ ctx.fillStyle = P.accent;
571
678
  for (let i = 0; i < SPARKS; i++) {
572
679
  const s = spark[i];
573
680
  if (s.life <= 0) continue;
@@ -596,7 +703,9 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
596
703
  * Cosmic Slider -- Thumb is a black hole that sucks in background dust.
597
704
  * Particles respawn when consumed.
598
705
  */
599
- export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
706
+ export function CosmicSlider(o = {}) {
707
+ const { seed = 42, dustCount = 80 } = o;
708
+ const P = resolveTheme(o, { accent: '#a78bfa', surface: '#000', dim: '#9999b8' });
600
709
  const rng = new Random(seed);
601
710
  const dust = [];
602
711
 
@@ -637,13 +746,13 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
637
746
  }
638
747
 
639
748
  // Black hole thumb with glow
640
- ctx.shadowBlur = 10; ctx.shadowColor = '#a78bfa';
641
- ctx.fillStyle = '#000'; ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 2;
749
+ ctx.shadowBlur = 10; ctx.shadowColor = P.accent;
750
+ ctx.fillStyle = P.surface; ctx.strokeStyle = P.accent; ctx.lineWidth = 2;
642
751
  ctx.beginPath(); ctx.arc(tx, 14, 12, 0, Math.PI * 2);
643
752
  ctx.fill(); ctx.stroke();
644
753
  ctx.shadowBlur = 0;
645
754
 
646
- drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
755
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, P.dim);
647
756
 
648
757
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
649
758
  },
@@ -654,7 +763,8 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
654
763
  /**
655
764
  * Laser Slider -- Energy beam traces the filled track. Pulsing plasma thumb.
656
765
  */
657
- export function LaserSlider() {
766
+ export function LaserSlider(o = {}) {
767
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8' });
658
768
  const REF = 100; // gradient reference width; scaled to tx at paint
659
769
  let pulseTime = 0, beam = null;
660
770
 
@@ -665,7 +775,7 @@ export function LaserSlider() {
665
775
  // no per-frame createLinearGradient.
666
776
  beam = ctx.createLinearGradient(0, 0, REF, 0);
667
777
  beam.addColorStop(0, 'transparent');
668
- beam.addColorStop(0.8, '#38bdf8');
778
+ beam.addColorStop(0.8, P.accent);
669
779
  beam.addColorStop(1, '#fff');
670
780
  },
671
781
  tick(ctx, dt, now, st) {
@@ -682,22 +792,22 @@ export function LaserSlider() {
682
792
  if (tx > 2) {
683
793
  ctx.save();
684
794
  ctx.scale(tx / REF, 1);
685
- ctx.fillStyle = beam || '#38bdf8';
686
- ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
795
+ ctx.fillStyle = beam || P.accent;
796
+ ctx.shadowBlur = 10; ctx.shadowColor = P.accent;
687
797
  roundRect(ctx, 0, 12, REF, 4, 2);
688
798
  ctx.fill();
689
799
  ctx.restore();
690
800
  }
691
801
 
692
802
  // Plasma thumb (pulsing) -- keep the glow the beam left on
693
- ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
803
+ ctx.shadowBlur = 10; ctx.shadowColor = P.accent;
694
804
  ctx.fillStyle = '#fff';
695
805
  ctx.beginPath();
696
806
  ctx.arc(tx, 14, 8 + Math.sin(pulseTime) * 2, 0, Math.PI * 2);
697
807
  ctx.fill();
698
808
  ctx.shadowBlur = 0;
699
809
 
700
- drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
810
+ drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, P.dim);
701
811
 
702
812
  if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
703
813
  },
@@ -710,7 +820,11 @@ export function LaserSlider() {
710
820
  // ===========================================================
711
821
 
712
822
  /** 1. Pendulum Toggle -- The knob swings like a pendulum with overshoot. */
713
- export function PendulumToggle() {
823
+ export function PendulumToggle(o = {}) {
824
+ const P = resolveTheme(o, { accent: '#6ee7b6', dim: '#9999b8', dim2: '#8888aa' });
825
+ const themed = !!(o.theme || o.colors);
826
+ const onTint = themed ? rgbaOf(P.accent, .2) : 'rgba(110,231,182,.2)';
827
+ const strokeOn = themed ? rgbaOf(P.accent, .3) : 'rgba(110,231,182,.3)';
714
828
  let angle = -0.4, velocity = 0;
715
829
  const STIFFNESS = 12, DAMPING = 3.5;
716
830
  return {
@@ -721,24 +835,26 @@ export function PendulumToggle() {
721
835
  velocity *= (1 - DAMPING * dt);
722
836
  angle += velocity * dt;
723
837
 
724
- ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
838
+ ctx.fillStyle = st.toggled ? onTint : 'rgba(255,255,255,.06)';
725
839
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
726
840
 
727
841
  const cx = st.w / 2, cy = -10, len = st.h / 2 + 14;
728
842
  const bx = cx + Math.sin(angle) * len, by = cy + Math.cos(angle) * len;
729
843
 
730
- ctx.strokeStyle = st.toggled ? 'rgba(110,231,182,.3)' : 'rgba(255,255,255,.08)';
844
+ ctx.strokeStyle = st.toggled ? strokeOn : 'rgba(255,255,255,.08)';
731
845
  ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(bx, by); ctx.stroke();
732
- ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
846
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
733
847
  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');
848
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent : P.dim2);
735
849
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
736
850
  },
737
851
  };
738
852
  }
739
853
 
740
854
  /** 2. Circuit Toggle -- Electricity flows through a circuit path when ON. */
741
- export function CircuitToggle({ seed = 42 } = {}) {
855
+ export function CircuitToggle(o = {}) {
856
+ const { seed = 42 } = o;
857
+ const P = resolveTheme(o, { accent: '#22d3ee', dim: '#9999b8', dim2: '#8888aa' });
742
858
  const rng = new Random(seed);
743
859
  // Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
744
860
  const SPARKS = 16;
@@ -766,14 +882,14 @@ export function CircuitToggle({ seed = 42 } = {}) {
766
882
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
767
883
 
768
884
  // Circuit path
769
- ctx.strokeStyle = st.toggled ? '#22d3ee' : 'rgba(255,255,255,.06)';
885
+ ctx.strokeStyle = st.toggled ? P.accent : 'rgba(255,255,255,.06)';
770
886
  ctx.lineWidth = 2; ctx.setLineDash(DASH_FLOW); ctx.lineDashOffset = -flowT * 16;
771
887
  ctx.beginPath(); ctx.moveTo(8, st.h / 2); ctx.lineTo(st.w - 8, st.h / 2); ctx.stroke();
772
888
  ctx.setLineDash(DASH_NONE); ctx.lineDashOffset = 0;
773
889
 
774
890
  // Flowing sparks -- fixed pool, per-spark alpha via globalAlpha
775
891
  if (st.toggled) {
776
- ctx.fillStyle = '#22d3ee';
892
+ ctx.fillStyle = P.accent;
777
893
  let alive = 0;
778
894
  for (let i = 0; i < SPARKS; i++) {
779
895
  const s = spark[i];
@@ -790,16 +906,21 @@ export function CircuitToggle({ seed = 42 } = {}) {
790
906
  if (alive < 12) spawn(0);
791
907
  }
792
908
 
793
- ctx.fillStyle = st.toggled ? '#22d3ee' : '#9999b8';
909
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
794
910
  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');
911
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent : P.dim2);
796
912
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
797
913
  },
798
914
  };
799
915
  }
800
916
 
801
917
  /** 3. Lightning Toggle -- Electric arc between endpoints. */
802
- export function LightningToggle({ seed = 42 } = {}) {
918
+ export function LightningToggle(o = {}) {
919
+ const { seed = 42 } = o;
920
+ const P = resolveTheme(o, { accent: '#fbbf24', dim: '#9999b8', dim2: '#8888aa' });
921
+ const themed = !!(o.theme || o.colors);
922
+ const onTint = themed ? rgbaOf(P.accent, .15) : 'rgba(251,191,36,.15)';
923
+ const arcColor = themed ? rgbaOf(P.accent, .7) : 'rgba(251,191,36,.7)';
803
924
  const rng = new Random(seed);
804
925
  let knobX = 18, arcTime = 0;
805
926
  function bolt(ctx, x1, y1, x2, y2, depth) {
@@ -814,27 +935,28 @@ export function LightningToggle({ seed = 42 } = {}) {
814
935
  knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 14);
815
936
  arcTime += dt;
816
937
 
817
- ctx.fillStyle = st.toggled ? 'rgba(251,191,36,.15)' : 'rgba(255,255,255,.04)';
938
+ ctx.fillStyle = st.toggled ? onTint : 'rgba(255,255,255,.04)';
818
939
  roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
819
940
 
820
941
  if (st.toggled && ((arcTime * 8) | 0) % 3 !== 0) {
821
942
  rng.reset(((now / 80) | 0) * 7 + 1);
822
- ctx.strokeStyle = 'rgba(251,191,36,.7)'; ctx.lineWidth = 1.5;
943
+ ctx.strokeStyle = arcColor; ctx.lineWidth = 1.5;
823
944
  ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
824
945
  ctx.strokeStyle = 'rgba(255,255,255,.3)'; ctx.lineWidth = 0.5;
825
946
  ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
826
947
  }
827
948
 
828
- ctx.fillStyle = st.toggled ? '#fbbf24' : '#9999b8';
949
+ ctx.fillStyle = st.toggled ? P.accent : P.dim;
829
950
  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');
951
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent : P.dim2);
831
952
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
832
953
  },
833
954
  };
834
955
  }
835
956
 
836
957
  /** 4. DNA Toggle -- Double helix wraps around the track. */
837
- export function DNAToggle() {
958
+ export function DNAToggle(o = {}) {
959
+ const P = resolveTheme(o, { accent: '#a78bfa', accent2: '#f472b6', accent3: '#c084fc', dim: '#9999b8', dim2: '#8888aa' });
838
960
  let knobX = 18, phase = 0;
839
961
  return {
840
962
  tick(ctx, dt, now, st) {
@@ -852,9 +974,9 @@ export function DNAToggle() {
852
974
  // Const strand colors, per-dot alpha via globalAlpha (was two
853
975
  // `rgba(...,${...})` templates built per dot per frame).
854
976
  if (st.toggled) {
855
- ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.3 + Math.sin(phase + t * 6) * 0.2;
977
+ ctx.fillStyle = P.accent; ctx.globalAlpha = 0.3 + Math.sin(phase + t * 6) * 0.2;
856
978
  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;
979
+ ctx.fillStyle = P.accent2; ctx.globalAlpha = 0.3 + Math.cos(phase + t * 6) * 0.2;
858
980
  ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
859
981
  ctx.globalAlpha = 1;
860
982
  } else {
@@ -869,9 +991,9 @@ export function DNAToggle() {
869
991
  }
870
992
  }
871
993
 
872
- ctx.fillStyle = st.toggled ? '#c084fc' : '#9999b8';
994
+ ctx.fillStyle = st.toggled ? P.accent3 : P.dim;
873
995
  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');
996
+ label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? P.accent3 : P.dim2);
875
997
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
876
998
  },
877
999
  };
@@ -883,7 +1005,12 @@ export function DNAToggle() {
883
1005
  // ===========================================================
884
1006
 
885
1007
  /** 5. Heartbeat Button -- Heart icon pumps with particle burst on click. */
886
- export function HeartbeatButton({ seed = 42 } = {}) {
1008
+ export function HeartbeatButton(o = {}) {
1009
+ const { seed = 42 } = o;
1010
+ const P = resolveTheme(o, { accent: '#DC143C', accent2: '#ff3c64' });
1011
+ const themed = !!(o.theme || o.colors);
1012
+ const tintOn = themed ? rgbaOf(P.accent, .2) : 'rgba(220,20,60,.2)';
1013
+ const tintOff = themed ? rgbaOf(P.accent, .08) : 'rgba(220,20,60,.08)';
887
1014
  const rng = new Random(seed);
888
1015
  // Fixed particle pool (life <= 0 == dead) -- no push/splice on the hot path.
889
1016
  const PARTS = 32;
@@ -910,12 +1037,12 @@ export function HeartbeatButton({ seed = 42 } = {}) {
910
1037
  beatPhase += dt * 5;
911
1038
  const pulse = 1 + Math.sin(beatPhase) * 0.03;
912
1039
 
913
- ctx.fillStyle = st.active ? 'rgba(220,20,60,.2)' : 'rgba(220,20,60,.08)';
1040
+ ctx.fillStyle = st.active ? tintOn : tintOff;
914
1041
  roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
915
1042
 
916
1043
  // Heart shape
917
1044
  ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(scale * pulse, scale * pulse);
918
- ctx.fillStyle = '#DC143C';
1045
+ ctx.fillStyle = P.accent;
919
1046
  ctx.beginPath();
920
1047
  ctx.moveTo(0, 4); ctx.bezierCurveTo(-10, -6, -20, -2, -20, 4);
921
1048
  ctx.bezierCurveTo(-20, 14, 0, 20, 0, 20);
@@ -925,7 +1052,7 @@ export function HeartbeatButton({ seed = 42 } = {}) {
925
1052
 
926
1053
  // Particles -- fixed pool, const color, alpha via globalAlpha
927
1054
  ctx.globalCompositeOperation = 'screen';
928
- ctx.fillStyle = '#ff3c64';
1055
+ ctx.fillStyle = P.accent2;
929
1056
  for (let i = 0; i < PARTS; i++) {
930
1057
  const p = part[i];
931
1058
  if (p.life <= 0) continue;
@@ -942,13 +1069,17 @@ export function HeartbeatButton({ seed = 42 } = {}) {
942
1069
  }
943
1070
 
944
1071
  /** 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.
1072
+ export function BreathingButton(o = {}) {
1073
+ const P = resolveTheme(o, { accent: '#6ee7b6' });
1074
+ const RGB = (o.theme || o.colors) ? rgbTriplet(P.accent, '110,231,182') : '110,231,182';
1075
+ const FONT = pickFont(o, "600 12px 'Space Grotesk',sans-serif");
1076
+ const TXT = pickText(o, 'BREATHE');
1077
+ // Alpha-graded accent LUT (quantized alpha) built once, so the pulsing glow is
1078
+ // a const-string lookup instead of three `rgba(...,${...})` templates per
1079
+ // frame. shadowColor's alpha is not globalAlpha, so a LUT (not globalAlpha) is
1080
+ // the zero-alloc fit here. RGB defaults to the historical green for byte-parity.
950
1081
  const GREEN = [];
951
- for (let i = 0; i <= 64; i++) GREEN[i] = 'rgba(110,231,182,' + (i / 64).toFixed(3) + ')';
1082
+ for (let i = 0; i <= 64; i++) GREEN[i] = 'rgba(' + RGB + ',' + (i / 64).toFixed(3) + ')';
952
1083
  function greenA(a) { const i = a <= 0 ? 0 : a >= 1 ? 64 : (a * 64) | 0; return GREEN[i]; }
953
1084
  let phase = 0;
954
1085
  return {
@@ -968,16 +1099,20 @@ export function BreathingButton() {
968
1099
  ctx.lineWidth = 1;
969
1100
  ctx.beginPath(); ctx.arc(st.w / 2, st.h / 2, st.w / 2 + radius, 0, Math.PI * 2); ctx.stroke();
970
1101
 
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);
1102
+ ctx.fillStyle = P.accent;
1103
+ ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1104
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
974
1105
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
975
1106
  },
976
1107
  };
977
1108
  }
978
1109
 
979
1110
  /** 7. Ink Splash Button -- Calligraphy ink splatter on click. */
980
- export function InkSplashButton({ seed = 42 } = {}) {
1111
+ export function InkSplashButton(o = {}) {
1112
+ const { seed = 42 } = o;
1113
+ const P = resolveTheme(o, { accent: '#1e1e32', dim: '#e2e2f0' });
1114
+ const FONT = pickFont(o, "600 13px 'Space Grotesk',sans-serif");
1115
+ const TXT = pickText(o, 'INK');
981
1116
  const rng = new Random(seed);
982
1117
  // Fixed splat pool (life <= 0 == dead).
983
1118
  const SPLATS = 32;
@@ -1005,12 +1140,12 @@ export function InkSplashButton({ seed = 42 } = {}) {
1005
1140
  ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
1006
1141
 
1007
1142
  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);
1143
+ ctx.fillStyle = P.dim; ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1144
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
1010
1145
  ctx.restore();
1011
1146
 
1012
1147
  // Splats -- fixed pool, const color, alpha via globalAlpha
1013
- ctx.fillStyle = '#1e1e32';
1148
+ ctx.fillStyle = P.accent;
1014
1149
  for (let i = 0; i < SPLATS; i++) {
1015
1150
  const s = splat[i];
1016
1151
  if (s.life <= 0) continue;
@@ -1026,7 +1161,12 @@ export function InkSplashButton({ seed = 42 } = {}) {
1026
1161
  }
1027
1162
 
1028
1163
  /** 8. Pixel Dissolve Button -- Hover breaks into floating pixels, reforms on leave. */
1029
- export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1164
+ export function PixelDissolveButton(o = {}) {
1165
+ const { seed = 42, cols = 16, rows = 5 } = o;
1166
+ const P = resolveTheme(o, { accent: '#a78bfa', dim: '#e2e2f0' });
1167
+ const hoverTint = (o.theme || o.colors) ? rgbaOf(P.accent, .4) : 'rgba(167,139,250,.4)';
1168
+ const FONT = pickFont(o, "600 12px 'Space Grotesk',sans-serif");
1169
+ const TXT = pickText(o, 'DISSOLVE');
1030
1170
  const rng = new Random(seed);
1031
1171
  const N = cols * rows;
1032
1172
  const ox = new Float32Array(N), oy = new Float32Array(N);
@@ -1047,7 +1187,7 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1047
1187
  tick(ctx, dt, now, st) {
1048
1188
  dissolveT = lerp(dissolveT, st.hover ? 1 : 0, dt * 6);
1049
1189
 
1050
- ctx.fillStyle = st.hover ? 'rgba(167,139,250,.4)' : 'rgba(255,255,255,.12)';
1190
+ ctx.fillStyle = st.hover ? hoverTint : 'rgba(255,255,255,.12)';
1051
1191
  const pw = st.w / cols, ph = st.h / rows;
1052
1192
 
1053
1193
  for (let i = 0; i < N; i++) {
@@ -1061,10 +1201,10 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1061
1201
  ctx.globalAlpha = 1;
1062
1202
 
1063
1203
  if (dissolveT < 0.3) {
1064
- ctx.fillStyle = '#e2e2f0'; ctx.font = "600 12px 'Space Grotesk',sans-serif";
1204
+ ctx.fillStyle = P.dim; ctx.font = FONT;
1065
1205
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1066
1206
  ctx.globalAlpha = 1 - dissolveT / 0.3;
1067
- ctx.fillText('DISSOLVE', st.w / 2, st.h / 2);
1207
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
1068
1208
  ctx.globalAlpha = 1;
1069
1209
  }
1070
1210
  if (st.focused) focusRing(ctx, st.w, st.h, 0);
@@ -1073,9 +1213,16 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
1073
1213
  }
1074
1214
 
1075
1215
  /** 9. Firework Button -- Shoots fireworks upward on click. */
1076
- export function FireworkButton({ seed = 42 } = {}) {
1216
+ export function FireworkButton(o = {}) {
1217
+ const { seed = 42 } = o;
1218
+ // `colors` is the firework palette (like ConfettiButton); `theme` seeds it and
1219
+ // recolors the label.
1220
+ const colors = o.colors ? o.colors
1221
+ : (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark] : ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff6bcb']);
1222
+ const labelColor = (o.theme && o.theme.light) || '#fbbf24';
1223
+ const FONT = pickFont(o, "600 13px 'Space Grotesk',sans-serif");
1224
+ const TXT = pickText(o, '\u{1F386} FIRE');
1077
1225
  const rng = new Random(seed);
1078
- const colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff6bcb'];
1079
1226
  // Fixed pools (life <= 0 == dead) -- no push/splice on the hot path.
1080
1227
  const ROCKETS = 16, SPARKS = 256;
1081
1228
  const rocket = [];
@@ -1110,8 +1257,8 @@ export function FireworkButton({ seed = 42 } = {}) {
1110
1257
  pressScale = lerp(pressScale, 1, dt * 10);
1111
1258
  ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
1112
1259
  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);
1260
+ ctx.fillStyle = labelColor; ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1261
+ ctx.fillText(TXT, st.w / 2, st.h / 2);
1115
1262
  ctx.restore();
1116
1263
 
1117
1264
  // Rockets -- fixed pool; on death, burst into sparks
@@ -1148,9 +1295,12 @@ export function FireworkButton({ seed = 42 } = {}) {
1148
1295
  // ===========================================================
1149
1296
 
1150
1297
  /** 10. Aurora Slider -- Northern lights colors flow along the filled track. */
1151
- export function AuroraSlider() {
1298
+ export function AuroraSlider(o = {}) {
1152
1299
  const REF = 100, PHASES = 12;
1153
- const colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#c084fc'];
1300
+ // `colors` is the aurora gradient palette; `theme` seeds it.
1301
+ const colors = o.colors ? o.colors
1302
+ : (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#6ee7b6', '#38bdf8', '#a78bfa', '#c084fc']);
1303
+ const glowColor = (o.theme && o.theme.light) || (o.colors && o.colors[0]) || '#6ee7b6';
1154
1304
  let time = 0;
1155
1305
  // The shimmer animated the gradient stops per frame. Pre-build one gradient
1156
1306
  // per animation phase in init (all 0..REF) and cycle them by time; scale to
@@ -1163,7 +1313,7 @@ export function AuroraSlider() {
1163
1313
  const g = ctx.createLinearGradient(0, 0, REF, 0);
1164
1314
  for (let i = 0; i < 4; i++) {
1165
1315
  const pos = clamp(i / 3 + Math.sin(t + i) * 0.15, 0, 1);
1166
- g.addColorStop(pos, colors[i]);
1316
+ g.addColorStop(pos, colors[i % colors.length]);
1167
1317
  }
1168
1318
  beams[p] = g;
1169
1319
  }
@@ -1176,7 +1326,7 @@ export function AuroraSlider() {
1176
1326
  const beam = beams[((time * 2) | 0) % PHASES] || beams[0];
1177
1327
  ctx.save();
1178
1328
  ctx.scale(tx / REF, 1);
1179
- ctx.fillStyle = beam; ctx.shadowBlur = 8; ctx.shadowColor = '#6ee7b6';
1329
+ ctx.fillStyle = beam; ctx.shadowBlur = 8; ctx.shadowColor = glowColor;
1180
1330
  roundRect(ctx, 0, 12, REF, 4, 2); ctx.fill();
1181
1331
  ctx.restore();
1182
1332
  ctx.shadowBlur = 0;
@@ -1189,7 +1339,9 @@ export function AuroraSlider() {
1189
1339
  }
1190
1340
 
1191
1341
  /** 11. Wave Slider -- Audio waveform visualization on the filled track. */
1192
- export function WaveSlider({ seed = 42 } = {}) {
1342
+ export function WaveSlider(o = {}) {
1343
+ const { seed = 42 } = o;
1344
+ const P = resolveTheme(o, { accent: '#f472b6' });
1193
1345
  const rng = new Random(seed);
1194
1346
  const bars = new Float32Array(40);
1195
1347
  return {
@@ -1200,10 +1352,10 @@ export function WaveSlider({ seed = 42 } = {}) {
1200
1352
  for (let i = 0; i < 40; i++) {
1201
1353
  const bx = i * bw, active = bx < tx;
1202
1354
  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)';
1355
+ ctx.fillStyle = active ? P.accent : 'rgba(255,255,255,.06)';
1204
1356
  ctx.fillRect(bx + 1, 14 - h / 2, bw - 2, h);
1205
1357
  }
1206
- ctx.fillStyle = '#f472b6'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
1358
+ ctx.fillStyle = P.accent; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
1207
1359
  label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1208
1360
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1209
1361
  },
@@ -1211,7 +1363,8 @@ export function WaveSlider({ seed = 42 } = {}) {
1211
1363
  }
1212
1364
 
1213
1365
  /** 12. Elastic Band Slider -- Track rubber-bands ahead of thumb, snaps back. */
1214
- export function ElasticBandSlider() {
1366
+ export function ElasticBandSlider(o = {}) {
1367
+ const P = resolveTheme(o, { accent: '#fb923c' });
1215
1368
  let leadX = 0;
1216
1369
  return {
1217
1370
  tick(ctx, dt, now, st) {
@@ -1222,12 +1375,12 @@ export function ElasticBandSlider() {
1222
1375
  ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
1223
1376
 
1224
1377
  // Elastic band (curves toward target)
1225
- ctx.strokeStyle = '#fb923c'; ctx.lineWidth = 3;
1378
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 3;
1226
1379
  ctx.beginPath(); ctx.moveTo(0, 14);
1227
1380
  ctx.quadraticCurveTo(leadX + stretch, 14 + stretch * 0.2, tx, 14);
1228
1381
  ctx.stroke();
1229
1382
 
1230
- ctx.fillStyle = '#fb923c'; ctx.beginPath(); ctx.arc(tx, 14, 9, 0, Math.PI * 2); ctx.fill();
1383
+ ctx.fillStyle = P.accent; ctx.beginPath(); ctx.arc(tx, 14, 9, 0, Math.PI * 2); ctx.fill();
1231
1384
  label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
1232
1385
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1233
1386
  },
@@ -1235,7 +1388,8 @@ export function ElasticBandSlider() {
1235
1388
  }
1236
1389
 
1237
1390
  /** 13. Gravity Slider -- Track sags under the weight of the thumb. */
1238
- export function GravitySlider() {
1391
+ export function GravitySlider(o = {}) {
1392
+ const P = resolveTheme(o, { accent: '#c084fc' });
1239
1393
  return {
1240
1394
  tick(ctx, dt, now, st) {
1241
1395
  const tx = st.val * st.w, sag = 8;
@@ -1247,13 +1401,13 @@ export function GravitySlider() {
1247
1401
  ctx.stroke();
1248
1402
 
1249
1403
  // Filled portion follows the sag
1250
- ctx.strokeStyle = '#c084fc'; ctx.lineWidth = 3;
1404
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 3;
1251
1405
  ctx.beginPath(); ctx.moveTo(0, 14);
1252
1406
  const midSag = sag * (tx / st.w);
1253
1407
  ctx.quadraticCurveTo(tx / 2, 14 + midSag, tx, 14 + sag * Math.sin(Math.PI * st.val));
1254
1408
  ctx.stroke();
1255
1409
 
1256
- ctx.fillStyle = '#c084fc'; ctx.beginPath();
1410
+ ctx.fillStyle = P.accent; ctx.beginPath();
1257
1411
  ctx.arc(tx, 14 + sag * Math.sin(Math.PI * st.val), 10, 0, Math.PI * 2); ctx.fill();
1258
1412
 
1259
1413
  label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 14);
@@ -1268,12 +1422,13 @@ export function GravitySlider() {
1268
1422
  // ===========================================================
1269
1423
 
1270
1424
  /** 14. Orbit Loader -- Planets orbit a sun. Toggle starts/stops. */
1271
- export function OrbitLoader() {
1425
+ export function OrbitLoader(o = {}) {
1426
+ const P = resolveTheme(o, { accent: '#38bdf8', accent2: '#6ee7b6', accent3: '#fbbf24', dim2: '#8888aa' });
1272
1427
  let phase = 0;
1273
1428
  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' },
1429
+ { r: 14, speed: 2.0, size: 3, color: P.accent },
1430
+ { r: 22, speed: 1.2, size: 2.5, color: P.accent2 },
1431
+ { r: 30, speed: 0.7, size: 2, color: P.accent3 },
1277
1432
  ];
1278
1433
  return {
1279
1434
  tick(ctx, dt, now, st) {
@@ -1291,15 +1446,16 @@ export function OrbitLoader() {
1291
1446
  }
1292
1447
 
1293
1448
  // 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');
1449
+ ctx.fillStyle = P.accent3; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
1450
+ label(ctx, st.toggled ? 'LOADING...' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? P.accent3 : P.dim2);
1296
1451
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
1297
1452
  },
1298
1453
  };
1299
1454
  }
1300
1455
 
1301
1456
  /** 15. Helix Loader -- DNA double helix spinning. */
1302
- export function HelixLoader() {
1457
+ export function HelixLoader(o = {}) {
1458
+ const P = resolveTheme(o, { accent: '#a78bfa', accent2: '#f472b6', accent3: '#c084fc', dim2: '#8888aa' });
1303
1459
  let phase = 0;
1304
1460
  return {
1305
1461
  tick(ctx, dt, now, st) {
@@ -1314,16 +1470,16 @@ export function HelixLoader() {
1314
1470
 
1315
1471
  // Const strand colors, per-node alpha via globalAlpha (was three
1316
1472
  // `rgba(...,${...})` templates per node per frame).
1317
- ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.2 + depth * 0.6;
1473
+ ctx.fillStyle = P.accent; ctx.globalAlpha = 0.2 + depth * 0.6;
1318
1474
  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;
1475
+ ctx.fillStyle = P.accent2; ctx.globalAlpha = 0.2 + (1 - depth) * 0.6;
1320
1476
  ctx.beginPath(); ctx.arc(x2, y2, 2 + (1 - depth), 0, Math.PI * 2); ctx.fill();
1321
1477
 
1322
1478
  ctx.strokeStyle = '#ffffff'; ctx.globalAlpha = 0.03 + depth * 0.04;
1323
1479
  ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
1324
1480
  }
1325
1481
  ctx.globalAlpha = 1;
1326
- label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
1482
+ label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? P.accent3 : P.dim2);
1327
1483
  if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
1328
1484
  },
1329
1485
  };
@@ -1335,7 +1491,8 @@ export function HelixLoader() {
1335
1491
  // ===========================================================
1336
1492
 
1337
1493
  /** 16. Ripple Checkbox -- Material-style ripple ring + morphing checkmark. */
1338
- export function RippleCheck() {
1494
+ export function RippleCheck(o = {}) {
1495
+ const P = resolveTheme(o, { accent: '#6ee7b6', surface: '#000', dim2: '#8888aa' });
1339
1496
  // Fixed ripple pool (life <= 0 == dead) -- no push/splice on the hot path.
1340
1497
  const RIPPLES = 8;
1341
1498
  const ripR = new Float64Array(RIPPLES);
@@ -1353,11 +1510,11 @@ export function RippleCheck() {
1353
1510
  const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
1354
1511
 
1355
1512
  // Box
1356
- ctx.fillStyle = st.toggled ? '#6ee7b6' : 'rgba(255,255,255,.06)';
1513
+ ctx.fillStyle = st.toggled ? P.accent : 'rgba(255,255,255,.06)';
1357
1514
  roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1358
1515
 
1359
1516
  // Ripples -- fixed pool, const color, alpha via globalAlpha
1360
- ctx.strokeStyle = '#6ee7b6'; ctx.lineWidth = 2;
1517
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 2;
1361
1518
  for (let i = 0; i < RIPPLES; i++) {
1362
1519
  if (ripLife[i] <= 0) continue;
1363
1520
  ripR[i] += 50 * dt; ripLife[i] -= 2 * dt;
@@ -1369,7 +1526,7 @@ export function RippleCheck() {
1369
1526
 
1370
1527
  // Checkmark (animated draw)
1371
1528
  if (checkT > 0.01) {
1372
- ctx.strokeStyle = '#000'; ctx.lineWidth = 3; ctx.lineCap = 'round';
1529
+ ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
1373
1530
  ctx.beginPath();
1374
1531
  const p1 = clamp(checkT * 2, 0, 1); // First stroke
1375
1532
  const p2 = clamp(checkT * 2 - 1, 0, 1); // Second stroke
@@ -1378,14 +1535,15 @@ export function RippleCheck() {
1378
1535
  if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
1379
1536
  ctx.stroke(); ctx.lineCap = 'butt';
1380
1537
  }
1381
- label(ctx, st.toggled ? '\u2713' : '\u25CB', sz / 2, sz + 12, st.toggled ? '#6ee7b6' : '#8888aa');
1538
+ label(ctx, st.toggled ? '\u2713' : '\u25CB', sz / 2, sz + 12, st.toggled ? P.accent : P.dim2);
1382
1539
  if (st.focused) focusRing(ctx, sz, sz, 6);
1383
1540
  },
1384
1541
  };
1385
1542
  }
1386
1543
 
1387
1544
  /** 17. Morph Checkbox -- X morphs into checkmark smoothly. */
1388
- export function MorphCheck() {
1545
+ export function MorphCheck(o = {}) {
1546
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8', dim2: '#8888aa' });
1389
1547
  let t = 0;
1390
1548
  return {
1391
1549
  tick(ctx, dt, now, st) {
@@ -1397,12 +1555,12 @@ export function MorphCheck() {
1397
1555
  ctx.fillStyle = 'rgba(255,255,255,.06)';
1398
1556
  roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1399
1557
  } else {
1400
- ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = lerp(0, 0.2, t);
1558
+ ctx.fillStyle = P.accent; ctx.globalAlpha = lerp(0, 0.2, t);
1401
1559
  roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
1402
1560
  ctx.globalAlpha = 1;
1403
1561
  }
1404
1562
 
1405
- ctx.strokeStyle = t > 0.5 ? '#38bdf8' : '#9999b8'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
1563
+ ctx.strokeStyle = t > 0.5 ? P.accent : P.dim; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
1406
1564
 
1407
1565
  // Morph: X -> checkmark by interpolating endpoints
1408
1566
  const x1a = cx - 8, y1a = cy - 8; // X top-left
@@ -1425,19 +1583,101 @@ export function MorphCheck() {
1425
1583
  ctx.stroke();
1426
1584
  ctx.lineCap = 'butt';
1427
1585
 
1428
- label(ctx, st.toggled ? '\u2713' : '\u2715', sz / 2, sz + 12, st.toggled ? '#38bdf8' : '#8888aa');
1586
+ label(ctx, st.toggled ? '\u2713' : '\u2715', sz / 2, sz + 12, st.toggled ? P.accent : P.dim2);
1429
1587
  if (st.focused) focusRing(ctx, sz, sz, 6);
1430
1588
  },
1431
1589
  };
1432
1590
  }
1433
1591
 
1592
+ /** Tick Draw (U4a CHECKBOX) -- the checkmark strokes itself on; a centred dash
1593
+ * when indeterminate; empty when off. Reads st.toggled + st.indeterminate. */
1594
+ export function TickDraw(o = {}) {
1595
+ const P = resolveTheme(o, { accent: '#34d399', surface: '#0a0a12', dim2: '#8888aa' });
1596
+ const boxOff = 'rgba(255,255,255,.06)';
1597
+ const FONT = pickFont(o, "500 9px 'JetBrains Mono',monospace");
1598
+ let drawT = 0; // 0..1 stroke progress of the check
1599
+ return {
1600
+ tick(ctx, dt, now, st) {
1601
+ // Indeterminate suppresses the check; a full check draws only when on.
1602
+ drawT = lerp(drawT, st.indeterminate ? 0 : (st.toggled ? 1 : 0), dt * 12);
1603
+ const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2, r = 6;
1604
+
1605
+ ctx.fillStyle = (st.toggled || st.indeterminate) ? P.accent : boxOff;
1606
+ roundRect(ctx, 0, 0, sz, sz, r); ctx.fill();
1607
+
1608
+ ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
1609
+ if (st.indeterminate) {
1610
+ // Standard indeterminate glyph: one centred dash.
1611
+ ctx.beginPath(); ctx.moveTo(sz * 0.28, cy); ctx.lineTo(sz * 0.72, cy); ctx.stroke();
1612
+ } else if (drawT > 0.01) {
1613
+ const p1 = clamp(drawT * 2, 0, 1), p2 = clamp(drawT * 2 - 1, 0, 1);
1614
+ ctx.beginPath();
1615
+ ctx.moveTo(cx - 7, cy);
1616
+ ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
1617
+ if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
1618
+ ctx.stroke();
1619
+ }
1620
+ ctx.lineCap = 'butt';
1621
+
1622
+ label(ctx, st.indeterminate ? '\u2212' : (st.toggled ? '\u2713' : '\u25cb'),
1623
+ cx, sz + 12, (st.toggled || st.indeterminate) ? P.accent : P.dim2, FONT);
1624
+ if (st.focused) focusRing(ctx, sz, sz, r);
1625
+ },
1626
+ };
1627
+ }
1628
+
1629
+ /** Indeterminate Scan (U4a CHECKBOX) -- sweeps a scan line while indeterminate,
1630
+ * settles to a stroked check when toggled, empty when off. */
1631
+ export function IndeterminateScan(o = {}) {
1632
+ const P = resolveTheme(o, { accent: '#60a5fa', surface: '#0a0a12', dim2: '#8888aa' });
1633
+ const FONT = pickFont(o, "500 9px 'JetBrains Mono',monospace");
1634
+ let scan = 0; // 0..1 sweep phase
1635
+ let checkT = 0; // 0..1 check reveal
1636
+ return {
1637
+ tick(ctx, dt, now, st) {
1638
+ checkT = lerp(checkT, (!st.indeterminate && st.toggled) ? 1 : 0, dt * 12);
1639
+ const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2, r = 6;
1640
+
1641
+ ctx.fillStyle = (st.toggled && !st.indeterminate) ? P.accent : 'rgba(255,255,255,.06)';
1642
+ roundRect(ctx, 0, 0, sz, sz, r); ctx.fill();
1643
+
1644
+ if (st.indeterminate) {
1645
+ // Scan line sweeping down, fading at the sweep ends (globalAlpha,
1646
+ // not a per-frame colour string).
1647
+ scan += dt * 1.6; if (scan > 1) scan -= 1;
1648
+ const y = 4 + scan * (sz - 8);
1649
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 2; ctx.lineCap = 'round';
1650
+ ctx.globalAlpha = Math.sin(scan * Math.PI);
1651
+ ctx.beginPath(); ctx.moveTo(4, y); ctx.lineTo(sz - 4, y); ctx.stroke();
1652
+ ctx.globalAlpha = 1; ctx.lineCap = 'butt';
1653
+ } else if (checkT > 0.01) {
1654
+ const p1 = clamp(checkT * 2, 0, 1), p2 = clamp(checkT * 2 - 1, 0, 1);
1655
+ ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
1656
+ ctx.beginPath();
1657
+ ctx.moveTo(cx - 7, cy);
1658
+ ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
1659
+ if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
1660
+ ctx.stroke(); ctx.lineCap = 'butt';
1661
+ }
1662
+
1663
+ label(ctx, st.indeterminate ? '\u2212' : (st.toggled ? '\u2713' : '\u25cb'),
1664
+ cx, sz + 12, (st.toggled || st.indeterminate) ? P.accent : P.dim2, FONT);
1665
+ if (st.focused) focusRing(ctx, sz, sz, r);
1666
+ },
1667
+ };
1668
+ }
1669
+
1434
1670
 
1435
1671
  // ===========================================================
1436
1672
  // COUNTERS (use slider val as input)
1437
1673
  // ===========================================================
1438
1674
 
1439
1675
  /** 18. Flame Counter -- Number with rising heat particles driven by slider value. */
1440
- export function FlameCounter({ seed = 42 } = {}) {
1676
+ export function FlameCounter(o = {}) {
1677
+ const { seed = 42 } = o;
1678
+ const P = resolveTheme(o, { accent: '#ff6b6b', accent2: '#fbbf24', dim: '#9999b8' });
1679
+ const FONT = pickFont(o, "700 28px 'JetBrains Mono',monospace");
1680
+ const TXT = pickText(o, 'HEAT');
1441
1681
  const rng = new Random(seed);
1442
1682
  // Fixed ember pool + heat-color LUT (rgb quantized by value), so neither the
1443
1683
  // pool nor the per-ember color allocates. The number string is rebuilt only
@@ -1480,18 +1720,22 @@ export function FlameCounter({ seed = 42 } = {}) {
1480
1720
  ctx.globalCompositeOperation = 'source-over';
1481
1721
 
1482
1722
  // 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';
1723
+ ctx.fillStyle = st.val > 0.7 ? P.accent : st.val > 0.3 ? P.accent2 : P.dim;
1724
+ ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1485
1725
  ctx.fillText(numStr, st.w / 2, st.h / 2);
1486
1726
 
1487
- label(ctx, 'HEAT', st.w / 2, st.h + 12);
1727
+ label(ctx, TXT, st.w / 2, st.h + 12);
1488
1728
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
1489
1729
  },
1490
1730
  };
1491
1731
  }
1492
1732
 
1493
1733
  /** 19. Glitch Counter -- Number glitches and jitters as slider value increases. */
1494
- export function GlitchCounter({ seed = 42 } = {}) {
1734
+ export function GlitchCounter(o = {}) {
1735
+ const { seed = 42 } = o;
1736
+ const P = resolveTheme(o, { accent: '#ff0055', accent2: '#00ffcc', dim: '#e2e2f0' });
1737
+ const FONT = pickFont(o, "700 28px 'JetBrains Mono',monospace");
1738
+ const TXT = pickText(o, 'SIGNAL');
1495
1739
  const rng = new Random(seed);
1496
1740
  let numStr = '0', lastNum = -1;
1497
1741
  return {
@@ -1515,22 +1759,22 @@ export function GlitchCounter({ seed = 42 } = {}) {
1515
1759
 
1516
1760
  if (intensity > 0.3 && rng.next() > 0.6) {
1517
1761
  ctx.globalCompositeOperation = 'screen';
1518
- ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1762
+ ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1519
1763
  // RGB split -- const channel colors, shared alpha via globalAlpha
1520
1764
  ctx.globalAlpha = intensity * 0.4;
1521
- ctx.fillStyle = '#ff0055';
1765
+ ctx.fillStyle = P.accent;
1522
1766
  ctx.fillText(numStr, st.w / 2 + jx - 2, st.h / 2 + jy);
1523
- ctx.fillStyle = '#00ffcc';
1767
+ ctx.fillStyle = P.accent2;
1524
1768
  ctx.fillText(numStr, st.w / 2 + jx + 2, st.h / 2 + jy);
1525
1769
  ctx.globalAlpha = 1;
1526
1770
  ctx.globalCompositeOperation = 'source-over';
1527
1771
  }
1528
1772
 
1529
- ctx.fillStyle = '#e2e2f0';
1530
- ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1773
+ ctx.fillStyle = P.dim;
1774
+ ctx.font = FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1531
1775
  ctx.fillText(numStr, st.w / 2 + jx, st.h / 2 + jy);
1532
1776
 
1533
- label(ctx, 'SIGNAL', st.w / 2, st.h + 12);
1777
+ label(ctx, TXT, st.w / 2, st.h + 12);
1534
1778
  if (st.focused) focusRing(ctx, st.w, st.h, 10);
1535
1779
  },
1536
1780
  };
@@ -1542,7 +1786,9 @@ export function GlitchCounter({ seed = 42 } = {}) {
1542
1786
  // ===========================================================
1543
1787
 
1544
1788
  /** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
1545
- export function BubbleRating({ seed = 42 } = {}) {
1789
+ export function BubbleRating(o = {}) {
1790
+ const { seed = 42 } = o;
1791
+ const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8' });
1546
1792
  const rng = new Random(seed);
1547
1793
  const R5 = ['0 / 5', '1 / 5', '2 / 5', '3 / 5', '4 / 5', '5 / 5']; // const labels
1548
1794
  const POPS = 32;
@@ -1575,11 +1821,11 @@ export function BubbleRating({ seed = 42 } = {}) {
1575
1821
  const cx = gap * i + gap / 2, cy = st.h / 2;
1576
1822
 
1577
1823
  // Bubble -- const color, active alpha via globalAlpha
1578
- if (active) { ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
1824
+ if (active) { ctx.fillStyle = P.accent; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
1579
1825
  else { ctx.fillStyle = 'rgba(255,255,255,.05)'; ctx.globalAlpha = 1; }
1580
1826
  ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.fill();
1581
1827
  ctx.globalAlpha = 1;
1582
- ctx.strokeStyle = active ? '#38bdf8' : 'rgba(255,255,255,.08)';
1828
+ ctx.strokeStyle = active ? P.accent : 'rgba(255,255,255,.08)';
1583
1829
  ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.stroke();
1584
1830
 
1585
1831
  // Highlight
@@ -1590,7 +1836,7 @@ export function BubbleRating({ seed = 42 } = {}) {
1590
1836
  }
1591
1837
 
1592
1838
  // Pop particles -- fixed pool, const color, alpha via globalAlpha
1593
- ctx.fillStyle = '#38bdf8';
1839
+ ctx.fillStyle = P.accent;
1594
1840
  for (let i = 0; i < POPS; i++) {
1595
1841
  const p = pop[i];
1596
1842
  if (p.life <= 0) continue;
@@ -1601,7 +1847,7 @@ export function BubbleRating({ seed = 42 } = {}) {
1601
1847
  }
1602
1848
  ctx.globalAlpha = 1;
1603
1849
 
1604
- label(ctx, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? '#38bdf8' : '#9999b8');
1850
+ label(ctx, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? P.accent : P.dim);
1605
1851
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1606
1852
  },
1607
1853
  onDrag(val) {
@@ -1620,14 +1866,18 @@ export function BubbleRating({ seed = 42 } = {}) {
1620
1866
  // ===========================================================
1621
1867
 
1622
1868
  /** 1. Volume Knob -- Rotary dial with tick marks and arc indicator. */
1623
- export function VolumeKnob() {
1869
+ export function VolumeKnob(o = {}) {
1870
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#38bdf8', dim: '#e2e2f0' });
1871
+ const tickOn = (o.theme || o.colors) ? rgbaOf(P.accent, .5) : 'rgba(110,231,182,.5)';
1872
+ const FONT = pickFont(o, "700 14px 'JetBrains Mono',monospace");
1873
+ const TXT = pickText(o, 'VOLUME');
1624
1874
  let displayVal = 0, grad = null;
1625
1875
  let numStr = '0', lastNum = -1;
1626
1876
  return {
1627
1877
  init(c, w, h) {
1628
1878
  // Value-arc gradient depends only on w/h (fixed per mount) -> build once.
1629
1879
  grad = c.createLinearGradient(0, h, w, 0);
1630
- grad.addColorStop(0, '#6ee7b6'); grad.addColorStop(1, '#38bdf8');
1880
+ grad.addColorStop(0, P.accent); grad.addColorStop(1, P.accent2);
1631
1881
  },
1632
1882
  tick(c,dt,now,st) {
1633
1883
  displayVal = lerp(displayVal, st.val, dt * 12);
@@ -1647,7 +1897,7 @@ export function VolumeKnob() {
1647
1897
  for(let i=0;i<=10;i++){
1648
1898
  const a=startA+i/10*range;
1649
1899
  const inner=R-6, outer=R+2;
1650
- c.strokeStyle=i/10<=displayVal?'rgba(110,231,182,.5)':'rgba(255,255,255,.08)';
1900
+ c.strokeStyle=i/10<=displayVal?tickOn:'rgba(255,255,255,.08)';
1651
1901
  c.lineWidth=1; c.beginPath();
1652
1902
  c.moveTo(cx+Math.cos(a)*inner,cy+Math.sin(a)*inner);
1653
1903
  c.lineTo(cx+Math.cos(a)*outer,cy+Math.sin(a)*outer);
@@ -1665,18 +1915,19 @@ export function VolumeKnob() {
1665
1915
  // Value text (rebuilt only when the integer changes)
1666
1916
  const v = Math.round(displayVal * 100);
1667
1917
  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';
1918
+ c.fillStyle=P.dim; c.font=FONT; c.textAlign='center'; c.textBaseline='middle';
1669
1919
  c.fillText(numStr,cx,cy);
1670
- lbl(c,'VOLUME',cx,st.h+10);
1920
+ lbl(c,TXT,cx,st.h+10);
1671
1921
  if(st.focused)fr(c,st.w,st.h,st.h/2);
1672
1922
  },
1673
1923
  };
1674
1924
  }
1675
1925
 
1676
1926
  /** 2. Compass Knob -- Needle points based on slider value (0=N, 0.5=S, 1=N). */
1677
- export function CompassKnob() {
1927
+ export function CompassKnob(o = {}) {
1928
+ const P = resolveTheme(o, { accent: '#ff6b6b', dim: '#9999b8', surface: '#333' });
1678
1929
  const dirs = ['N', 'E', 'S', 'W']; // hoisted out of tick
1679
- const cols = ['#ff6b6b', '#9999b8', '#9999b8', '#9999b8']; // (were per-frame arrays)
1930
+ const cols = [P.accent, P.dim, P.dim, P.dim]; // (were per-frame arrays)
1680
1931
  let needleA = 0;
1681
1932
  let degStr = '0\u00B0', lastDeg = -1;
1682
1933
  return {
@@ -1698,14 +1949,14 @@ export function CompassKnob() {
1698
1949
 
1699
1950
  // Needle
1700
1951
  c.save(); c.translate(cx,cy); c.rotate(needleA-Math.PI/2);
1701
- c.fillStyle='#ff6b6b';
1952
+ c.fillStyle=P.accent;
1702
1953
  c.beginPath(); c.moveTo(0,-R+18); c.lineTo(-4,4); c.lineTo(4,4); c.closePath(); c.fill();
1703
1954
  c.fillStyle='rgba(255,255,255,.15)';
1704
1955
  c.beginPath(); c.moveTo(0,R-18); c.lineTo(-4,-4); c.lineTo(4,-4); c.closePath(); c.fill();
1705
1956
  c.restore();
1706
1957
 
1707
1958
  // Center pin
1708
- c.fillStyle='#333'; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.fill();
1959
+ c.fillStyle=P.surface; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.fill();
1709
1960
  c.strokeStyle='rgba(255,255,255,.1)'; c.lineWidth=1; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.stroke();
1710
1961
 
1711
1962
  const deg = Math.round(st.val * 360);
@@ -1722,7 +1973,11 @@ export function CompassKnob() {
1722
1973
  // ===========================================================
1723
1974
 
1724
1975
  /** 3. Ring Progress -- Circular progress with animated fill and particles at the tip. */
1725
- export function RingProgress({seed=42}={}) {
1976
+ export function RingProgress(o = {}) {
1977
+ const {seed=42}=o;
1978
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#a78bfa', dim: '#e2e2f0' });
1979
+ const tail = (o.theme || o.colors) ? rgbaOf(P.accent, .2) : 'rgba(110,231,182,.2)';
1980
+ const FONT = pickFont(o, "700 16px 'JetBrains Mono',monospace");
1726
1981
  const rng=new Random(seed);
1727
1982
  // Fixed spark pool + a bounded set of conic gradients (one per progress
1728
1983
  // level) built in init, so the green stop still tracks the arc tip with zero
@@ -1743,9 +1998,9 @@ export function RingProgress({seed=42}={}) {
1743
1998
  init(c, w, h) {
1744
1999
  for (let p = 0; p < GPHASES; p++) {
1745
2000
  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)');
2001
+ g.addColorStop(0, P.accent2);
2002
+ g.addColorStop((p / (GPHASES - 1)) * 0.95, P.accent);
2003
+ g.addColorStop(1, tail);
1749
2004
  grads[p] = g;
1750
2005
  }
1751
2006
  },
@@ -1767,7 +2022,7 @@ export function RingProgress({seed=42}={}) {
1767
2022
  const tx=cx+Math.cos(ea)*R,ty=cy+Math.sin(ea)*R;
1768
2023
  spawn(tx, ty);
1769
2024
  }
1770
- c.fillStyle = '#6ee7b6';
2025
+ c.fillStyle = P.accent;
1771
2026
  for(let i=0;i<SPARKS;i++){
1772
2027
  const s=spark[i];
1773
2028
  if(s.life<=0)continue;
@@ -1779,7 +2034,7 @@ export function RingProgress({seed=42}={}) {
1779
2034
  c.globalAlpha=1;
1780
2035
 
1781
2036
  // Percentage
1782
- c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2037
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1783
2038
  c.fillText(PCT[Math.round(displayVal*100)],cx,cy);
1784
2039
  if(st.focused)fr(c,st.w,st.h,st.h/2);
1785
2040
  },
@@ -1787,7 +2042,9 @@ export function RingProgress({seed=42}={}) {
1787
2042
  }
1788
2043
 
1789
2044
  /** 4. Battery Gauge -- Battery icon that fills and changes color. */
1790
- export function BatteryGauge() {
2045
+ export function BatteryGauge(o = {}) {
2046
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#fbbf24', accent3: '#ff6b6b', dim: '#e2e2f0' });
2047
+ const FONT = pickFont(o, "600 10px 'JetBrains Mono',monospace");
1791
2048
  let displayVal=0;
1792
2049
  return {
1793
2050
  tick(c,dt,now,st) {
@@ -1803,7 +2060,7 @@ export function BatteryGauge() {
1803
2060
 
1804
2061
  // Fill
1805
2062
  const fillW=Math.max(0,(bw-6)*displayVal);
1806
- const col=displayVal<.2?'#ff6b6b':displayVal<.5?'#fbbf24':'#6ee7b6';
2063
+ const col=displayVal<.2?P.accent3:displayVal<.5?P.accent2:P.accent;
1807
2064
  c.fillStyle=col;
1808
2065
  rr(c,bx+3,by+3,fillW,bh-6,2);c.fill();
1809
2066
 
@@ -1820,7 +2077,7 @@ export function BatteryGauge() {
1820
2077
  c.fillStyle='rgba(255,80,80,.15)';rr(c,bx,by,bw,bh,r);c.fill();
1821
2078
  }
1822
2079
 
1823
- c.fillStyle='#e2e2f0';c.font="600 10px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2080
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1824
2081
  c.fillText(PCT[Math.round(displayVal*100)],bx+bw/2,by+bh/2);
1825
2082
  if(st.focused)fr(c,st.w+4,st.h,r);
1826
2083
  },
@@ -1828,7 +2085,8 @@ export function BatteryGauge() {
1828
2085
  }
1829
2086
 
1830
2087
  /** 5. Signal Meter -- WiFi-style signal bars. */
1831
- export function SignalMeter() {
2088
+ export function SignalMeter(o = {}) {
2089
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#fbbf24', accent3: '#ff6b6b', dim: '#9999b8' });
1832
2090
  let bars=[0,0,0,0,0];
1833
2091
  return {
1834
2092
  tick(c,dt,now,st) {
@@ -1844,10 +2102,10 @@ export function SignalMeter() {
1844
2102
  const maxH=8+i*6, h=maxH*bars[i]+2;
1845
2103
  const x=ox+i*(bw+gap), y=st.h-6-h;
1846
2104
  const active=bars[i]>.5;
1847
- c.fillStyle=active?(st.val>.6?'#6ee7b6':st.val>.3?'#fbbf24':'#ff6b6b'):'rgba(255,255,255,.06)';
2105
+ c.fillStyle=active?(st.val>.6?P.accent:st.val>.3?P.accent2:P.accent3):'rgba(255,255,255,.06)';
1848
2106
  rr(c,x,y,bw,h,2);c.fill();
1849
2107
  }
1850
- lbl(c,level+'/5',st.w/2,st.h+10,st.val>.6?'#6ee7b6':'#9999b8');
2108
+ lbl(c,level+'/5',st.w/2,st.h+10,st.val>.6?P.accent:P.dim);
1851
2109
  if(st.focused)fr(c,st.w,st.h,4);
1852
2110
  },
1853
2111
  };
@@ -1858,8 +2116,62 @@ export function SignalMeter() {
1858
2116
  // CONTROLS
1859
2117
  // ===========================================================
1860
2118
 
2119
+ /** Liquid Fill (U4a PROGRESS) -- a container that fills with a waving liquid to
2120
+ * the progress value, with a percentage readout. Non-interactive. */
2121
+ export function LiquidFill(o = {}) {
2122
+ const P = resolveTheme(o, { accent: '#22d3ee', accent2: '#0ea5e9', dim: '#e2e2f0' });
2123
+ const FONT = pickFont(o, "700 14px 'JetBrains Mono',monospace");
2124
+ let displayVal = 0, phase = 0, liquid = null;
2125
+ return {
2126
+ init(c, w, h) {
2127
+ // Vertical liquid gradient, built ONCE (cold): crest -> base.
2128
+ const g = c.createLinearGradient(0, 0, 0, h);
2129
+ g.addColorStop(0, P.accent);
2130
+ g.addColorStop(1, P.accent2);
2131
+ liquid = g;
2132
+ },
2133
+ tick(c, dt, now, st) {
2134
+ displayVal = lerp(displayVal, st.val, dt * 5);
2135
+ phase += dt * 3;
2136
+ const w = st.w, h = st.h, r = Math.min(12, h / 2);
2137
+
2138
+ // Container outline
2139
+ c.strokeStyle = 'rgba(255,255,255,.14)'; c.lineWidth = 2;
2140
+ roundRect(c, 1, 1, w - 2, h - 2, r); c.stroke();
2141
+
2142
+ // Liquid body, clipped to the container, with a sine surface. Const
2143
+ // gradient fill + fixed-step sampling -> zero per-frame allocation.
2144
+ c.save();
2145
+ roundRect(c, 1, 1, w - 2, h - 2, r); c.clip();
2146
+ const surface = h - displayVal * h;
2147
+ const amp = displayVal > 0.01 && displayVal < 0.99 ? 3 : 0;
2148
+ c.fillStyle = liquid || P.accent;
2149
+ c.beginPath();
2150
+ c.moveTo(0, h);
2151
+ c.lineTo(0, surface);
2152
+ for (let x = 0; x <= w; x += 6) {
2153
+ c.lineTo(x, surface + Math.sin(x * 0.15 + phase) * amp);
2154
+ }
2155
+ c.lineTo(w, h);
2156
+ c.closePath();
2157
+ c.fill();
2158
+ c.restore();
2159
+
2160
+ // Percentage readout (PCT LUT -- no per-frame string build)
2161
+ c.fillStyle = P.dim; c.font = FONT; c.textAlign = 'center'; c.textBaseline = 'middle';
2162
+ c.fillText(PCT[Math.round(displayVal * 100)], w / 2, h / 2);
2163
+ if (st.focused) fr(c, w, h, r);
2164
+ },
2165
+ };
2166
+ }
2167
+
1861
2168
  /** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
1862
- export function PillTabs() {
2169
+ export function PillTabs(o = {}) {
2170
+ const P = resolveTheme(o, { accent: '#c4b5fd', accent2: '#a78bfa', dim: '#9999b8' });
2171
+ const themed = !!(o.theme || o.colors);
2172
+ const indFill = themed ? rgbaOf(P.accent2, .12) : 'rgba(167,139,250,.12)';
2173
+ const indStroke = themed ? rgbaOf(P.accent2, .25) : 'rgba(167,139,250,.25)';
2174
+ const FONT = pickFont(o, "600 11px 'Space Grotesk',sans-serif");
1863
2175
  let indicatorX=0, indicatorW=0, selected=0;
1864
2176
  const labels=['Alpha','Beta','Gamma'];
1865
2177
  return {
@@ -1877,13 +2189,13 @@ export function PillTabs() {
1877
2189
  c.fillStyle='rgba(255,255,255,.03)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
1878
2190
 
1879
2191
  // Indicator
1880
- c.fillStyle='rgba(167,139,250,.12)';c.strokeStyle='rgba(167,139,250,.25)';c.lineWidth=1;
2192
+ c.fillStyle=indFill;c.strokeStyle=indStroke;c.lineWidth=1;
1881
2193
  rr(c,indicatorX+2,2,indicatorW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
1882
2194
 
1883
2195
  // Labels
1884
- c.font="600 11px 'Space Grotesk',sans-serif";c.textAlign='center';c.textBaseline='middle';
2196
+ c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1885
2197
  for(let i=0;i<3;i++){
1886
- c.fillStyle=i===selected?'#c4b5fd':'#9999b8';
2198
+ c.fillStyle=i===selected?P.accent:P.dim;
1887
2199
  c.fillText(labels[i],tw*i+tw/2,st.h/2);
1888
2200
  }
1889
2201
  if(st.focused)fr(c,st.w,st.h,st.h/2);
@@ -1892,7 +2204,12 @@ export function PillTabs() {
1892
2204
  }
1893
2205
 
1894
2206
  /** 7. Stepper -- +/- buttons with spring counter. */
1895
- export function Stepper() {
2207
+ export function Stepper(o = {}) {
2208
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#ff6b6b', dim: '#e2e2f0' });
2209
+ const themed = !!(o.theme || o.colors);
2210
+ const minusFlash = themed ? rgbaOf(P.accent2, .15) : 'rgba(255,100,100,.15)';
2211
+ const plusFlash = themed ? rgbaOf(P.accent, .15) : 'rgba(110,231,182,.15)';
2212
+ const FONT = pickFont(o, "700 18px 'JetBrains Mono',monospace");
1896
2213
  let count=0,displayCount=0,flashDir=0,flashTimer=0;
1897
2214
  return {
1898
2215
  onClick(x,y,st) {
@@ -1909,18 +2226,18 @@ export function Stepper() {
1909
2226
 
1910
2227
  // Minus zone
1911
2228
  const third=st.w/3;
1912
- c.fillStyle=flashDir===-1&&flashTimer>0?'rgba(255,100,100,.15)':'rgba(255,255,255,.03)';
2229
+ c.fillStyle=flashDir===-1&&flashTimer>0?minusFlash:'rgba(255,255,255,.03)';
1913
2230
  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';
2231
+ c.fillStyle=P.accent2;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
1915
2232
  c.fillText('\u2212',third/2,st.h/2);
1916
2233
 
1917
2234
  // Plus zone
1918
- c.fillStyle=flashDir===1&&flashTimer>0?'rgba(110,231,182,.15)':'rgba(255,255,255,.03)';
2235
+ c.fillStyle=flashDir===1&&flashTimer>0?plusFlash:'rgba(255,255,255,.03)';
1919
2236
  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);
2237
+ c.fillStyle=P.accent;c.fillText('+',third*2+third/2,st.h/2);
1921
2238
 
1922
2239
  // Counter
1923
- c.fillStyle='#e2e2f0';c.font="700 20px 'JetBrains Mono',monospace";
2240
+ c.fillStyle=P.dim;c.font="700 20px 'JetBrains Mono',monospace";
1924
2241
  c.fillText(Math.round(displayCount),st.w/2,st.h/2);
1925
2242
 
1926
2243
  if(st.focused)fr(c,st.w,st.h,10);
@@ -1929,9 +2246,14 @@ export function Stepper() {
1929
2246
  }
1930
2247
 
1931
2248
  /** 8. Radio Orbit -- 4 options arranged in a circle. Slider picks one. */
1932
- export function RadioOrbit() {
2249
+ export function RadioOrbit(o = {}) {
1933
2250
  let selectedGlow=new Float32Array(4);
1934
- const names=['A','B','C','D'],colors=['#ff6b6b','#fbbf24','#6ee7b6','#38bdf8'];
2251
+ const names=['A','B','C','D'];
2252
+ // `colors` is the 4-node palette; `theme` seeds it. Normalised to length 4 so
2253
+ // the body's colors[i] stays valid for any override.
2254
+ const base = o.colors ? o.colors
2255
+ : (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#ff6b6b','#fbbf24','#6ee7b6','#38bdf8']);
2256
+ const colors = base.length >= 4 ? base : [base[0], base[1 % base.length], base[2 % base.length], base[3 % base.length]];
1935
2257
  const OPTS=['OPTION A','OPTION B','OPTION C','OPTION D']; // const (was 'OPTION '+name concat)
1936
2258
  return {
1937
2259
  tick(c,dt,now,st) {
@@ -1972,9 +2294,14 @@ export function RadioOrbit() {
1972
2294
  // ===========================================================
1973
2295
 
1974
2296
  /** 9. Password Strength -- Segmented bar with color progression and label. */
1975
- export function PasswordStrength() {
2297
+ export function PasswordStrength(o = {}) {
1976
2298
  let segs=[0,0,0,0];
1977
- const labels=['WEAK','FAIR','GOOD','STRONG'],colors=['#ff6b6b','#fbbf24','#38bdf8','#6ee7b6'];
2299
+ const labels=['WEAK','FAIR','GOOD','STRONG'];
2300
+ // `colors` is the 4-step strength ramp; `theme` seeds it. Normalised to 4.
2301
+ const base = o.colors ? o.colors
2302
+ : (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#ff6b6b','#fbbf24','#38bdf8','#6ee7b6']);
2303
+ const colors = base.length >= 4 ? base : [base[0], base[1 % base.length], base[2 % base.length], base[3 % base.length]];
2304
+ const noneColor = (o.theme && o.theme.mid) || '#666';
1978
2305
  return {
1979
2306
  tick(c,dt,now,st) {
1980
2307
  const level=Math.ceil(st.val*4);
@@ -1993,14 +2320,23 @@ export function PasswordStrength() {
1993
2320
  }
1994
2321
 
1995
2322
  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');
2323
+ lbl(c,level>0?labels[idx]:'NONE',st.w/2,st.h/2+16,level>0?colors[idx]:noneColor);
1997
2324
  if(st.focused)fr(c,st.w,st.h,4);
1998
2325
  },
1999
2326
  };
2000
2327
  }
2001
2328
 
2002
2329
  /** 10. Water Level -- Animated wave surface inside a container. */
2003
- export function WaterLevel() {
2330
+ export function WaterLevel(o = {}) {
2331
+ const P = resolveTheme(o, { accent: '#38bdf8', accent2: '#6ee7b6', accent3: '#fbbf24', dim: '#e2e2f0' });
2332
+ const themed = !!(o.theme || o.colors);
2333
+ const surfHi = themed ? rgbaOf(P.accent, .3) : 'rgba(56,189,248,.3)';
2334
+ const surfMid = themed ? rgbaOf(P.accent2, .25) : 'rgba(110,231,182,.25)';
2335
+ const surfLo = themed ? rgbaOf(P.accent3, .2) : 'rgba(251,191,36,.2)';
2336
+ const deepHi = themed ? rgbaOf(P.accent, .15) : 'rgba(56,189,248,.15)';
2337
+ const deepMid = themed ? rgbaOf(P.accent2, .1) : 'rgba(110,231,182,.1)';
2338
+ const deepLo = themed ? rgbaOf(P.accent3, .08) : 'rgba(251,191,36,.08)';
2339
+ const FONT = pickFont(o, "700 12px 'JetBrains Mono',monospace");
2004
2340
  let displayVal=0, wavePhase=0;
2005
2341
  return {
2006
2342
  tick(c,dt,now,st) {
@@ -2016,7 +2352,7 @@ export function WaterLevel() {
2016
2352
  c.save();c.beginPath();rr(c,bx+1,by+1,bw-2,bh-2,5);c.clip();
2017
2353
 
2018
2354
  // Wave surface
2019
- c.fillStyle=displayVal>.7?'rgba(56,189,248,.3)':displayVal>.3?'rgba(110,231,182,.25)':'rgba(251,191,36,.2)';
2355
+ c.fillStyle=displayVal>.7?surfHi:displayVal>.3?surfMid:surfLo;
2020
2356
  c.beginPath();c.moveTo(bx,by+bh);
2021
2357
  for(let x=0;x<=bw;x++){
2022
2358
  const wave=Math.sin(wavePhase+x*0.08)*3+Math.sin(wavePhase*1.5+x*0.12)*2;
@@ -2025,12 +2361,12 @@ export function WaterLevel() {
2025
2361
  c.lineTo(bx+bw,by+bh);c.closePath();c.fill();
2026
2362
 
2027
2363
  // Deeper water
2028
- c.fillStyle=displayVal>.7?'rgba(56,189,248,.15)':displayVal>.3?'rgba(110,231,182,.1)':'rgba(251,191,36,.08)';
2364
+ c.fillStyle=displayVal>.7?deepHi:displayVal>.3?deepMid:deepLo;
2029
2365
  c.fillRect(bx,waterY+5,bw,bh);
2030
2366
 
2031
2367
  c.restore();
2032
2368
 
2033
- c.fillStyle='#e2e2f0';c.font="700 12px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2369
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2034
2370
  c.fillText(PCT[Math.round(displayVal*100)],st.w/2,st.h/2);
2035
2371
  if(st.focused)fr(c,st.w,st.h,6);
2036
2372
  },
@@ -2038,14 +2374,21 @@ export function WaterLevel() {
2038
2374
  }
2039
2375
 
2040
2376
  /** 11. Heat Map -- 5×3 grid of cells that heat up based on slider. */
2041
- export function HeatMap({seed=42}={}) {
2377
+ export function HeatMap(o = {}) {
2378
+ const {seed=42}=o;
2379
+ const P = resolveTheme(o, { accent: '#ff5014', dim: '#9999b8' });
2042
2380
  const rng=new Random(seed);
2043
2381
  const N=15,thresholds=new Float32Array(N);
2044
2382
  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.
2383
+ // Heat-color LUT (rgb quantized by heat): a const lookup + globalAlpha instead
2384
+ // of a per-cell rgb() template every frame. Default = the historical fire ramp
2385
+ // (byte-identical); a theme/colors override interpolates cool -> accent.
2386
+ const hotP = (o.theme || o.colors) ? rgbTriplet(P.accent, '255,80,20').split(',').map(Number) : null;
2047
2387
  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)+')';
2388
+ for(let i=0;i<=32;i++){
2389
+ 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)+')';}
2390
+ else HEATC[i]='rgb('+Math.round(100+i/32*155)+','+Math.round(60+i/32*20)+','+Math.round(60-i/32*40)+')';
2391
+ }
2049
2392
  return {
2050
2393
  init(){for(let i=0;i<N;i++)thresholds[i]=rng.range(0,1)},
2051
2394
  tick(c,dt,now,st) {
@@ -2063,7 +2406,7 @@ export function HeatMap({seed=42}={}) {
2063
2406
  rr(c,x,y,cw,ch,3);c.fill();
2064
2407
  c.globalAlpha=1;
2065
2408
  }
2066
- lbl(c,PCT[Math.round(st.val*100)],st.w/2,st.h+10);
2409
+ lbl(c,PCT[Math.round(st.val*100)],st.w/2,st.h+10,P.dim);
2067
2410
  if(st.focused)fr(c,st.w,st.h,3);
2068
2411
  },
2069
2412
  };
@@ -2075,7 +2418,9 @@ export function HeatMap({seed=42}={}) {
2075
2418
  // ===========================================================
2076
2419
 
2077
2420
  /** 12. Day/Night Toggle -- Sun/moon transition with star particles. */
2078
- export function DayNightToggle({seed=42}={}) {
2421
+ export function DayNightToggle(o = {}) {
2422
+ const {seed=42}=o;
2423
+ const P = resolveTheme(o, { accent: '#fbbf24', dim: '#9999b8', surface: '#d4d4e8' });
2079
2424
  const rng=new Random(seed);
2080
2425
  // Sky-color LUT (day->night rgb quantized by t): a const lookup, not an
2081
2426
  // rgb() template per frame. Stars are a fixed pool filled in init.
@@ -2108,14 +2453,14 @@ export function DayNightToggle({seed=42}={}) {
2108
2453
  const orbX=lerp(18,st.w-18,t);
2109
2454
  if(t<.5){
2110
2455
  // Sun
2111
- c.fillStyle='#fbbf24';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2456
+ c.fillStyle=P.accent;c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2112
2457
  // Rays -- const color, alpha via globalAlpha
2113
- c.strokeStyle='#fbbf24';c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
2458
+ c.strokeStyle=P.accent;c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
2114
2459
  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
2460
  c.globalAlpha=1;
2116
2461
  } else {
2117
2462
  // Moon
2118
- c.fillStyle='#d4d4e8';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2463
+ c.fillStyle=P.surface;c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
2119
2464
  // Crater shadows -- const black, alpha via globalAlpha
2120
2465
  c.fillStyle='#000000';c.globalAlpha=(t-.5)*2*.15;
2121
2466
  c.beginPath();c.arc(orbX-3,st.h/2-2,3,0,PI2);c.fill();
@@ -2123,16 +2468,20 @@ export function DayNightToggle({seed=42}={}) {
2123
2468
  c.globalAlpha=1;
2124
2469
  }
2125
2470
 
2126
- lbl(c,st.toggled?'NIGHT':'DAY',st.w/2,st.h+14,st.toggled?'#9999b8':'#fbbf24');
2471
+ lbl(c,st.toggled?'NIGHT':'DAY',st.w/2,st.h+14,st.toggled?P.dim:P.accent);
2127
2472
  if(st.focused)fr(c,st.w,st.h,st.h/2);
2128
2473
  },
2129
2474
  };
2130
2475
  }
2131
2476
 
2132
2477
  /** 13. Reaction Picker -- 5 emoji-style circles that inflate on hover region. */
2133
- export function ReactionPicker() {
2478
+ export function ReactionPicker(o = {}) {
2134
2479
  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'];
2480
+ const emojis=['\u{1F610}','\u{1F642}','\u{1F60A}','\u{1F604}','\u{1F929}'];
2481
+ // `colors` is the 5-reaction palette; `theme` seeds it. Normalised to 5.
2482
+ const base = o.colors ? o.colors
2483
+ : (o.theme ? [o.theme.light,o.theme.mid,o.theme.dark,o.theme.light,o.theme.mid] : ['#9999b8','#fbbf24','#fb923c','#f472b6','#ff6b6b']);
2484
+ const colors = base.length>=5 ? base : Array.from({length:5},(_,i)=>base[i%base.length]);
2136
2485
  const colors30=colors.map((col)=>col+'30'); // active fill (was `${color}30`)
2137
2486
  const REACT_LABELS=['MEH','OK','NICE','GREAT','LOVE']; // was a per-frame array literal
2138
2487
  const FONTS=[]; for(let i=0;i<=48;i++)FONTS[i]=i+'px sans-serif'; // emoji font by rounded size
@@ -2169,7 +2518,9 @@ export function ReactionPicker() {
2169
2518
  }
2170
2519
 
2171
2520
  /** 14. Notification Bell -- Bell icon with bounce and count badge. */
2172
- export function NotificationBell() {
2521
+ export function NotificationBell(o = {}) {
2522
+ const P = resolveTheme(o, { accent: '#fbbf24', accent2: '#ff6b6b' });
2523
+ const TXT = pickText(o, 'NOTIFY');
2173
2524
  let count=0, bellAngle=0, bellVel=0, badgeScale=0;
2174
2525
  let badgeStr='0'; // rebuilt only on click (count change), not per frame
2175
2526
  return {
@@ -2183,23 +2534,23 @@ export function NotificationBell() {
2183
2534
 
2184
2535
  // Bell body
2185
2536
  c.save();c.translate(cx,cy-4);c.rotate(bellAngle*0.3);
2186
- c.fillStyle='#fbbf24';
2537
+ c.fillStyle=P.accent;
2187
2538
  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
2539
  // Clapper
2189
- c.fillStyle='#fbbf24';c.beginPath();c.arc(0,3,3,0,PI2);c.fill();
2540
+ c.fillStyle=P.accent;c.beginPath();c.arc(0,3,3,0,PI2);c.fill();
2190
2541
  c.restore();
2191
2542
 
2192
2543
  // Badge
2193
2544
  if(count>0){
2194
2545
  const bx=cx+10,by=cy-16;
2195
2546
  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();
2547
+ c.fillStyle=P.accent2;c.beginPath();c.arc(0,0,8,0,PI2);c.fill();
2197
2548
  c.fillStyle='#fff';c.font="700 8px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2198
2549
  c.fillText(badgeStr,0,0);
2199
2550
  c.restore();
2200
2551
  }
2201
2552
 
2202
- lbl(c,'NOTIFY',cx,st.h+10);
2553
+ lbl(c,TXT,cx,st.h+10);
2203
2554
  if(st.focused)fr(c,st.w,st.h,4);
2204
2555
  },
2205
2556
  };
@@ -2211,8 +2562,10 @@ export function NotificationBell() {
2211
2562
  // ===========================================================
2212
2563
 
2213
2564
  /** 15. Typewriter Field -- Characters appear one by one with cursor blink. */
2214
- export function TypewriterField() {
2215
- const text='Hello World';
2565
+ export function TypewriterField(o = {}) {
2566
+ const P = resolveTheme(o, { accent: '#6ee7b6', dim2: '#8888aa' });
2567
+ const FONT = pickFont(o, "500 13px 'JetBrains Mono',monospace");
2568
+ const text=pickText(o, 'Hello World');
2216
2569
  let charIdx=0, timer=0, cursorBlink=0, typing=false;
2217
2570
  let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
2218
2571
  return {
@@ -2224,24 +2577,26 @@ export function TypewriterField() {
2224
2577
  c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
2225
2578
  c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
2226
2579
 
2227
- c.fillStyle='#6ee7b6';c.font="500 13px 'JetBrains Mono',monospace";c.textAlign='left';c.textBaseline='middle';
2580
+ c.fillStyle=P.accent;c.font=FONT;c.textAlign='left';c.textBaseline='middle';
2228
2581
  // Rebuild the visible substring + its width only when a char is added.
2229
2582
  if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
2230
2583
  c.fillText(display,8,st.h/2);
2231
2584
 
2232
2585
  // Cursor
2233
2586
  if(cursorBlink<1){
2234
- c.fillStyle='#6ee7b6';c.fillRect(9+dispW,st.h/2-8,1.5,16);
2587
+ c.fillStyle=P.accent;c.fillRect(9+dispW,st.h/2-8,1.5,16);
2235
2588
  }
2236
2589
 
2237
- lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?'#6ee7b6':'#8888aa');
2590
+ lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?P.accent:P.dim2);
2238
2591
  if(st.focused)fr(c,st.w,st.h,6);
2239
2592
  },
2240
2593
  };
2241
2594
  }
2242
2595
 
2243
2596
  /** 16. Sound Wave Button -- Oscillating waveform on press, static on idle. */
2244
- export function SoundWaveBtn() {
2597
+ export function SoundWaveBtn(o = {}) {
2598
+ const P = resolveTheme(o, { accent: '#f472b6', dim: '#9999b8' });
2599
+ const FONT = pickFont(o, "600 11px 'Space Grotesk',sans-serif");
2245
2600
  let intensity=0, phase=0;
2246
2601
  return {
2247
2602
  onClick(){intensity=1},
@@ -2253,7 +2608,7 @@ export function SoundWaveBtn() {
2253
2608
 
2254
2609
  // Waveform
2255
2610
  const cy=st.h/2, amp=10*intensity;
2256
- c.strokeStyle=intensity>.1?'#f472b6':'rgba(255,255,255,.08)';c.lineWidth=2;
2611
+ c.strokeStyle=intensity>.1?P.accent:'rgba(255,255,255,.08)';c.lineWidth=2;
2257
2612
  c.beginPath();
2258
2613
  for(let x=0;x<=st.w;x++){
2259
2614
  const freq=x*0.1;
@@ -2268,7 +2623,7 @@ export function SoundWaveBtn() {
2268
2623
  c.beginPath();c.moveTo(10,cy);c.lineTo(st.w-10,cy);c.stroke();
2269
2624
  }
2270
2625
 
2271
- c.fillStyle=intensity>.1?'#f472b6':'#9999b8';c.font="600 11px 'Space Grotesk',sans-serif";c.textAlign='center';c.textBaseline='middle';
2626
+ c.fillStyle=intensity>.1?P.accent:P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2272
2627
  c.fillText(intensity>.1?'\u25CF REC':'RECORD',st.w/2,st.h/2);
2273
2628
  if(st.focused)fr(c,st.w,st.h,10);
2274
2629
  },
@@ -2276,7 +2631,8 @@ export function SoundWaveBtn() {
2276
2631
  }
2277
2632
 
2278
2633
  /** 17. Upload Progress -- File icon fills from bottom as slider increases. */
2279
- export function UploadProgress() {
2634
+ export function UploadProgress(o = {}) {
2635
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#38bdf8', dim: '#9999b8' });
2280
2636
  let displayVal=0;
2281
2637
  return {
2282
2638
  tick(c,dt,now,st) {
@@ -2292,8 +2648,8 @@ export function UploadProgress() {
2292
2648
 
2293
2649
  // Fill
2294
2650
  const fillH=fh*displayVal;
2295
- const col=displayVal>=1?'#6ee7b6':'#38bdf8';
2296
- c.fillStyle=displayVal>=1?'#6ee7b630':'#38bdf830'; // was `${col}30`
2651
+ const col=displayVal>=1?P.accent:P.accent2;
2652
+ c.fillStyle=col+'30'; // was `${col}30`
2297
2653
  c.save();c.beginPath();c.rect(fx+1,fy+fh-fillH,fw-1,fillH);c.clip();
2298
2654
  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
2655
  c.restore();
@@ -2308,7 +2664,7 @@ export function UploadProgress() {
2308
2664
  }
2309
2665
  c.lineCap='butt';
2310
2666
 
2311
- lbl(c,displayVal>=.99?'DONE':PCT[Math.round(displayVal*100)],cx,st.h+10,displayVal>=.99?'#6ee7b6':'#9999b8');
2667
+ lbl(c,displayVal>=.99?'DONE':PCT[Math.round(displayVal*100)],cx,st.h+10,displayVal>=.99?P.accent:P.dim);
2312
2668
  if(st.focused)fr(c,st.w,st.h,4);
2313
2669
  },
2314
2670
  };
@@ -2320,7 +2676,11 @@ export function UploadProgress() {
2320
2676
  // ===========================================================
2321
2677
 
2322
2678
  /** 18. Scratch Reveal -- Drag to erase a mask and reveal the prize beneath. */
2323
- export function ScratchReveal({seed=42}={}) {
2679
+ export function ScratchReveal(o = {}) {
2680
+ const {seed=42}=o;
2681
+ const P = resolveTheme(o, { accent: '#6ee7b6', dim: '#9999b8' });
2682
+ const prizeBg = (o.theme || o.colors) ? rgbaOf(P.accent, .08) : 'rgba(110,231,182,.08)';
2683
+ const FONT = pickFont(o, "700 14px 'JetBrains Mono',monospace");
2324
2684
  const rng=new Random(seed);
2325
2685
  // Fixed scratch pool (holes persist). Once all SCR slots are used the scratch
2326
2686
  // stops adding holes -- existing marks are never overwritten, so earlier
@@ -2345,10 +2705,10 @@ export function ScratchReveal({seed=42}={}) {
2345
2705
  tick(c,dt,now,st) {
2346
2706
  lastW=st.w;
2347
2707
  // Prize background
2348
- c.fillStyle='rgba(110,231,182,.08)';rr(c,0,0,st.w,st.h,8);c.fill();
2708
+ c.fillStyle=prizeBg;rr(c,0,0,st.w,st.h,8);c.fill();
2349
2709
 
2350
2710
  // 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';
2711
+ c.fillStyle=P.accent;c.globalAlpha=.1+revealed*.6;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2352
2712
  c.fillText(revealed>.6?'\u{1F389} WINNER!':'? ? ?',st.w/2,st.h/2);
2353
2713
  c.globalAlpha=1;
2354
2714
 
@@ -2371,14 +2731,16 @@ export function ScratchReveal({seed=42}={}) {
2371
2731
  c.fillRect(shimX-2,0,4,st.h);
2372
2732
  }
2373
2733
 
2374
- lbl(c,revealed>.6?'REVEALED!':'DRAG TO SCRATCH',st.w/2,st.h+10,revealed>.6?'#6ee7b6':'#9999b8');
2734
+ lbl(c,revealed>.6?'REVEALED!':'DRAG TO SCRATCH',st.w/2,st.h+10,revealed>.6?P.accent:P.dim);
2375
2735
  if(st.focused)fr(c,st.w,st.h,8);
2376
2736
  },
2377
2737
  };
2378
2738
  }
2379
2739
 
2380
2740
  /** 19. Timer Countdown -- Circular countdown timer. Toggle starts/stops. */
2381
- export function TimerCountdown() {
2741
+ export function TimerCountdown(o = {}) {
2742
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#fbbf24', accent3: '#ff6b6b', dim: '#e2e2f0', dim2: '#8888aa' });
2743
+ const FONT = pickFont(o, "700 16px 'JetBrains Mono',monospace");
2382
2744
  let timeLeft=10, running=false, flashAlpha=0;
2383
2745
  let timeStr='10.0s', lastTenths=-1; // rebuilt at ~10 Hz, not per frame
2384
2746
  return {
@@ -2398,24 +2760,25 @@ export function TimerCountdown() {
2398
2760
 
2399
2761
  // Progress arc
2400
2762
  const progress=timeLeft/10;
2401
- const col=timeLeft>3?'#6ee7b6':timeLeft>1?'#fbbf24':'#ff6b6b';
2763
+ const col=timeLeft>3?P.accent:timeLeft>1?P.accent2:P.accent3;
2402
2764
  c.strokeStyle=col;c.lineWidth=4;
2403
2765
  c.beginPath();c.arc(cx,cy,R,-Math.PI/2,-Math.PI/2+progress*PI2,false);c.stroke();
2404
2766
 
2405
2767
  // Time text
2406
2768
  const tenths=Math.round(timeLeft*10);
2407
2769
  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';
2770
+ c.fillStyle=P.dim;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2409
2771
  c.fillText(timeStr,cx,cy);
2410
2772
 
2411
- lbl(c,running?(timeLeft>0?'RUNNING':'TIME UP!'):'TOGGLE TO START',cx,st.h+10,running?col:'#8888aa');
2773
+ lbl(c,running?(timeLeft>0?'RUNNING':'TIME UP!'):'TOGGLE TO START',cx,st.h+10,running?col:P.dim2);
2412
2774
  if(st.focused)fr(c,st.w,st.h,R);
2413
2775
  },
2414
2776
  };
2415
2777
  }
2416
2778
 
2417
2779
  /** 20. Pull Refresh -- Drag down to charge, release to spin. */
2418
- export function PullRefresh() {
2780
+ export function PullRefresh(o = {}) {
2781
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#38bdf8', dim: '#9999b8' });
2419
2782
  let pullAmt=0, spinning=false, spinAngle=0, spinTimer=0;
2420
2783
  return {
2421
2784
  onDrag(val) { if(!spinning) pullAmt=val; },
@@ -2435,25 +2798,25 @@ export function PullRefresh() {
2435
2798
  // Spinner arcs
2436
2799
  for(let i=0;i<3;i++){
2437
2800
  const a=spinAngle+i*PI2/3;
2438
- c.strokeStyle='#38bdf8';c.globalAlpha=.3+i*.2;c.lineWidth=3;
2801
+ c.strokeStyle=P.accent2;c.globalAlpha=.3+i*.2;c.lineWidth=3;
2439
2802
  c.beginPath();c.arc(cx,cy,16,a,a+.8);c.stroke();
2440
2803
  }
2441
2804
  c.globalAlpha=1;
2442
- lbl(c,'LOADING...',cx,st.h+10,'#38bdf8');
2805
+ lbl(c,'LOADING...',cx,st.h+10,P.accent2);
2443
2806
  } else {
2444
2807
  // Arrow that stretches with pull
2445
2808
  const stretch=pullAmt*14;
2446
- c.strokeStyle=pullAmt>.6?'#6ee7b6':'rgba(255,255,255,.15)';c.lineWidth=2;c.lineCap='round';
2809
+ c.strokeStyle=pullAmt>.6?P.accent:'rgba(255,255,255,.15)';c.lineWidth=2;c.lineCap='round';
2447
2810
  c.beginPath();c.moveTo(cx,cy-8-stretch);c.lineTo(cx,cy+4);c.stroke();
2448
2811
  c.beginPath();c.moveTo(cx-5,cy+0);c.lineTo(cx,cy+4);c.lineTo(cx+5,cy+0);c.stroke();
2449
2812
  c.lineCap='butt';
2450
2813
 
2451
2814
  // Fill ring
2452
2815
  if(pullAmt>0){
2453
- c.strokeStyle=pullAmt>.6?'#6ee7b6':'rgba(255,255,255,.1)';c.lineWidth=2;
2816
+ c.strokeStyle=pullAmt>.6?P.accent:'rgba(255,255,255,.1)';c.lineWidth=2;
2454
2817
  c.beginPath();c.arc(cx,cy,20,-Math.PI/2,-Math.PI/2+pullAmt*PI2);c.stroke();
2455
2818
  }
2456
- lbl(c,pullAmt>.6?'RELEASE!':'DRAG SLIDER',cx,st.h+10,pullAmt>.6?'#6ee7b6':'#9999b8');
2819
+ lbl(c,pullAmt>.6?'RELEASE!':'DRAG SLIDER',cx,st.h+10,pullAmt>.6?P.accent:P.dim);
2457
2820
  }
2458
2821
  if(st.focused)fr(c,st.w,st.h,24);
2459
2822
  },
@@ -2491,9 +2854,17 @@ export const UIFXRecipes3 = {
2491
2854
  ScratchReveal, TimerCountdown, PullRefresh,
2492
2855
  };
2493
2856
 
2857
+ // U4a additions -- new native element types (CHECKBOX, PROGRESS). Kept out of the
2858
+ // Vol.1-3 historical snapshots above so those stay accurate; all recipes remain
2859
+ // reachable via RECIPES / RECIPE_META and their named exports regardless.
2860
+ export const UIFXRecipes4 = {
2861
+ TickDraw, IndeterminateScan,
2862
+ LiquidFill,
2863
+ };
2864
+
2494
2865
 
2495
2866
  // ===========================================================
2496
- // DEFAULT EXPORT -- combined all-50 namespace
2867
+ // DEFAULT EXPORT -- combined all-53 namespace
2497
2868
  // ===========================================================
2498
2869
 
2499
2870
  export default {
@@ -2524,6 +2895,8 @@ export default {
2524
2895
  HelixLoader,
2525
2896
  RippleCheck,
2526
2897
  MorphCheck,
2898
+ TickDraw,
2899
+ IndeterminateScan,
2527
2900
  FlameCounter,
2528
2901
  GlitchCounter,
2529
2902
  BubbleRating,
@@ -2532,6 +2905,7 @@ export default {
2532
2905
  RingProgress,
2533
2906
  BatteryGauge,
2534
2907
  SignalMeter,
2908
+ LiquidFill,
2535
2909
  PillTabs,
2536
2910
  Stepper,
2537
2911
  RadioOrbit,
@@ -2587,6 +2961,8 @@ export const RECIPES = Object.assign(Object.create(null), {
2587
2961
  helixLoader: HelixLoader,
2588
2962
  rippleCheck: RippleCheck,
2589
2963
  morphCheck: MorphCheck,
2964
+ tickDraw: TickDraw,
2965
+ indeterminateScan: IndeterminateScan,
2590
2966
  flameCounter: FlameCounter,
2591
2967
  glitchCounter: GlitchCounter,
2592
2968
  bubbleRating: BubbleRating,
@@ -2595,6 +2971,7 @@ export const RECIPES = Object.assign(Object.create(null), {
2595
2971
  ringProgress: RingProgress,
2596
2972
  batteryGauge: BatteryGauge,
2597
2973
  signalMeter: SignalMeter,
2974
+ liquidFill: LiquidFill,
2598
2975
  pillTabs: PillTabs,
2599
2976
  stepper: Stepper,
2600
2977
  radioOrbit: RadioOrbit,
@@ -2619,65 +2996,73 @@ export const RECIPES = Object.assign(Object.create(null), {
2619
2996
  *
2620
2997
  * type 'toggle' | 'button' | 'slider' -- the native element it mounts on
2621
2998
  * 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)
2999
+ * themeable accepts { colors, theme } (true for all as of U3b/1.4.0)
3000
+ * motionSafe inherently-calm under prefers-reduced-motion (false for all -- U5)
2624
3001
  */
2625
3002
  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 },
3003
+ { id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3004
+ { id: 'liquidToggle', name: 'Liquid Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3005
+ { id: 'neonPulseToggle', name: 'Neon Pulse Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3006
+ { id: 'magneticButton', name: 'Magnetic Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3007
+ { id: 'shatterButton', name: 'Shatter Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3008
+ { id: 'confettiButton', name: 'Confetti Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3009
+ { id: 'glitchButton', name: 'Glitch Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3010
+ { id: 'sparkSlider', name: 'Spark Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
3011
+ { id: 'cosmicSlider', name: 'Cosmic Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
3012
+ { id: 'laserSlider', name: 'Laser Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
3013
+ { id: 'pendulumToggle', name: 'Pendulum Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3014
+ { id: 'circuitToggle', name: 'Circuit Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3015
+ { id: 'lightningToggle', name: 'Lightning Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3016
+ { id: 'dnaToggle', name: 'Dna Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3017
+ { id: 'heartbeatButton', name: 'Heartbeat Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3018
+ { id: 'breathingButton', name: 'Breathing Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3019
+ { id: 'inkSplashButton', name: 'Ink Splash Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3020
+ { id: 'pixelDissolveButton', name: 'Pixel Dissolve Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3021
+ { id: 'fireworkButton', name: 'Firework Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
3022
+ { id: 'auroraSlider', name: 'Aurora Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
3023
+ { id: 'waveSlider', name: 'Wave Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
3024
+ { id: 'elasticBandSlider', name: 'Elastic Band Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
3025
+ { id: 'gravitySlider', name: 'Gravity Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
3026
+ { id: 'orbitLoader', name: 'Orbit Loader', type: 'toggle', family: 'Loaders', themeable: true, motionSafe: false },
3027
+ { id: 'helixLoader', name: 'Helix Loader', type: 'toggle', family: 'Loaders', themeable: true, motionSafe: false },
3028
+ { id: 'rippleCheck', name: 'Ripple Check', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3029
+ { id: 'morphCheck', name: 'Morph Check', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3030
+ { id: 'tickDraw', name: 'Tick Draw', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3031
+ { id: 'indeterminateScan', name: 'Indeterminate Scan', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3032
+ { id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
3033
+ { id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
3034
+ { id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: true, motionSafe: false },
3035
+ { id: 'volumeKnob', name: 'Volume Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3036
+ { id: 'compassKnob', name: 'Compass Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3037
+ { id: 'ringProgress', name: 'Ring Progress', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3038
+ { id: 'batteryGauge', name: 'Battery Gauge', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3039
+ { id: 'signalMeter', name: 'Signal Meter', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3040
+ { id: 'liquidFill', name: 'Liquid Fill', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3041
+ { id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3042
+ { id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3043
+ { id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
3044
+ { id: 'passwordStrength', name: 'Password Strength', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
3045
+ { id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
3046
+ { id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
3047
+ { id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: true, motionSafe: false },
3048
+ { id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
3049
+ { id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
3050
+ { id: 'typewriterField', name: 'Typewriter Field', type: 'toggle', family: 'Feedback', themeable: true, motionSafe: false },
3051
+ { id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: true, motionSafe: false },
3052
+ { id: 'uploadProgress', name: 'Upload Progress', type: 'progress', family: 'Feedback', themeable: true, motionSafe: false },
3053
+ { id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
3054
+ { id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: true, motionSafe: false },
3055
+ { id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
2676
3056
  ];
2677
3057
 
2678
3058
  /** Names of every built-in recipe (the keys of RECIPES at load time). */
2679
3059
  export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
2680
3060
 
3061
+ // The valid recipe/mount types, taken from the controller's UIType so the
3062
+ // registry's fail-closed check and the controller's mount guard are one source
3063
+ // of truth (they cannot drift as U4 adds types). Built once at load (cold).
3064
+ const VALID_META_TYPES = new Set(Object.values(UIType));
3065
+
2681
3066
  /**
2682
3067
  * Register a custom recipe, or override a built-in. Instantly usable via
2683
3068
  * RECIPES[id] and reflected in RECIPE_META so existing pickers keep working.
@@ -2702,9 +3087,11 @@ export function registerRecipe(id, factory, meta) {
2702
3087
  const type = (meta && meta.type) || (prev && prev.type) || undefined;
2703
3088
  // A recipe's type selects its native element; a typeless recipe cannot be
2704
3089
  // mounted. Reject it at registration (fail closed) -- checked BEFORE any
2705
- // mutation, so a rejected call leaves RECIPES/RECIPE_META untouched.
2706
- if (type !== 'toggle' && type !== 'button' && type !== 'slider') {
2707
- throw new TypeError('registerRecipe: type must be "toggle", "button", or "slider"');
3090
+ // mutation, so a rejected call leaves RECIPES/RECIPE_META untouched. The
3091
+ // valid set is UIType (VALID_META_TYPES), so registry + controller never
3092
+ // disagree about what a type is.
3093
+ if (!VALID_META_TYPES.has(type)) {
3094
+ throw new TypeError('registerRecipe: type must be one of "button", "toggle", "slider", "checkbox", "progress", "knob"');
2708
3095
  }
2709
3096
 
2710
3097
  RECIPES[id] = factory;