@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/UIFXRecipes.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @zakkster/lite-ui-fx -- Recipe Collection (all 50)
2
+ * @zakkster/lite-ui-fx -- Recipe Collection (all 56)
3
3
  *
4
4
  * The three recipe volumes consolidated into one shipped, typed, versioned
5
- * module, exposed as the ./recipes subpath export. See
6
- * decisions/0001-recipes-position.md.
5
+ * module, exposed as the ./recipes subpath export, plus the U4a additions for
6
+ * the new native element types. See decisions/0001-recipes-position.md.
7
7
  *
8
8
  * Vol.1 (10): SwarmToggle, LiquidToggle, NeonPulseToggle, MagneticButton,
9
9
  * ShatterButton, ConfettiButton, GlitchButton, SparkSlider,
@@ -18,6 +18,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
- const level=Math.ceil(st.val*4);
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 FONT = pickFont(o, "500 13px 'JetBrains Mono',monospace");
2440
- const text=pickText(o, 'Hello World');
2441
- let charIdx=0, timer=0, cursorBlink=0, typing=false;
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
- cursorBlink=(cursorBlink+dt*3)%2;
2447
- if(typing&&charIdx<text.length){timer+=dt;if(timer>.08){timer=0;charIdx++}}
2448
-
2449
- c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
2450
- c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
2451
-
2452
- c.fillStyle=P.accent;c.font=FONT;c.textAlign='left';c.textBaseline='middle';
2453
- // Rebuild the visible substring + its width only when a char is added.
2454
- if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
2455
- c.fillText(display,8,st.h/2);
2456
-
2457
- // Cursor
2458
- if(cursorBlink<1){
2459
- c.fillStyle=P.accent;c.fillRect(9+dispW,st.h/2-8,1.5,16);
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-50 namespace
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' | 'button' | 'slider' -- the native element it mounts on
2856
- * family display grouping (Toggles, Buttons, Sliders, Knobs, ...)
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: 'toggle', family: 'Checkboxes', themeable: true, motionSafe: false },
2887
- { id: 'morphCheck', name: 'Morph Check', type: 'toggle', family: 'Checkboxes', themeable: true, motionSafe: false },
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: 'slider', family: 'Knobs', themeable: true, motionSafe: false },
2892
- { id: 'compassKnob', name: 'Compass Knob', type: 'slider', family: 'Knobs', themeable: true, motionSafe: false },
2893
- { id: 'ringProgress', name: 'Ring Progress', type: 'slider', family: 'Progress', themeable: true, motionSafe: false },
2894
- { id: 'batteryGauge', name: 'Battery Gauge', type: 'slider', family: 'Progress', themeable: true, motionSafe: false },
2895
- { id: 'signalMeter', name: 'Signal Meter', type: 'slider', family: 'Progress', themeable: true, motionSafe: false },
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: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
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: 'toggle', family: 'Feedback', themeable: true, motionSafe: false },
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: 'slider', family: 'Feedback', themeable: true, motionSafe: false },
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
- if (type !== 'toggle' && type !== 'button' && type !== 'slider') {
2942
- throw new TypeError('registerRecipe: type must be "toggle", "button", or "slider"');
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 via
2996
- * mountUIFX. Fail closed:
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