@zakkster/lite-ui-fx 1.4.0 → 1.6.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 +95 -0
- package/README.md +64 -12
- package/UIFX-RECIPE-GUIDE.md +59 -3
- package/UIFXController.d.ts +86 -1
- package/UIFXController.js +513 -17
- package/UIFXRecipes.d.ts +42 -4
- package/UIFXRecipes.js +366 -46
- package/llms.txt +54 -13
- package/package.json +1 -1
package/UIFXRecipes.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-ui-fx -- Recipe Collection (all
|
|
2
|
+
* @zakkster/lite-ui-fx -- Recipe Collection (all 56)
|
|
3
3
|
*
|
|
4
4
|
* The three recipe volumes consolidated into one shipped, typed, versioned
|
|
5
|
-
* module, exposed as the ./recipes subpath export
|
|
6
|
-
* decisions/0001-recipes-position.md.
|
|
5
|
+
* module, exposed as the ./recipes subpath export, plus the U4a additions for
|
|
6
|
+
* the new native element types. See decisions/0001-recipes-position.md.
|
|
7
7
|
*
|
|
8
8
|
* Vol.1 (10): SwarmToggle, LiquidToggle, NeonPulseToggle, MagneticButton,
|
|
9
9
|
* ShatterButton, ConfettiButton, GlitchButton, SparkSlider,
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* HeatMap, DayNightToggle, ReactionPicker, NotificationBell,
|
|
19
19
|
* TypewriterField, SoundWaveBtn, UploadProgress, ScratchReveal,
|
|
20
20
|
* TimerCountdown, PullRefresh
|
|
21
|
+
* U4a (3): TickDraw, IndeterminateScan (CHECKBOX), LiquidFill (PROGRESS)
|
|
22
|
+
* U4b (3): FocusHalo, ErrorShake, SuccessBloom (DECORATE) -- generic form
|
|
23
|
+
* feedback; PasswordStrength + TypewriterField re-homed to DECORATE
|
|
24
|
+
* (canvas AROUND a live input, driven by state.text; see 0004)
|
|
21
25
|
*
|
|
22
26
|
* Registry: RECIPES (null-prototype), RECIPE_META (live), RECIPE_NAMES,
|
|
23
27
|
* registerRecipe(id, factory, meta?), mountRecipe(container, id, options?).
|
|
@@ -31,7 +35,7 @@
|
|
|
31
35
|
|
|
32
36
|
import { lerp, clamp, easeOut, easeIn, easeInOut } from '@zakkster/lite-lerp';
|
|
33
37
|
import { Random } from '@zakkster/lite-random';
|
|
34
|
-
import { mountUIFX, UIType } from './UIFXController.js';
|
|
38
|
+
import { mountUIFX, decorateUIFX, UIType } from './UIFXController.js';
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
// ---------------------------------------------------------
|
|
@@ -1588,6 +1592,84 @@ export function MorphCheck(o = {}) {
|
|
|
1588
1592
|
};
|
|
1589
1593
|
}
|
|
1590
1594
|
|
|
1595
|
+
/** Tick Draw (U4a CHECKBOX) -- the checkmark strokes itself on; a centred dash
|
|
1596
|
+
* when indeterminate; empty when off. Reads st.toggled + st.indeterminate. */
|
|
1597
|
+
export function TickDraw(o = {}) {
|
|
1598
|
+
const P = resolveTheme(o, { accent: '#34d399', surface: '#0a0a12', dim2: '#8888aa' });
|
|
1599
|
+
const boxOff = 'rgba(255,255,255,.06)';
|
|
1600
|
+
const FONT = pickFont(o, "500 9px 'JetBrains Mono',monospace");
|
|
1601
|
+
let drawT = 0; // 0..1 stroke progress of the check
|
|
1602
|
+
return {
|
|
1603
|
+
tick(ctx, dt, now, st) {
|
|
1604
|
+
// Indeterminate suppresses the check; a full check draws only when on.
|
|
1605
|
+
drawT = lerp(drawT, st.indeterminate ? 0 : (st.toggled ? 1 : 0), dt * 12);
|
|
1606
|
+
const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2, r = 6;
|
|
1607
|
+
|
|
1608
|
+
ctx.fillStyle = (st.toggled || st.indeterminate) ? P.accent : boxOff;
|
|
1609
|
+
roundRect(ctx, 0, 0, sz, sz, r); ctx.fill();
|
|
1610
|
+
|
|
1611
|
+
ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
|
|
1612
|
+
if (st.indeterminate) {
|
|
1613
|
+
// Standard indeterminate glyph: one centred dash.
|
|
1614
|
+
ctx.beginPath(); ctx.moveTo(sz * 0.28, cy); ctx.lineTo(sz * 0.72, cy); ctx.stroke();
|
|
1615
|
+
} else if (drawT > 0.01) {
|
|
1616
|
+
const p1 = clamp(drawT * 2, 0, 1), p2 = clamp(drawT * 2 - 1, 0, 1);
|
|
1617
|
+
ctx.beginPath();
|
|
1618
|
+
ctx.moveTo(cx - 7, cy);
|
|
1619
|
+
ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
|
|
1620
|
+
if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
|
|
1621
|
+
ctx.stroke();
|
|
1622
|
+
}
|
|
1623
|
+
ctx.lineCap = 'butt';
|
|
1624
|
+
|
|
1625
|
+
label(ctx, st.indeterminate ? '\u2212' : (st.toggled ? '\u2713' : '\u25cb'),
|
|
1626
|
+
cx, sz + 12, (st.toggled || st.indeterminate) ? P.accent : P.dim2, FONT);
|
|
1627
|
+
if (st.focused) focusRing(ctx, sz, sz, r);
|
|
1628
|
+
},
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/** Indeterminate Scan (U4a CHECKBOX) -- sweeps a scan line while indeterminate,
|
|
1633
|
+
* settles to a stroked check when toggled, empty when off. */
|
|
1634
|
+
export function IndeterminateScan(o = {}) {
|
|
1635
|
+
const P = resolveTheme(o, { accent: '#60a5fa', surface: '#0a0a12', dim2: '#8888aa' });
|
|
1636
|
+
const FONT = pickFont(o, "500 9px 'JetBrains Mono',monospace");
|
|
1637
|
+
let scan = 0; // 0..1 sweep phase
|
|
1638
|
+
let checkT = 0; // 0..1 check reveal
|
|
1639
|
+
return {
|
|
1640
|
+
tick(ctx, dt, now, st) {
|
|
1641
|
+
checkT = lerp(checkT, (!st.indeterminate && st.toggled) ? 1 : 0, dt * 12);
|
|
1642
|
+
const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2, r = 6;
|
|
1643
|
+
|
|
1644
|
+
ctx.fillStyle = (st.toggled && !st.indeterminate) ? P.accent : 'rgba(255,255,255,.06)';
|
|
1645
|
+
roundRect(ctx, 0, 0, sz, sz, r); ctx.fill();
|
|
1646
|
+
|
|
1647
|
+
if (st.indeterminate) {
|
|
1648
|
+
// Scan line sweeping down, fading at the sweep ends (globalAlpha,
|
|
1649
|
+
// not a per-frame colour string).
|
|
1650
|
+
scan += dt * 1.6; if (scan > 1) scan -= 1;
|
|
1651
|
+
const y = 4 + scan * (sz - 8);
|
|
1652
|
+
ctx.strokeStyle = P.accent; ctx.lineWidth = 2; ctx.lineCap = 'round';
|
|
1653
|
+
ctx.globalAlpha = Math.sin(scan * Math.PI);
|
|
1654
|
+
ctx.beginPath(); ctx.moveTo(4, y); ctx.lineTo(sz - 4, y); ctx.stroke();
|
|
1655
|
+
ctx.globalAlpha = 1; ctx.lineCap = 'butt';
|
|
1656
|
+
} else if (checkT > 0.01) {
|
|
1657
|
+
const p1 = clamp(checkT * 2, 0, 1), p2 = clamp(checkT * 2 - 1, 0, 1);
|
|
1658
|
+
ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
|
|
1659
|
+
ctx.beginPath();
|
|
1660
|
+
ctx.moveTo(cx - 7, cy);
|
|
1661
|
+
ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
|
|
1662
|
+
if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
|
|
1663
|
+
ctx.stroke(); ctx.lineCap = 'butt';
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
label(ctx, st.indeterminate ? '\u2212' : (st.toggled ? '\u2713' : '\u25cb'),
|
|
1667
|
+
cx, sz + 12, (st.toggled || st.indeterminate) ? P.accent : P.dim2, FONT);
|
|
1668
|
+
if (st.focused) focusRing(ctx, sz, sz, r);
|
|
1669
|
+
},
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1591
1673
|
|
|
1592
1674
|
// ===========================================================
|
|
1593
1675
|
// COUNTERS (use slider val as input)
|
|
@@ -2037,6 +2119,55 @@ export function SignalMeter(o = {}) {
|
|
|
2037
2119
|
// CONTROLS
|
|
2038
2120
|
// ===========================================================
|
|
2039
2121
|
|
|
2122
|
+
/** Liquid Fill (U4a PROGRESS) -- a container that fills with a waving liquid to
|
|
2123
|
+
* the progress value, with a percentage readout. Non-interactive. */
|
|
2124
|
+
export function LiquidFill(o = {}) {
|
|
2125
|
+
const P = resolveTheme(o, { accent: '#22d3ee', accent2: '#0ea5e9', dim: '#e2e2f0' });
|
|
2126
|
+
const FONT = pickFont(o, "700 14px 'JetBrains Mono',monospace");
|
|
2127
|
+
let displayVal = 0, phase = 0, liquid = null;
|
|
2128
|
+
return {
|
|
2129
|
+
init(c, w, h) {
|
|
2130
|
+
// Vertical liquid gradient, built ONCE (cold): crest -> base.
|
|
2131
|
+
const g = c.createLinearGradient(0, 0, 0, h);
|
|
2132
|
+
g.addColorStop(0, P.accent);
|
|
2133
|
+
g.addColorStop(1, P.accent2);
|
|
2134
|
+
liquid = g;
|
|
2135
|
+
},
|
|
2136
|
+
tick(c, dt, now, st) {
|
|
2137
|
+
displayVal = lerp(displayVal, st.val, dt * 5);
|
|
2138
|
+
phase += dt * 3;
|
|
2139
|
+
const w = st.w, h = st.h, r = Math.min(12, h / 2);
|
|
2140
|
+
|
|
2141
|
+
// Container outline
|
|
2142
|
+
c.strokeStyle = 'rgba(255,255,255,.14)'; c.lineWidth = 2;
|
|
2143
|
+
roundRect(c, 1, 1, w - 2, h - 2, r); c.stroke();
|
|
2144
|
+
|
|
2145
|
+
// Liquid body, clipped to the container, with a sine surface. Const
|
|
2146
|
+
// gradient fill + fixed-step sampling -> zero per-frame allocation.
|
|
2147
|
+
c.save();
|
|
2148
|
+
roundRect(c, 1, 1, w - 2, h - 2, r); c.clip();
|
|
2149
|
+
const surface = h - displayVal * h;
|
|
2150
|
+
const amp = displayVal > 0.01 && displayVal < 0.99 ? 3 : 0;
|
|
2151
|
+
c.fillStyle = liquid || P.accent;
|
|
2152
|
+
c.beginPath();
|
|
2153
|
+
c.moveTo(0, h);
|
|
2154
|
+
c.lineTo(0, surface);
|
|
2155
|
+
for (let x = 0; x <= w; x += 6) {
|
|
2156
|
+
c.lineTo(x, surface + Math.sin(x * 0.15 + phase) * amp);
|
|
2157
|
+
}
|
|
2158
|
+
c.lineTo(w, h);
|
|
2159
|
+
c.closePath();
|
|
2160
|
+
c.fill();
|
|
2161
|
+
c.restore();
|
|
2162
|
+
|
|
2163
|
+
// Percentage readout (PCT LUT -- no per-frame string build)
|
|
2164
|
+
c.fillStyle = P.dim; c.font = FONT; c.textAlign = 'center'; c.textBaseline = 'middle';
|
|
2165
|
+
c.fillText(PCT[Math.round(displayVal * 100)], w / 2, h / 2);
|
|
2166
|
+
if (st.focused) fr(c, w, h, r);
|
|
2167
|
+
},
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2040
2171
|
/** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
|
|
2041
2172
|
export function PillTabs(o = {}) {
|
|
2042
2173
|
const P = resolveTheme(o, { accent: '#c4b5fd', accent2: '#a78bfa', dim: '#9999b8' });
|
|
@@ -2166,6 +2297,28 @@ export function RadioOrbit(o = {}) {
|
|
|
2166
2297
|
// ===========================================================
|
|
2167
2298
|
|
|
2168
2299
|
/** 9. Password Strength -- Segmented bar with color progression and label. */
|
|
2300
|
+
/** Password strength 0..1 from a string, zero-alloc (charCodeAt scan, no
|
|
2301
|
+
* allocating string ops). Length (up to ~12 chars) is 60%, character-class
|
|
2302
|
+
* diversity (lower/upper/digit/symbol) 40%. COLD -- called only when the
|
|
2303
|
+
* decorated field's text changes. */
|
|
2304
|
+
function pwStrength(s) {
|
|
2305
|
+
const n = s.length;
|
|
2306
|
+
if (n === 0) return 0;
|
|
2307
|
+
let lo = 0, up = 0, di = 0, sy = 0;
|
|
2308
|
+
for (let i = 0; i < n; i++) {
|
|
2309
|
+
const c = s.charCodeAt(i);
|
|
2310
|
+
if (c >= 97 && c <= 122) lo = 1;
|
|
2311
|
+
else if (c >= 65 && c <= 90) up = 1;
|
|
2312
|
+
else if (c >= 48 && c <= 57) di = 1;
|
|
2313
|
+
else sy = 1;
|
|
2314
|
+
}
|
|
2315
|
+
const v = Math.min(n / 12, 1) * 0.6 + ((lo + up + di + sy) / 4) * 0.4;
|
|
2316
|
+
return v > 1 ? 1 : v;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/** Password Strength (U4b DECORATE, re-home) -- four strength segments driven by
|
|
2320
|
+
* the LIVE host input's value (state.text), not a faked slider. Strength is
|
|
2321
|
+
* recomputed only when the text changes (cold); the tick is zero-alloc. */
|
|
2169
2322
|
export function PasswordStrength(o = {}) {
|
|
2170
2323
|
let segs=[0,0,0,0];
|
|
2171
2324
|
const labels=['WEAK','FAIR','GOOD','STRONG'];
|
|
@@ -2174,9 +2327,14 @@ export function PasswordStrength(o = {}) {
|
|
|
2174
2327
|
: (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#ff6b6b','#fbbf24','#38bdf8','#6ee7b6']);
|
|
2175
2328
|
const colors = base.length >= 4 ? base : [base[0], base[1 % base.length], base[2 % base.length], base[3 % base.length]];
|
|
2176
2329
|
const noneColor = (o.theme && o.theme.mid) || '#666';
|
|
2330
|
+
let lastText = null, strength = 0; // strength recomputed only on text change
|
|
2177
2331
|
return {
|
|
2178
2332
|
tick(c,dt,now,st) {
|
|
2179
|
-
|
|
2333
|
+
// state.text is the live host value; rescan only on change (cold). A
|
|
2334
|
+
// bare decoration over an empty field reads '' -> strength 0.
|
|
2335
|
+
const t = st.text || '';
|
|
2336
|
+
if (t !== lastText) { lastText = t; strength = pwStrength(t); }
|
|
2337
|
+
const level=Math.ceil(strength*4);
|
|
2180
2338
|
for(let i=0;i<4;i++) segs[i]=lerp(segs[i],i<level?1:0,dt*10);
|
|
2181
2339
|
|
|
2182
2340
|
const segW=(st.w-12)/4,segH=8;
|
|
@@ -2434,33 +2592,43 @@ export function NotificationBell(o = {}) {
|
|
|
2434
2592
|
// ===========================================================
|
|
2435
2593
|
|
|
2436
2594
|
/** 15. Typewriter Field -- Characters appear one by one with cursor blink. */
|
|
2595
|
+
/** Typewriter Field (U4b DECORATE, re-home) -- an animated underline that grows
|
|
2596
|
+
* with the LIVE host input's text and a caret that flares on each new character.
|
|
2597
|
+
* Draws NO text (the real input shows its own; a decoration never re-renders the
|
|
2598
|
+
* host content) and reads only state.text's length -- zero-alloc, no measureText. */
|
|
2437
2599
|
export function TypewriterField(o = {}) {
|
|
2438
2600
|
const P = resolveTheme(o, { accent: '#6ee7b6', dim2: '#8888aa' });
|
|
2439
|
-
const
|
|
2440
|
-
const
|
|
2441
|
-
let
|
|
2442
|
-
let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
|
|
2601
|
+
const themed = !!(o.theme || o.colors);
|
|
2602
|
+
const glow = themed ? rgbaOf(P.accent, .5) : 'rgba(110,231,182,.5)';
|
|
2603
|
+
let lastLen=0, fill=0, spark=0, blink=0;
|
|
2443
2604
|
return {
|
|
2444
|
-
onToggle(checked){typing=checked;if(checked){charIdx=0;timer=0}},
|
|
2445
2605
|
tick(c,dt,now,st) {
|
|
2446
|
-
|
|
2447
|
-
if(
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
//
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2606
|
+
const len=(st.text || '').length;
|
|
2607
|
+
if(len>lastLen) spark=1; // a new char landed -> caret pulse
|
|
2608
|
+
lastLen=len;
|
|
2609
|
+
blink=(blink+dt*3)%2;
|
|
2610
|
+
spark=spark>0?spark-dt*3:0;
|
|
2611
|
+
|
|
2612
|
+
// Underline grows toward a fraction of the width set by text length
|
|
2613
|
+
// (capped at ~24 chars = full width). No measureText -> zero-alloc.
|
|
2614
|
+
const target=len===0?0:Math.min(len/24,1);
|
|
2615
|
+
fill=lerp(fill,target,dt*8);
|
|
2616
|
+
const y=st.h-3, x0=2, x1=2+(st.w-4)*fill;
|
|
2617
|
+
|
|
2618
|
+
c.strokeStyle='rgba(255,255,255,.08)';c.lineWidth=2;
|
|
2619
|
+
c.beginPath();c.moveTo(x0,y);c.lineTo(st.w-2,y);c.stroke();
|
|
2620
|
+
c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2621
|
+
c.beginPath();c.moveTo(x0,y);c.lineTo(x1,y);c.stroke();
|
|
2622
|
+
|
|
2623
|
+
// Caret: a glow that flares on each keystroke, blinks when idle+focused.
|
|
2624
|
+
if(spark>0.01){
|
|
2625
|
+
c.globalAlpha=spark;c.fillStyle=glow;
|
|
2626
|
+
c.beginPath();c.arc(x1,y,4+spark*3,0,PI2);c.fill();
|
|
2627
|
+
c.globalAlpha=1;
|
|
2628
|
+
}
|
|
2629
|
+
if(st.focused&&blink<1){
|
|
2630
|
+
c.fillStyle=P.accent;c.fillRect(x1,y-9,1.5,12);
|
|
2460
2631
|
}
|
|
2461
|
-
|
|
2462
|
-
lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?P.accent:P.dim2);
|
|
2463
|
-
if(st.focused)fr(c,st.w,st.h,6);
|
|
2464
2632
|
},
|
|
2465
2633
|
};
|
|
2466
2634
|
}
|
|
@@ -2726,9 +2894,118 @@ export const UIFXRecipes3 = {
|
|
|
2726
2894
|
ScratchReveal, TimerCountdown, PullRefresh,
|
|
2727
2895
|
};
|
|
2728
2896
|
|
|
2897
|
+
// ===========================================================
|
|
2898
|
+
// DECORATIONS (U4b) -- canvas AROUND a live element (decorateUIFX). Generic form
|
|
2899
|
+
// feedback reading state.focused / state.valid / state.text; NONE draw the host's
|
|
2900
|
+
// own content. Born themed + zero-alloc + t3-gated. See decisions/0004.
|
|
2901
|
+
// ===========================================================
|
|
2902
|
+
|
|
2903
|
+
/** Focus Halo (U4b DECORATE) -- a soft glow around the host that breathes while
|
|
2904
|
+
* focused and fades on blur. Generic form feedback; reads only state.focused. */
|
|
2905
|
+
export function FocusHalo(o = {}) {
|
|
2906
|
+
const P = resolveTheme(o, { accent: '#6ee7b6' });
|
|
2907
|
+
let halo=0; // 0..1 presence
|
|
2908
|
+
return {
|
|
2909
|
+
tick(c,dt,now,st) {
|
|
2910
|
+
halo=lerp(halo, st.focused?1:0, dt*8);
|
|
2911
|
+
if(halo<0.01) return;
|
|
2912
|
+
const breathe=0.75+Math.sin(now/380)*0.25, r=8;
|
|
2913
|
+
c.strokeStyle=P.accent;
|
|
2914
|
+
for(let i=3;i>=1;i--){
|
|
2915
|
+
c.globalAlpha=halo*breathe*(0.10*i);
|
|
2916
|
+
c.lineWidth=i*2;
|
|
2917
|
+
rr(c,-i*2,-i*2,st.w+i*4,st.h+i*4,r+i*2);c.stroke();
|
|
2918
|
+
}
|
|
2919
|
+
c.globalAlpha=halo;c.strokeStyle=P.accent;c.lineWidth=1.5;
|
|
2920
|
+
rr(c,-1,-1,st.w+2,st.h+2,r);c.stroke();
|
|
2921
|
+
c.globalAlpha=1;
|
|
2922
|
+
},
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** Error Shake (U4b DECORATE) -- a red border that shakes on the state.valid
|
|
2927
|
+
* true->false edge and settles as the shake decays; a steady red border holds
|
|
2928
|
+
* while invalid. Draws only its OWN jitter (never moves the host). */
|
|
2929
|
+
export function ErrorShake(o = {}) {
|
|
2930
|
+
const P = resolveTheme(o, { accent: '#ff6b6b' });
|
|
2931
|
+
let wasValid=true, shake=0;
|
|
2932
|
+
return {
|
|
2933
|
+
tick(c,dt,now,st) {
|
|
2934
|
+
if(wasValid && st.valid===false) shake=1; // valid -> invalid edge
|
|
2935
|
+
wasValid = st.valid !== false;
|
|
2936
|
+
shake = shake>0 ? shake-dt*1.6 : 0;
|
|
2937
|
+
if(shake<0.01 && st.valid!==false) return; // nothing to show
|
|
2938
|
+
|
|
2939
|
+
const dx = shake>0 ? Math.sin(now/22)*shake*6 : 0;
|
|
2940
|
+
const a = st.valid===false ? 0.9 : shake, r=8;
|
|
2941
|
+
c.globalAlpha=a;c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2942
|
+
rr(c,dx,0,st.w,st.h,r);c.stroke();
|
|
2943
|
+
c.globalAlpha=1;
|
|
2944
|
+
},
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
/** Success Bloom (U4b DECORATE) -- a green ring + fixed-pool particle bloom on the
|
|
2949
|
+
* state.valid false->true edge (a fixed error resolved). Zero-alloc: typed-array
|
|
2950
|
+
* pool preallocated in the factory. */
|
|
2951
|
+
export function SuccessBloom(o = {}) {
|
|
2952
|
+
const P = resolveTheme(o, { accent: '#6ee7b6' });
|
|
2953
|
+
const N = 20;
|
|
2954
|
+
const px=new Float32Array(N), py=new Float32Array(N), pvx=new Float32Array(N), pvy=new Float32Array(N), pa=new Float32Array(N);
|
|
2955
|
+
let wasValid=true, ring=0;
|
|
2956
|
+
function bloom(st){
|
|
2957
|
+
ring=1;
|
|
2958
|
+
const cx=st.w/2, cy=st.h/2;
|
|
2959
|
+
for(let i=0;i<N;i++){
|
|
2960
|
+
const ang=(i/N)*PI2, sp=40+(i%5)*8;
|
|
2961
|
+
px[i]=cx; py[i]=cy; pvx[i]=Math.cos(ang)*sp; pvy[i]=Math.sin(ang)*sp; pa[i]=1;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
return {
|
|
2965
|
+
tick(c,dt,now,st) {
|
|
2966
|
+
// false -> true edge = success. (undefined stays !== false: no edge.)
|
|
2967
|
+
if(wasValid===false && st.valid!==false) bloom(st);
|
|
2968
|
+
wasValid = st.valid !== false;
|
|
2969
|
+
|
|
2970
|
+
if(ring>0){
|
|
2971
|
+
ring-=dt*1.4; if(ring<0) ring=0;
|
|
2972
|
+
const cx=st.w/2, cy=st.h/2, rad=(1-ring)*st.w*0.6;
|
|
2973
|
+
c.globalAlpha=ring;c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2974
|
+
c.beginPath();c.arc(cx,cy,rad,0,PI2);c.stroke();
|
|
2975
|
+
c.globalAlpha=1;
|
|
2976
|
+
}
|
|
2977
|
+
c.fillStyle=P.accent;
|
|
2978
|
+
for(let i=0;i<N;i++){
|
|
2979
|
+
if(pa[i]<=0) continue;
|
|
2980
|
+
px[i]+=pvx[i]*dt; py[i]+=pvy[i]*dt; pvx[i]*=0.92; pvy[i]*=0.92; pa[i]-=dt*1.4;
|
|
2981
|
+
if(pa[i]<=0) continue;
|
|
2982
|
+
c.globalAlpha=pa[i];
|
|
2983
|
+
c.beginPath();c.arc(px[i],py[i],2.5,0,PI2);c.fill();
|
|
2984
|
+
}
|
|
2985
|
+
c.globalAlpha=1;
|
|
2986
|
+
},
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
// U4a additions -- new native element types (CHECKBOX, PROGRESS). Kept out of the
|
|
2992
|
+
// Vol.1-3 historical snapshots above so those stay accurate; all recipes remain
|
|
2993
|
+
// reachable via RECIPES / RECIPE_META and their named exports regardless.
|
|
2994
|
+
export const UIFXRecipes4 = {
|
|
2995
|
+
TickDraw, IndeterminateScan,
|
|
2996
|
+
LiquidFill,
|
|
2997
|
+
};
|
|
2998
|
+
|
|
2999
|
+
// U4b additions -- decorate-mode recipes (a canvas AROUND a live element). Kept out
|
|
3000
|
+
// of the Vol.1-3 + Vol.4 snapshots above; reachable via RECIPES / RECIPE_META and
|
|
3001
|
+
// their named exports regardless.
|
|
3002
|
+
export const UIFXRecipes5 = {
|
|
3003
|
+
FocusHalo, ErrorShake, SuccessBloom,
|
|
3004
|
+
};
|
|
3005
|
+
|
|
2729
3006
|
|
|
2730
3007
|
// ===========================================================
|
|
2731
|
-
// DEFAULT EXPORT -- combined all-
|
|
3008
|
+
// DEFAULT EXPORT -- combined all-53 namespace
|
|
2732
3009
|
// ===========================================================
|
|
2733
3010
|
|
|
2734
3011
|
export default {
|
|
@@ -2759,6 +3036,8 @@ export default {
|
|
|
2759
3036
|
HelixLoader,
|
|
2760
3037
|
RippleCheck,
|
|
2761
3038
|
MorphCheck,
|
|
3039
|
+
TickDraw,
|
|
3040
|
+
IndeterminateScan,
|
|
2762
3041
|
FlameCounter,
|
|
2763
3042
|
GlitchCounter,
|
|
2764
3043
|
BubbleRating,
|
|
@@ -2767,6 +3046,7 @@ export default {
|
|
|
2767
3046
|
RingProgress,
|
|
2768
3047
|
BatteryGauge,
|
|
2769
3048
|
SignalMeter,
|
|
3049
|
+
LiquidFill,
|
|
2770
3050
|
PillTabs,
|
|
2771
3051
|
Stepper,
|
|
2772
3052
|
RadioOrbit,
|
|
@@ -2782,6 +3062,9 @@ export default {
|
|
|
2782
3062
|
ScratchReveal,
|
|
2783
3063
|
TimerCountdown,
|
|
2784
3064
|
PullRefresh,
|
|
3065
|
+
FocusHalo,
|
|
3066
|
+
ErrorShake,
|
|
3067
|
+
SuccessBloom,
|
|
2785
3068
|
};
|
|
2786
3069
|
|
|
2787
3070
|
|
|
@@ -2822,6 +3105,8 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2822
3105
|
helixLoader: HelixLoader,
|
|
2823
3106
|
rippleCheck: RippleCheck,
|
|
2824
3107
|
morphCheck: MorphCheck,
|
|
3108
|
+
tickDraw: TickDraw,
|
|
3109
|
+
indeterminateScan: IndeterminateScan,
|
|
2825
3110
|
flameCounter: FlameCounter,
|
|
2826
3111
|
glitchCounter: GlitchCounter,
|
|
2827
3112
|
bubbleRating: BubbleRating,
|
|
@@ -2830,6 +3115,7 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2830
3115
|
ringProgress: RingProgress,
|
|
2831
3116
|
batteryGauge: BatteryGauge,
|
|
2832
3117
|
signalMeter: SignalMeter,
|
|
3118
|
+
liquidFill: LiquidFill,
|
|
2833
3119
|
pillTabs: PillTabs,
|
|
2834
3120
|
stepper: Stepper,
|
|
2835
3121
|
radioOrbit: RadioOrbit,
|
|
@@ -2845,6 +3131,9 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2845
3131
|
scratchReveal: ScratchReveal,
|
|
2846
3132
|
timerCountdown: TimerCountdown,
|
|
2847
3133
|
pullRefresh: PullRefresh,
|
|
3134
|
+
focusHalo: FocusHalo,
|
|
3135
|
+
errorShake: ErrorShake,
|
|
3136
|
+
successBloom: SuccessBloom,
|
|
2848
3137
|
});
|
|
2849
3138
|
|
|
2850
3139
|
/**
|
|
@@ -2852,8 +3141,10 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2852
3141
|
* a picker without hardcoding the list. A live array: registerRecipe() updates
|
|
2853
3142
|
* it, so existing pickers keep working.
|
|
2854
3143
|
*
|
|
2855
|
-
* type 'toggle'
|
|
2856
|
-
*
|
|
3144
|
+
* type 'toggle'|'button'|'slider'|'checkbox'|'progress'|'knob' -- the
|
|
3145
|
+
* native element it mounts on (mountUIFX); or 'decorate' -- mounted
|
|
3146
|
+
* AROUND a live element via decorateUIFX (no native element created)
|
|
3147
|
+
* family display grouping (Toggles, Buttons, Sliders, Knobs, Form, ...)
|
|
2857
3148
|
* themeable accepts { colors, theme } (true for all as of U3b/1.4.0)
|
|
2858
3149
|
* motionSafe inherently-calm under prefers-reduced-motion (false for all -- U5)
|
|
2859
3150
|
*/
|
|
@@ -2883,36 +3174,51 @@ export const RECIPE_META = [
|
|
|
2883
3174
|
{ id: 'gravitySlider', name: 'Gravity Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
|
|
2884
3175
|
{ id: 'orbitLoader', name: 'Orbit Loader', type: 'toggle', family: 'Loaders', themeable: true, motionSafe: false },
|
|
2885
3176
|
{ id: 'helixLoader', name: 'Helix Loader', type: 'toggle', family: 'Loaders', themeable: true, motionSafe: false },
|
|
2886
|
-
{ id: 'rippleCheck', name: 'Ripple Check', type: '
|
|
2887
|
-
{ id: 'morphCheck', name: 'Morph Check', type: '
|
|
3177
|
+
{ id: 'rippleCheck', name: 'Ripple Check', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
|
|
3178
|
+
{ id: 'morphCheck', name: 'Morph Check', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
|
|
3179
|
+
{ id: 'tickDraw', name: 'Tick Draw', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
|
|
3180
|
+
{ id: 'indeterminateScan', name: 'Indeterminate Scan', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
|
|
2888
3181
|
{ id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
|
|
2889
3182
|
{ id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
|
|
2890
3183
|
{ id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: true, motionSafe: false },
|
|
2891
|
-
{ id: 'volumeKnob', name: 'Volume Knob', type: '
|
|
2892
|
-
{ id: 'compassKnob', name: 'Compass Knob', type: '
|
|
2893
|
-
{ id: 'ringProgress', name: 'Ring Progress', type: '
|
|
2894
|
-
{ id: 'batteryGauge', name: 'Battery Gauge', type: '
|
|
2895
|
-
{ id: 'signalMeter', name: 'Signal Meter', type: '
|
|
3184
|
+
{ id: 'volumeKnob', name: 'Volume Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
|
|
3185
|
+
{ id: 'compassKnob', name: 'Compass Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
|
|
3186
|
+
{ id: 'ringProgress', name: 'Ring Progress', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
3187
|
+
{ id: 'batteryGauge', name: 'Battery Gauge', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
3188
|
+
{ id: 'signalMeter', name: 'Signal Meter', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
3189
|
+
{ id: 'liquidFill', name: 'Liquid Fill', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
2896
3190
|
{ id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
|
|
2897
3191
|
{ id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
|
|
2898
3192
|
{ id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
|
|
2899
|
-
{ id: 'passwordStrength', name: 'Password Strength', type: '
|
|
3193
|
+
{ id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: false },
|
|
2900
3194
|
{ id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
2901
3195
|
{ id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
2902
3196
|
{ id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: true, motionSafe: false },
|
|
2903
3197
|
{ id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
|
|
2904
3198
|
{ id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
|
|
2905
|
-
{ id: 'typewriterField', name: 'Typewriter Field', type: '
|
|
3199
|
+
{ id: 'typewriterField', name: 'Typewriter Field', type: 'decorate', family: 'Feedback', themeable: true, motionSafe: false },
|
|
2906
3200
|
{ id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: true, motionSafe: false },
|
|
2907
|
-
{ id: 'uploadProgress', name: 'Upload Progress', type: '
|
|
3201
|
+
{ id: 'uploadProgress', name: 'Upload Progress', type: 'progress', family: 'Feedback', themeable: true, motionSafe: false },
|
|
2908
3202
|
{ id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
|
|
2909
3203
|
{ id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: true, motionSafe: false },
|
|
2910
3204
|
{ id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
|
|
3205
|
+
{ id: 'focusHalo', name: 'Focus Halo', type: 'decorate', family: 'Form', themeable: true, motionSafe: false },
|
|
3206
|
+
{ id: 'errorShake', name: 'Error Shake', type: 'decorate', family: 'Form', themeable: true, motionSafe: false },
|
|
3207
|
+
{ id: 'successBloom', name: 'Success Bloom', type: 'decorate', family: 'Form', themeable: true, motionSafe: false },
|
|
2911
3208
|
];
|
|
2912
3209
|
|
|
2913
3210
|
/** Names of every built-in recipe (the keys of RECIPES at load time). */
|
|
2914
3211
|
export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
|
|
2915
3212
|
|
|
3213
|
+
// The valid recipe/mount types, taken from the controller's UIType so the
|
|
3214
|
+
// registry's fail-closed check and the controller's mount guard are one source
|
|
3215
|
+
// of truth (they cannot drift as U4 adds types). Built once at load (cold).
|
|
3216
|
+
// Plus the ONE non-UIType routing tag: 'decorate' (U4b) creates no native
|
|
3217
|
+
// element -- it is mounted AROUND a live element by decorateUIFX, not by
|
|
3218
|
+
// mountUIFX -- so it is not a UIType, but it is a valid RECIPE_META.type that
|
|
3219
|
+
// mountRecipe routes on (see below). It is the only member not from UIType.
|
|
3220
|
+
const VALID_META_TYPES = new Set([...Object.values(UIType), 'decorate']);
|
|
3221
|
+
|
|
2916
3222
|
/**
|
|
2917
3223
|
* Register a custom recipe, or override a built-in. Instantly usable via
|
|
2918
3224
|
* RECIPES[id] and reflected in RECIPE_META so existing pickers keep working.
|
|
@@ -2937,9 +3243,11 @@ export function registerRecipe(id, factory, meta) {
|
|
|
2937
3243
|
const type = (meta && meta.type) || (prev && prev.type) || undefined;
|
|
2938
3244
|
// A recipe's type selects its native element; a typeless recipe cannot be
|
|
2939
3245
|
// mounted. Reject it at registration (fail closed) -- checked BEFORE any
|
|
2940
|
-
// mutation, so a rejected call leaves RECIPES/RECIPE_META untouched.
|
|
2941
|
-
|
|
2942
|
-
|
|
3246
|
+
// mutation, so a rejected call leaves RECIPES/RECIPE_META untouched. The
|
|
3247
|
+
// valid set is UIType (VALID_META_TYPES), so registry + controller never
|
|
3248
|
+
// disagree about what a type is.
|
|
3249
|
+
if (!VALID_META_TYPES.has(type)) {
|
|
3250
|
+
throw new TypeError('registerRecipe: type must be one of "button", "toggle", "slider", "checkbox", "progress", "knob"');
|
|
2943
3251
|
}
|
|
2944
3252
|
|
|
2945
3253
|
RECIPES[id] = factory;
|
|
@@ -2992,13 +3300,18 @@ function nearestRecipe(id) {
|
|
|
2992
3300
|
}
|
|
2993
3301
|
|
|
2994
3302
|
/**
|
|
2995
|
-
* Resolve a recipe id to its factory + declared type and mount it
|
|
2996
|
-
*
|
|
3303
|
+
* Resolve a recipe id to its factory + declared type and mount it. A hijack
|
|
3304
|
+
* recipe (type toggle/button/slider/checkbox/progress/knob) mounts via mountUIFX,
|
|
3305
|
+
* creating the native element inside `container`. A DECORATE recipe (type
|
|
3306
|
+
* 'decorate', U4b) mounts via decorateUIFX, treating the first argument as the
|
|
3307
|
+
* LIVE element to decorate (a canvas is placed AROUND it -- nothing is created
|
|
3308
|
+
* inside it). Fail closed:
|
|
2997
3309
|
* - unknown id -> throw naming the nearest known id (did-you-mean).
|
|
2998
3310
|
* - options.type present and != the recipe's declared type -> throw.
|
|
2999
3311
|
* options.type is consumed here, never forwarded as a mount option.
|
|
3000
3312
|
*
|
|
3001
|
-
* @param {HTMLElement} container
|
|
3313
|
+
* @param {HTMLElement} container hijack: parent to mount into; decorate: the
|
|
3314
|
+
* live element to decorate.
|
|
3002
3315
|
* @param {string} id
|
|
3003
3316
|
* @param {Object} [options]
|
|
3004
3317
|
* @returns {{ el: HTMLElement, destroy: Function }}
|
|
@@ -3027,6 +3340,13 @@ export function mountRecipe(container, id, options) {
|
|
|
3027
3340
|
mountOptions = {};
|
|
3028
3341
|
for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
|
|
3029
3342
|
}
|
|
3343
|
+
// A decoration is mounted AROUND a live element (no native element created),
|
|
3344
|
+
// so it routes to decorateUIFX with `container` as the host element. Every
|
|
3345
|
+
// other type is a hijack mount. mountUIFX keeps rejecting 'decorate' via its
|
|
3346
|
+
// own _KNOWN_TYPES guard, so the two paths cannot cross.
|
|
3347
|
+
if (type === 'decorate') {
|
|
3348
|
+
return decorateUIFX(container, factory, mountOptions);
|
|
3349
|
+
}
|
|
3030
3350
|
return mountUIFX(container, type, factory, mountOptions);
|
|
3031
3351
|
}
|
|
3032
3352
|
|