@zakkster/lite-ui-fx 1.5.0 → 1.7.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 +86 -0
- package/README.md +45 -7
- package/UIFX-RECIPE-GUIDE.md +91 -3
- package/UIFXController.d.ts +99 -1
- package/UIFXController.js +481 -13
- package/UIFXRecipes.d.ts +27 -4
- package/UIFXRecipes.js +239 -44
- package/llms.txt +68 -11
- package/package.json +1 -1
package/UIFXRecipes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
5
|
* module, exposed as the ./recipes subpath export, plus the U4a additions for
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* TypewriterField, SoundWaveBtn, UploadProgress, ScratchReveal,
|
|
20
20
|
* TimerCountdown, PullRefresh
|
|
21
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)
|
|
22
25
|
*
|
|
23
26
|
* Registry: RECIPES (null-prototype), RECIPE_META (live), RECIPE_NAMES,
|
|
24
27
|
* registerRecipe(id, factory, meta?), mountRecipe(container, id, options?).
|
|
@@ -32,7 +35,7 @@
|
|
|
32
35
|
|
|
33
36
|
import { lerp, clamp, easeOut, easeIn, easeInOut } from '@zakkster/lite-lerp';
|
|
34
37
|
import { Random } from '@zakkster/lite-random';
|
|
35
|
-
import { mountUIFX, UIType } from './UIFXController.js';
|
|
38
|
+
import { mountUIFX, decorateUIFX, UIType } from './UIFXController.js';
|
|
36
39
|
|
|
37
40
|
|
|
38
41
|
// ---------------------------------------------------------
|
|
@@ -232,16 +235,26 @@ export function SwarmToggle(o = {}) {
|
|
|
232
235
|
// Knob target position
|
|
233
236
|
const tx = st.toggled ? st.w - 18 : 18;
|
|
234
237
|
|
|
235
|
-
// Render particles (spring toward formation)
|
|
238
|
+
// Render particles (spring toward formation). Under reduced motion
|
|
239
|
+
// (U5) they REST at the formation -- no swarm drift, no explosion --
|
|
240
|
+
// so the toggle reads as a static knob. This is the reference calm path.
|
|
236
241
|
ctx.fillStyle = st.toggled ? P.accent : P.dim;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
if (st.reducedMotion) {
|
|
243
|
+
for (let i = 0; i < N; i++) {
|
|
244
|
+
px[i] = tx + ox[i];
|
|
245
|
+
py[i] = st.h / 2 + oy[i];
|
|
246
|
+
ctx.fillRect(px[i], py[i], 1.5, 1.5);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
for (let i = 0; i < N; i++) {
|
|
250
|
+
vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
|
|
251
|
+
vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
|
|
252
|
+
vx[i] *= 0.85;
|
|
253
|
+
vy[i] *= 0.85;
|
|
254
|
+
px[i] += vx[i] * dt;
|
|
255
|
+
py[i] += vy[i] * dt;
|
|
256
|
+
ctx.fillRect(px[i], py[i], 1.5, 1.5);
|
|
257
|
+
}
|
|
245
258
|
}
|
|
246
259
|
|
|
247
260
|
if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
|
|
@@ -2294,6 +2307,28 @@ export function RadioOrbit(o = {}) {
|
|
|
2294
2307
|
// ===========================================================
|
|
2295
2308
|
|
|
2296
2309
|
/** 9. Password Strength -- Segmented bar with color progression and label. */
|
|
2310
|
+
/** Password strength 0..1 from a string, zero-alloc (charCodeAt scan, no
|
|
2311
|
+
* allocating string ops). Length (up to ~12 chars) is 60%, character-class
|
|
2312
|
+
* diversity (lower/upper/digit/symbol) 40%. COLD -- called only when the
|
|
2313
|
+
* decorated field's text changes. */
|
|
2314
|
+
function pwStrength(s) {
|
|
2315
|
+
const n = s.length;
|
|
2316
|
+
if (n === 0) return 0;
|
|
2317
|
+
let lo = 0, up = 0, di = 0, sy = 0;
|
|
2318
|
+
for (let i = 0; i < n; i++) {
|
|
2319
|
+
const c = s.charCodeAt(i);
|
|
2320
|
+
if (c >= 97 && c <= 122) lo = 1;
|
|
2321
|
+
else if (c >= 65 && c <= 90) up = 1;
|
|
2322
|
+
else if (c >= 48 && c <= 57) di = 1;
|
|
2323
|
+
else sy = 1;
|
|
2324
|
+
}
|
|
2325
|
+
const v = Math.min(n / 12, 1) * 0.6 + ((lo + up + di + sy) / 4) * 0.4;
|
|
2326
|
+
return v > 1 ? 1 : v;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/** Password Strength (U4b DECORATE, re-home) -- four strength segments driven by
|
|
2330
|
+
* the LIVE host input's value (state.text), not a faked slider. Strength is
|
|
2331
|
+
* recomputed only when the text changes (cold); the tick is zero-alloc. */
|
|
2297
2332
|
export function PasswordStrength(o = {}) {
|
|
2298
2333
|
let segs=[0,0,0,0];
|
|
2299
2334
|
const labels=['WEAK','FAIR','GOOD','STRONG'];
|
|
@@ -2302,10 +2337,16 @@ export function PasswordStrength(o = {}) {
|
|
|
2302
2337
|
: (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#ff6b6b','#fbbf24','#38bdf8','#6ee7b6']);
|
|
2303
2338
|
const colors = base.length >= 4 ? base : [base[0], base[1 % base.length], base[2 % base.length], base[3 % base.length]];
|
|
2304
2339
|
const noneColor = (o.theme && o.theme.mid) || '#666';
|
|
2340
|
+
let lastText = null, strength = 0; // strength recomputed only on text change
|
|
2305
2341
|
return {
|
|
2306
2342
|
tick(c,dt,now,st) {
|
|
2307
|
-
|
|
2308
|
-
|
|
2343
|
+
// state.text is the live host value; rescan only on change (cold). A
|
|
2344
|
+
// bare decoration over an empty field reads '' -> strength 0.
|
|
2345
|
+
const t = st.text || '';
|
|
2346
|
+
if (t !== lastText) { lastText = t; strength = pwStrength(t); }
|
|
2347
|
+
const level=Math.ceil(strength*4);
|
|
2348
|
+
// Reduced motion (U5): snap segments to their level (no grow animation).
|
|
2349
|
+
for(let i=0;i<4;i++) segs[i]=st.reducedMotion?(i<level?1:0):lerp(segs[i],i<level?1:0,dt*10);
|
|
2309
2350
|
|
|
2310
2351
|
const segW=(st.w-12)/4,segH=8;
|
|
2311
2352
|
for(let i=0;i<4;i++){
|
|
@@ -2562,33 +2603,45 @@ export function NotificationBell(o = {}) {
|
|
|
2562
2603
|
// ===========================================================
|
|
2563
2604
|
|
|
2564
2605
|
/** 15. Typewriter Field -- Characters appear one by one with cursor blink. */
|
|
2606
|
+
/** Typewriter Field (U4b DECORATE, re-home) -- an animated underline that grows
|
|
2607
|
+
* with the LIVE host input's text and a caret that flares on each new character.
|
|
2608
|
+
* Draws NO text (the real input shows its own; a decoration never re-renders the
|
|
2609
|
+
* host content) and reads only state.text's length -- zero-alloc, no measureText. */
|
|
2565
2610
|
export function TypewriterField(o = {}) {
|
|
2566
2611
|
const P = resolveTheme(o, { accent: '#6ee7b6', dim2: '#8888aa' });
|
|
2567
|
-
const
|
|
2568
|
-
const
|
|
2569
|
-
let
|
|
2570
|
-
let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
|
|
2612
|
+
const themed = !!(o.theme || o.colors);
|
|
2613
|
+
const glow = themed ? rgbaOf(P.accent, .5) : 'rgba(110,231,182,.5)';
|
|
2614
|
+
let lastLen=0, fill=0, spark=0, blink=0;
|
|
2571
2615
|
return {
|
|
2572
|
-
onToggle(checked){typing=checked;if(checked){charIdx=0;timer=0}},
|
|
2573
2616
|
tick(c,dt,now,st) {
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2617
|
+
const len=(st.text || '').length;
|
|
2618
|
+
// Reduced motion (U5): no keystroke pulse, no caret blink, underline
|
|
2619
|
+
// snaps to length -- a steady underline + steady caret, zero animation.
|
|
2620
|
+
if(len>lastLen && !st.reducedMotion) spark=1; // a new char landed -> caret pulse
|
|
2621
|
+
lastLen=len;
|
|
2622
|
+
if(!st.reducedMotion) blink=(blink+dt*3)%2;
|
|
2623
|
+
spark=st.reducedMotion?0:(spark>0?spark-dt*3:0);
|
|
2624
|
+
|
|
2625
|
+
// Underline grows toward a fraction of the width set by text length
|
|
2626
|
+
// (capped at ~24 chars = full width). No measureText -> zero-alloc.
|
|
2627
|
+
const target=len===0?0:Math.min(len/24,1);
|
|
2628
|
+
fill=st.reducedMotion?target:lerp(fill,target,dt*8);
|
|
2629
|
+
const y=st.h-3, x0=2, x1=2+(st.w-4)*fill;
|
|
2630
|
+
|
|
2631
|
+
c.strokeStyle='rgba(255,255,255,.08)';c.lineWidth=2;
|
|
2632
|
+
c.beginPath();c.moveTo(x0,y);c.lineTo(st.w-2,y);c.stroke();
|
|
2633
|
+
c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2634
|
+
c.beginPath();c.moveTo(x0,y);c.lineTo(x1,y);c.stroke();
|
|
2635
|
+
|
|
2636
|
+
// Caret: a glow that flares on each keystroke, blinks when idle+focused.
|
|
2637
|
+
if(spark>0.01){
|
|
2638
|
+
c.globalAlpha=spark;c.fillStyle=glow;
|
|
2639
|
+
c.beginPath();c.arc(x1,y,4+spark*3,0,PI2);c.fill();
|
|
2640
|
+
c.globalAlpha=1;
|
|
2641
|
+
}
|
|
2642
|
+
if(st.focused&&(st.reducedMotion||blink<1)){
|
|
2643
|
+
c.fillStyle=P.accent;c.fillRect(x1,y-9,1.5,12);
|
|
2588
2644
|
}
|
|
2589
|
-
|
|
2590
|
-
lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?P.accent:P.dim2);
|
|
2591
|
-
if(st.focused)fr(c,st.w,st.h,6);
|
|
2592
2645
|
},
|
|
2593
2646
|
};
|
|
2594
2647
|
}
|
|
@@ -2854,6 +2907,105 @@ export const UIFXRecipes3 = {
|
|
|
2854
2907
|
ScratchReveal, TimerCountdown, PullRefresh,
|
|
2855
2908
|
};
|
|
2856
2909
|
|
|
2910
|
+
// ===========================================================
|
|
2911
|
+
// DECORATIONS (U4b) -- canvas AROUND a live element (decorateUIFX). Generic form
|
|
2912
|
+
// feedback reading state.focused / state.valid / state.text; NONE draw the host's
|
|
2913
|
+
// own content. Born themed + zero-alloc + t3-gated. See decisions/0004.
|
|
2914
|
+
// ===========================================================
|
|
2915
|
+
|
|
2916
|
+
/** Focus Halo (U4b DECORATE) -- a soft glow around the host that breathes while
|
|
2917
|
+
* focused and fades on blur. Generic form feedback; reads only state.focused. */
|
|
2918
|
+
export function FocusHalo(o = {}) {
|
|
2919
|
+
const P = resolveTheme(o, { accent: '#6ee7b6' });
|
|
2920
|
+
let halo=0; // 0..1 presence
|
|
2921
|
+
return {
|
|
2922
|
+
tick(c,dt,now,st) {
|
|
2923
|
+
halo=lerp(halo, st.focused?1:0, dt*8);
|
|
2924
|
+
if(halo<0.01) return;
|
|
2925
|
+
// Reduced motion (U5): a steady halo, no breathing pulse.
|
|
2926
|
+
const breathe=st.reducedMotion?1:0.75+Math.sin(now/380)*0.25, r=8;
|
|
2927
|
+
c.strokeStyle=P.accent;
|
|
2928
|
+
for(let i=3;i>=1;i--){
|
|
2929
|
+
c.globalAlpha=halo*breathe*(0.10*i);
|
|
2930
|
+
c.lineWidth=i*2;
|
|
2931
|
+
rr(c,-i*2,-i*2,st.w+i*4,st.h+i*4,r+i*2);c.stroke();
|
|
2932
|
+
}
|
|
2933
|
+
c.globalAlpha=halo;c.strokeStyle=P.accent;c.lineWidth=1.5;
|
|
2934
|
+
rr(c,-1,-1,st.w+2,st.h+2,r);c.stroke();
|
|
2935
|
+
c.globalAlpha=1;
|
|
2936
|
+
},
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** Error Shake (U4b DECORATE) -- a red border that shakes on the state.valid
|
|
2941
|
+
* true->false edge and settles as the shake decays; a steady red border holds
|
|
2942
|
+
* while invalid. Draws only its OWN jitter (never moves the host). */
|
|
2943
|
+
export function ErrorShake(o = {}) {
|
|
2944
|
+
const P = resolveTheme(o, { accent: '#ff6b6b' });
|
|
2945
|
+
let wasValid=true, shake=0;
|
|
2946
|
+
return {
|
|
2947
|
+
tick(c,dt,now,st) {
|
|
2948
|
+
if(wasValid && st.valid===false) shake=1; // valid -> invalid edge
|
|
2949
|
+
wasValid = st.valid !== false;
|
|
2950
|
+
shake = shake>0 ? shake-dt*1.6 : 0;
|
|
2951
|
+
if(shake<0.01 && st.valid!==false) return; // nothing to show
|
|
2952
|
+
|
|
2953
|
+
// Reduced motion (U5): the border appears/holds but NEVER shakes
|
|
2954
|
+
// (dx stays 0) -- the whole point of the media query.
|
|
2955
|
+
const dx = (shake>0 && !st.reducedMotion) ? Math.sin(now/22)*shake*6 : 0;
|
|
2956
|
+
const a = st.valid===false ? 0.9 : shake, r=8;
|
|
2957
|
+
c.globalAlpha=a;c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2958
|
+
rr(c,dx,0,st.w,st.h,r);c.stroke();
|
|
2959
|
+
c.globalAlpha=1;
|
|
2960
|
+
},
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/** Success Bloom (U4b DECORATE) -- a green ring + fixed-pool particle bloom on the
|
|
2965
|
+
* state.valid false->true edge (a fixed error resolved). Zero-alloc: typed-array
|
|
2966
|
+
* pool preallocated in the factory. */
|
|
2967
|
+
export function SuccessBloom(o = {}) {
|
|
2968
|
+
const P = resolveTheme(o, { accent: '#6ee7b6' });
|
|
2969
|
+
const N = 20;
|
|
2970
|
+
const px=new Float32Array(N), py=new Float32Array(N), pvx=new Float32Array(N), pvy=new Float32Array(N), pa=new Float32Array(N);
|
|
2971
|
+
let wasValid=true, ring=0;
|
|
2972
|
+
function bloom(st){
|
|
2973
|
+
ring=1;
|
|
2974
|
+
if(st.reducedMotion) return; // U5 calm: a fading ring only, no particle burst
|
|
2975
|
+
const cx=st.w/2, cy=st.h/2;
|
|
2976
|
+
for(let i=0;i<N;i++){
|
|
2977
|
+
const ang=(i/N)*PI2, sp=40+(i%5)*8;
|
|
2978
|
+
px[i]=cx; py[i]=cy; pvx[i]=Math.cos(ang)*sp; pvy[i]=Math.sin(ang)*sp; pa[i]=1;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
return {
|
|
2982
|
+
tick(c,dt,now,st) {
|
|
2983
|
+
// false -> true edge = success. (undefined stays !== false: no edge.)
|
|
2984
|
+
if(wasValid===false && st.valid!==false) bloom(st);
|
|
2985
|
+
wasValid = st.valid !== false;
|
|
2986
|
+
|
|
2987
|
+
if(ring>0){
|
|
2988
|
+
ring-=dt*1.4; if(ring<0) ring=0;
|
|
2989
|
+
// Reduced motion (U5): a fixed-radius ring that fades (alpha), no expansion.
|
|
2990
|
+
const cx=st.w/2, cy=st.h/2, rad=st.reducedMotion?st.w*0.5:(1-ring)*st.w*0.6;
|
|
2991
|
+
c.globalAlpha=ring;c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2992
|
+
c.beginPath();c.arc(cx,cy,rad,0,PI2);c.stroke();
|
|
2993
|
+
c.globalAlpha=1;
|
|
2994
|
+
}
|
|
2995
|
+
c.fillStyle=P.accent;
|
|
2996
|
+
for(let i=0;i<N;i++){
|
|
2997
|
+
if(pa[i]<=0) continue;
|
|
2998
|
+
px[i]+=pvx[i]*dt; py[i]+=pvy[i]*dt; pvx[i]*=0.92; pvy[i]*=0.92; pa[i]-=dt*1.4;
|
|
2999
|
+
if(pa[i]<=0) continue;
|
|
3000
|
+
c.globalAlpha=pa[i];
|
|
3001
|
+
c.beginPath();c.arc(px[i],py[i],2.5,0,PI2);c.fill();
|
|
3002
|
+
}
|
|
3003
|
+
c.globalAlpha=1;
|
|
3004
|
+
},
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
|
|
2857
3009
|
// U4a additions -- new native element types (CHECKBOX, PROGRESS). Kept out of the
|
|
2858
3010
|
// Vol.1-3 historical snapshots above so those stay accurate; all recipes remain
|
|
2859
3011
|
// reachable via RECIPES / RECIPE_META and their named exports regardless.
|
|
@@ -2862,6 +3014,13 @@ export const UIFXRecipes4 = {
|
|
|
2862
3014
|
LiquidFill,
|
|
2863
3015
|
};
|
|
2864
3016
|
|
|
3017
|
+
// U4b additions -- decorate-mode recipes (a canvas AROUND a live element). Kept out
|
|
3018
|
+
// of the Vol.1-3 + Vol.4 snapshots above; reachable via RECIPES / RECIPE_META and
|
|
3019
|
+
// their named exports regardless.
|
|
3020
|
+
export const UIFXRecipes5 = {
|
|
3021
|
+
FocusHalo, ErrorShake, SuccessBloom,
|
|
3022
|
+
};
|
|
3023
|
+
|
|
2865
3024
|
|
|
2866
3025
|
// ===========================================================
|
|
2867
3026
|
// DEFAULT EXPORT -- combined all-53 namespace
|
|
@@ -2921,6 +3080,9 @@ export default {
|
|
|
2921
3080
|
ScratchReveal,
|
|
2922
3081
|
TimerCountdown,
|
|
2923
3082
|
PullRefresh,
|
|
3083
|
+
FocusHalo,
|
|
3084
|
+
ErrorShake,
|
|
3085
|
+
SuccessBloom,
|
|
2924
3086
|
};
|
|
2925
3087
|
|
|
2926
3088
|
|
|
@@ -2987,6 +3149,9 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2987
3149
|
scratchReveal: ScratchReveal,
|
|
2988
3150
|
timerCountdown: TimerCountdown,
|
|
2989
3151
|
pullRefresh: PullRefresh,
|
|
3152
|
+
focusHalo: FocusHalo,
|
|
3153
|
+
errorShake: ErrorShake,
|
|
3154
|
+
successBloom: SuccessBloom,
|
|
2990
3155
|
});
|
|
2991
3156
|
|
|
2992
3157
|
/**
|
|
@@ -2994,13 +3159,15 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2994
3159
|
* a picker without hardcoding the list. A live array: registerRecipe() updates
|
|
2995
3160
|
* it, so existing pickers keep working.
|
|
2996
3161
|
*
|
|
2997
|
-
* type 'toggle'
|
|
2998
|
-
*
|
|
3162
|
+
* type 'toggle'|'button'|'slider'|'checkbox'|'progress'|'knob' -- the
|
|
3163
|
+
* native element it mounts on (mountUIFX); or 'decorate' -- mounted
|
|
3164
|
+
* AROUND a live element via decorateUIFX (no native element created)
|
|
3165
|
+
* family display grouping (Toggles, Buttons, Sliders, Knobs, Form, ...)
|
|
2999
3166
|
* themeable accepts { colors, theme } (true for all as of U3b/1.4.0)
|
|
3000
3167
|
* motionSafe inherently-calm under prefers-reduced-motion (false for all -- U5)
|
|
3001
3168
|
*/
|
|
3002
3169
|
export const RECIPE_META = [
|
|
3003
|
-
{ id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe:
|
|
3170
|
+
{ id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: true },
|
|
3004
3171
|
{ id: 'liquidToggle', name: 'Liquid Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
|
|
3005
3172
|
{ id: 'neonPulseToggle', name: 'Neon Pulse Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
|
|
3006
3173
|
{ id: 'magneticButton', name: 'Magnetic Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
|
|
@@ -3041,18 +3208,21 @@ export const RECIPE_META = [
|
|
|
3041
3208
|
{ id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
|
|
3042
3209
|
{ id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
|
|
3043
3210
|
{ id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
|
|
3044
|
-
{ id: 'passwordStrength', name: 'Password Strength', type: '
|
|
3211
|
+
{ id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: true },
|
|
3045
3212
|
{ id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
3046
3213
|
{ id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
3047
3214
|
{ id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: true, motionSafe: false },
|
|
3048
3215
|
{ id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
|
|
3049
3216
|
{ id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
|
|
3050
|
-
{ id: 'typewriterField', name: 'Typewriter Field', type: '
|
|
3217
|
+
{ id: 'typewriterField', name: 'Typewriter Field', type: 'decorate', family: 'Feedback', themeable: true, motionSafe: true },
|
|
3051
3218
|
{ id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: true, motionSafe: false },
|
|
3052
3219
|
{ id: 'uploadProgress', name: 'Upload Progress', type: 'progress', family: 'Feedback', themeable: true, motionSafe: false },
|
|
3053
3220
|
{ id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
|
|
3054
3221
|
{ id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: true, motionSafe: false },
|
|
3055
3222
|
{ id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
|
|
3223
|
+
{ id: 'focusHalo', name: 'Focus Halo', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
|
|
3224
|
+
{ id: 'errorShake', name: 'Error Shake', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
|
|
3225
|
+
{ id: 'successBloom', name: 'Success Bloom', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
|
|
3056
3226
|
];
|
|
3057
3227
|
|
|
3058
3228
|
/** Names of every built-in recipe (the keys of RECIPES at load time). */
|
|
@@ -3061,7 +3231,11 @@ export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
|
|
|
3061
3231
|
// The valid recipe/mount types, taken from the controller's UIType so the
|
|
3062
3232
|
// registry's fail-closed check and the controller's mount guard are one source
|
|
3063
3233
|
// of truth (they cannot drift as U4 adds types). Built once at load (cold).
|
|
3064
|
-
|
|
3234
|
+
// Plus the ONE non-UIType routing tag: 'decorate' (U4b) creates no native
|
|
3235
|
+
// element -- it is mounted AROUND a live element by decorateUIFX, not by
|
|
3236
|
+
// mountUIFX -- so it is not a UIType, but it is a valid RECIPE_META.type that
|
|
3237
|
+
// mountRecipe routes on (see below). It is the only member not from UIType.
|
|
3238
|
+
const VALID_META_TYPES = new Set([...Object.values(UIType), 'decorate']);
|
|
3065
3239
|
|
|
3066
3240
|
/**
|
|
3067
3241
|
* Register a custom recipe, or override a built-in. Instantly usable via
|
|
@@ -3144,13 +3318,18 @@ function nearestRecipe(id) {
|
|
|
3144
3318
|
}
|
|
3145
3319
|
|
|
3146
3320
|
/**
|
|
3147
|
-
* Resolve a recipe id to its factory + declared type and mount it
|
|
3148
|
-
*
|
|
3321
|
+
* Resolve a recipe id to its factory + declared type and mount it. A hijack
|
|
3322
|
+
* recipe (type toggle/button/slider/checkbox/progress/knob) mounts via mountUIFX,
|
|
3323
|
+
* creating the native element inside `container`. A DECORATE recipe (type
|
|
3324
|
+
* 'decorate', U4b) mounts via decorateUIFX, treating the first argument as the
|
|
3325
|
+
* LIVE element to decorate (a canvas is placed AROUND it -- nothing is created
|
|
3326
|
+
* inside it). Fail closed:
|
|
3149
3327
|
* - unknown id -> throw naming the nearest known id (did-you-mean).
|
|
3150
3328
|
* - options.type present and != the recipe's declared type -> throw.
|
|
3151
3329
|
* options.type is consumed here, never forwarded as a mount option.
|
|
3152
3330
|
*
|
|
3153
|
-
* @param {HTMLElement} container
|
|
3331
|
+
* @param {HTMLElement} container hijack: parent to mount into; decorate: the
|
|
3332
|
+
* live element to decorate.
|
|
3154
3333
|
* @param {string} id
|
|
3155
3334
|
* @param {Object} [options]
|
|
3156
3335
|
* @returns {{ el: HTMLElement, destroy: Function }}
|
|
@@ -3168,6 +3347,15 @@ export function mountRecipe(container, id, options) {
|
|
|
3168
3347
|
}
|
|
3169
3348
|
const meta = RECIPE_META.find((m) => m.id === id) || null;
|
|
3170
3349
|
const type = meta ? meta.type : undefined;
|
|
3350
|
+
// Reduced-motion advisory (U5, decisions/0005): a recipe with no calm path,
|
|
3351
|
+
// mounted while the user prefers reduced motion, gets a console.warn -- advice,
|
|
3352
|
+
// not a gate (a host may run its own toggle). Cold, mount-time only; a missing
|
|
3353
|
+
// matchMedia is a silent skip (fail closed, never throw).
|
|
3354
|
+
if (meta && meta.motionSafe === false &&
|
|
3355
|
+
typeof window !== 'undefined' && typeof window.matchMedia === 'function' &&
|
|
3356
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
3357
|
+
console.warn('mountRecipe: recipe "' + id + '" is not motionSafe and the user prefers reduced motion; it will animate. See RECIPE_META.motionSafe.');
|
|
3358
|
+
}
|
|
3171
3359
|
if (options && 'type' in options && options.type !== type) {
|
|
3172
3360
|
throw new Error(
|
|
3173
3361
|
'mountRecipe: recipe "' + id + '" mounts as "' + type +
|
|
@@ -3179,6 +3367,13 @@ export function mountRecipe(container, id, options) {
|
|
|
3179
3367
|
mountOptions = {};
|
|
3180
3368
|
for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
|
|
3181
3369
|
}
|
|
3370
|
+
// A decoration is mounted AROUND a live element (no native element created),
|
|
3371
|
+
// so it routes to decorateUIFX with `container` as the host element. Every
|
|
3372
|
+
// other type is a hijack mount. mountUIFX keeps rejecting 'decorate' via its
|
|
3373
|
+
// own _KNOWN_TYPES guard, so the two paths cannot cross.
|
|
3374
|
+
if (type === 'decorate') {
|
|
3375
|
+
return decorateUIFX(container, factory, mountOptions);
|
|
3376
|
+
}
|
|
3182
3377
|
return mountUIFX(container, type, factory, mountOptions);
|
|
3183
3378
|
}
|
|
3184
3379
|
|
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @zakkster/lite-ui-fx
|
|
2
|
-
> Canvas-hijacked UI components with pluggable recipe system.
|
|
2
|
+
> Canvas-hijacked UI components with pluggable recipe system. 56 built-in recipes.
|
|
3
3
|
|
|
4
|
-
VERSION 1.
|
|
4
|
+
VERSION 1.7.0
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
npm i @zakkster/lite-ui-fx
|
|
@@ -12,9 +12,9 @@ Canvas overlay (z-index:1) renders visuals via a recipe factory function.
|
|
|
12
12
|
Recipe = { tick(), init?(), onHover?(), onClick?(), onToggle?(), onDrag?(), destroy?() }
|
|
13
13
|
|
|
14
14
|
## Import -- Controller
|
|
15
|
-
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
15
|
+
import { mountUIFX, decorateUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
16
16
|
|
|
17
|
-
## Import -- Recipes (one ./recipes subpath,
|
|
17
|
+
## Import -- Recipes (one ./recipes subpath, 56 total, tree-shakeable)
|
|
18
18
|
import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
19
19
|
import { PendulumToggle, HeartbeatButton, AuroraSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
20
20
|
import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
|
|
@@ -24,11 +24,23 @@ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from
|
|
|
24
24
|
// RECIPES id->factory (null-proto); RECIPE_META { id, name, type, family, themeable, motionSafe }; RECIPE_NAMES frozen.
|
|
25
25
|
// mountRecipe(container, id, options?): resolves id fail-closed (did-you-mean), asserts META.type, mounts.
|
|
26
26
|
|
|
27
|
-
## Mount
|
|
27
|
+
## Mount -- two modes
|
|
28
|
+
// HIJACK (mountUIFX): creates a native element (opacity:0) inside `container` and
|
|
29
|
+
// paints a canvas over it. The native element owns events + a11y.
|
|
28
30
|
const instance = mountUIFX(container, UIType.TOGGLE, SwarmToggle, { label: 'Sound' });
|
|
29
31
|
instance.setValue(v); // SLIDER/KNOB/PROGRESS: v in 0..1 (fires onDrag once); CHECKBOX: setValue(null) = indeterminate
|
|
30
32
|
instance.setChecked(b); // TOGGLE/CHECKBOX: set checked (fires onToggle once)
|
|
31
33
|
instance.destroy(); // cleanup
|
|
34
|
+
// DECORATE (decorateUIFX): a canvas AROUND an EXISTING visible element -- no
|
|
35
|
+
// native element created, no opacity:0, host never reparented; the overlay is a
|
|
36
|
+
// sibling placed from the host's offset box, removed on destroy (host byte-
|
|
37
|
+
// identical). State is wired from the host's own events; for a form-control host
|
|
38
|
+
// state.text/state.valid mirror el.value/el.validity (read at event time). This
|
|
39
|
+
// is the home for a decoration over a real input.
|
|
40
|
+
const deco = decorateUIFX(inputEl, PasswordStrength, { theme });
|
|
41
|
+
deco.destroy(); // removes ONLY the overlay + its listeners; host untouched
|
|
42
|
+
// setValue/setChecked are HIJACK-ONLY: they throw in decorate mode (a decoration
|
|
43
|
+
// reflects the host; it does not drive it).
|
|
32
44
|
|
|
33
45
|
## Options (4th arg; unknown option or recipe-hook keys throw a did-you-mean -- fail closed)
|
|
34
46
|
width, height, padding=40, label // geometry + accessible label
|
|
@@ -42,6 +54,23 @@ text // visible canvas label; falls back to label, then the recipe default
|
|
|
42
54
|
font // canvas font string; falls back to the recipe's historical font
|
|
43
55
|
knobMode // KNOB only: 'rotate' | 'vertical' pointer mapping (default 'rotate'); wrong type throws
|
|
44
56
|
announce // PROGRESS only: opt-in aria-live announcements at 10% steps; wrong type throws
|
|
57
|
+
ticker // U5 host clock: a caller-supplied lite-ticker ({ add(fn)->removeFn }) drives this component
|
|
58
|
+
driven // U5 host clock: boolean; true = no ticker/RAF, the host calls instance.tick(dtMs)
|
|
59
|
+
// decorateUIFX accepts a SUBSET: padding, seed, colors, theme, text, font -- PLUS
|
|
60
|
+
// the host-clock keys ticker/driven (a decoration wants clock control too). The
|
|
61
|
+
// hijack-only keys (width/height/value/checked/disabled/knobMode/announce/label)
|
|
62
|
+
// throw in decorate mode -- geometry comes from the host, value is read from it.
|
|
63
|
+
|
|
64
|
+
## Host clock (U5) -- three mutually-exclusive modes, both mount modes
|
|
65
|
+
// default (neither option): the shared ref-counted ticker -- one RAF for all
|
|
66
|
+
// components, byte-identical to earlier versions.
|
|
67
|
+
// { ticker: t }: a caller-supplied lite-ticker drives this component. destroy()
|
|
68
|
+
// removes the component's frame but NEVER destroys the caller's ticker.
|
|
69
|
+
const inst = mountUIFX(container, UIType.SLIDER, SparkSlider, { ticker: myTicker });
|
|
70
|
+
// { driven: true }: no ticker/RAF at all; the host drives each frame by hand.
|
|
71
|
+
const d = mountUIFX(container, UIType.BUTTON, MagneticButton, { driven: true });
|
|
72
|
+
d.tick(16.7); // one frame; the SAME body the ticker would call. throws when NOT driven.
|
|
73
|
+
// Passing both, a non-boolean driven, or a ticker without .add() throws (fail closed).
|
|
45
74
|
|
|
46
75
|
## Element Types
|
|
47
76
|
UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
|
|
@@ -50,11 +79,19 @@ UIType.SLIDER -> <input type="range"> -> state.val (0-1), onDrag(val, velocity
|
|
|
50
79
|
UIType.CHECKBOX -> <input type="checkbox"> (no role=switch) -> state.toggled + state.indeterminate, onToggle(checked)
|
|
51
80
|
UIType.PROGRESS -> <progress> (non-interactive) -> state.val, driven by instance.setValue
|
|
52
81
|
UIType.KNOB -> <input type="range"> -> state.val, arrows native + knobMode pointer map, onDrag(val, velocity)
|
|
82
|
+
(decorate) -> NO native element created; a canvas AROUND a live host (decorateUIFX). Not a UIType --
|
|
83
|
+
RECIPE_META.type 'decorate' routes mountRecipe to decorateUIFX. State: focused + text + valid.
|
|
53
84
|
|
|
54
85
|
## State Object (provided to tick every frame)
|
|
55
|
-
{ hover, active, focused, toggled, indeterminate, disabled, val, w, h, padding, dpr
|
|
86
|
+
{ hover, active, focused, toggled, indeterminate, disabled, val, w, h, padding, dpr,
|
|
87
|
+
reducedMotion, budget }
|
|
88
|
+
// reducedMotion (U5): boolean, true when the user prefers reduced motion; a
|
|
89
|
+
// calm-path recipe renders statically when set (RECIPE_META.motionSafe marks which).
|
|
90
|
+
// budget (U5): 0..1 frame budget, 1 at ~60fps, lower as frames lengthen; budget-aware
|
|
91
|
+
// recipes shed work (particles/glow) when it drops.
|
|
92
|
+
// decorate mode also adds: text (host value string), valid (host validity boolean).
|
|
56
93
|
|
|
57
|
-
##
|
|
94
|
+
## 56 Built-in Recipes
|
|
58
95
|
|
|
59
96
|
### Vol. 1 -- 10 recipes
|
|
60
97
|
Toggles: SwarmToggle, LiquidToggle, NeonPulseToggle
|
|
@@ -83,6 +120,11 @@ Fun: ScratchReveal, TimerCountdown, PullRefresh
|
|
|
83
120
|
Checkboxes (UIType.CHECKBOX): TickDraw, IndeterminateScan
|
|
84
121
|
Progress (UIType.PROGRESS): LiquidFill
|
|
85
122
|
|
|
123
|
+
### U4b -- 3 recipes (decorate mode: a canvas AROUND a live element)
|
|
124
|
+
Form feedback (decorateUIFX): FocusHalo, ErrorShake, SuccessBloom
|
|
125
|
+
// Re-homed to decorate mode (type 'decorate'): PasswordStrength (reads the live
|
|
126
|
+
// input's text), TypewriterField (an underline that grows with the typed text).
|
|
127
|
+
|
|
86
128
|
## Writing Custom Recipes
|
|
87
129
|
See UIFX-RECIPE-GUIDE.md (included in package).
|
|
88
130
|
|
|
@@ -98,10 +140,25 @@ See UIFX-RECIPE-GUIDE.md (included in package).
|
|
|
98
140
|
- Zero-GC in all built-in recipes: const colors + globalAlpha, precomputed
|
|
99
141
|
color/label LUTs, fixed preallocated particle pools, gradients built in init.
|
|
100
142
|
Gated per recipe by the t3-frame-alloc torture tier (default AND themed mount).
|
|
101
|
-
- Themeable: all
|
|
143
|
+
- Themeable: all 56 recipes honour { colors, theme:{light,mid,dark}, text, font },
|
|
102
144
|
resolved once in init (zero per-frame alloc). RECIPE_META.themeable is true for
|
|
103
|
-
all
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
all 56. A bare mount is byte-identical to pre-theming. Shipped palettes + APCA
|
|
146
|
+
contrast are authored with @zakkster/lite-hueforge (a dev-only tool, never a
|
|
147
|
+
runtime dependency).
|
|
148
|
+
- U5 host clock: mount { ticker } to ride a caller-supplied lite-ticker (destroy
|
|
149
|
+
never destroys the caller's clock) or { driven:true } to drive instance.tick(dtMs)
|
|
150
|
+
by hand (no RAF); omit both for the shared ref-counted ticker. Default byte-identical.
|
|
151
|
+
- U5 reduced motion: state.reducedMotion (matchMedia, watched) makes calm-path
|
|
152
|
+
recipes render statically -- ErrorShake stops shaking, SwarmToggle/SuccessBloom/
|
|
153
|
+
FocusHalo drop their motion. RECIPE_META.motionSafe is true for EXACTLY the
|
|
154
|
+
recipes that ship a calm path (SwarmToggle + the 5 decorate recipes today; the
|
|
155
|
+
rest honestly false until each lands one). mountRecipe warns (not throws) mounting
|
|
156
|
+
a motionSafe:false recipe under active reduce. state.budget (0..1) lets budget-aware
|
|
157
|
+
recipes shed work before frames drop.
|
|
106
158
|
- U4a element types: CHECKBOX (indeterminate), PROGRESS (setValue-driven, aria-live
|
|
107
159
|
opt-in), KNOB (knobMode pointer map); setValue/setChecked sync native+state+hook once.
|
|
160
|
+
- U4b decorate mode (decorateUIFX): a canvas AROUND a live element -- no hijack, no
|
|
161
|
+
opacity:0, host never reparented; the overlay is a sibling placed from the host's
|
|
162
|
+
offset box and removed on destroy (host byte-identical, additive-only). State is
|
|
163
|
+
wired from the host's own events (state.text/state.valid at event time). It is the
|
|
164
|
+
second mount mode + the surface the enrichment decorations build on.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-ui-fx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Canvas-hijacked UI components with a pluggable recipe system. 50 built-in recipes across toggles, buttons, sliders, knobs, loaders, checkboxes, counters, and ratings.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|