@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/UIFXRecipes.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-ui-fx -- Recipe Collection (all 53)
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
- for (let i = 0; i < N; i++) {
238
- vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
239
- vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
240
- vx[i] *= 0.85;
241
- vy[i] *= 0.85;
242
- px[i] += vx[i] * dt;
243
- py[i] += vy[i] * dt;
244
- ctx.fillRect(px[i], py[i], 1.5, 1.5);
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
- const level=Math.ceil(st.val*4);
2308
- for(let i=0;i<4;i++) segs[i]=lerp(segs[i],i<level?1:0,dt*10);
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 FONT = pickFont(o, "500 13px 'JetBrains Mono',monospace");
2568
- const text=pickText(o, 'Hello World');
2569
- let charIdx=0, timer=0, cursorBlink=0, typing=false;
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
- cursorBlink=(cursorBlink+dt*3)%2;
2575
- if(typing&&charIdx<text.length){timer+=dt;if(timer>.08){timer=0;charIdx++}}
2576
-
2577
- c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
2578
- c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
2579
-
2580
- c.fillStyle=P.accent;c.font=FONT;c.textAlign='left';c.textBaseline='middle';
2581
- // Rebuild the visible substring + its width only when a char is added.
2582
- if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
2583
- c.fillText(display,8,st.h/2);
2584
-
2585
- // Cursor
2586
- if(cursorBlink<1){
2587
- c.fillStyle=P.accent;c.fillRect(9+dispW,st.h/2-8,1.5,16);
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' | 'button' | 'slider' -- the native element it mounts on
2998
- * family display grouping (Toggles, Buttons, Sliders, Knobs, ...)
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: false },
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: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
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: 'toggle', family: 'Feedback', themeable: true, motionSafe: false },
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
- const VALID_META_TYPES = new Set(Object.values(UIType));
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 via
3148
- * mountUIFX. Fail closed:
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. 53 built-in recipes.
2
+ > Canvas-hijacked UI components with pluggable recipe system. 56 built-in recipes.
3
3
 
4
- VERSION 1.5.0
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, 53 total, tree-shakeable)
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
- ## 53 Built-in Recipes
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 53 recipes honour { colors, theme:{light,mid,dark}, text, font },
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 53; motionSafe stays false (reduced motion is a later pass). A bare mount is
104
- byte-identical to pre-theming. Shipped palettes + APCA contrast are authored with
105
- @zakkster/lite-hueforge (a dev-only tool, never a runtime dependency).
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.5.0",
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",