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