@zakkster/lite-ui-fx 1.7.0 → 1.9.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.d.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { UIFXRecipe, UIFXInstance, MountOptions } from './UIFXController';
1
+ import type {
2
+ UIFXRecipe, UIFXInstance, MountOptions,
3
+ UIFXGroupRecipe, GroupOptions, UIFXGroupInstance, DecorateInstance,
4
+ } from './UIFXController';
2
5
 
3
6
  // ===========================================================
4
7
  // RECIPE OPTIONS + FACTORIES (all 56)
@@ -81,7 +84,8 @@ export declare function FlameCounter(options?: RecipeOptions): UIFXRecipe;
81
84
  export declare function GlitchCounter(options?: RecipeOptions): UIFXRecipe;
82
85
 
83
86
  // -- Vol.2: Rating --
84
- export declare function BubbleRating(options?: RecipeOptions): UIFXRecipe;
87
+ /** U7 GROUP (rating): mount via mountUIFXGroup(GroupType.RATING, ...). */
88
+ export declare function BubbleRating(options?: RecipeOptions): UIFXGroupRecipe;
85
89
 
86
90
  // -- Vol.3: Knobs --
87
91
  export declare function VolumeKnob(options?: RecipeOptions): UIFXRecipe;
@@ -95,9 +99,13 @@ export declare function SignalMeter(options?: RecipeOptions): UIFXRecipe;
95
99
  export declare function LiquidFill(options?: RecipeOptions): UIFXRecipe;
96
100
 
97
101
  // -- Vol.3: Controls --
98
- export declare function PillTabs(options?: RecipeOptions): UIFXRecipe;
99
- export declare function Stepper(options?: RecipeOptions): UIFXRecipe;
100
- export declare function RadioOrbit(options?: RecipeOptions): UIFXRecipe;
102
+ // -- U7 GROUP recipes (mounted via mountUIFXGroup): N native elements + one
103
+ // canvas. PillTabs/SegmentedSlide -> GroupType.TABS, Stepper -> STEPPER,
104
+ // RadioOrbit -> RADIO. See decisions/0007. --
105
+ export declare function PillTabs(options?: RecipeOptions): UIFXGroupRecipe;
106
+ export declare function SegmentedSlide(options?: RecipeOptions): UIFXGroupRecipe;
107
+ export declare function Stepper(options?: RecipeOptions): UIFXGroupRecipe;
108
+ export declare function RadioOrbit(options?: RecipeOptions): UIFXGroupRecipe;
101
109
 
102
110
  // -- Vol.3: Indicators --
103
111
  export declare function PasswordStrength(options?: RecipeOptions): UIFXRecipe;
@@ -203,14 +211,24 @@ export declare const UIFXRecipes5: {
203
211
  SuccessBloom: typeof SuccessBloom;
204
212
  };
205
213
 
214
+ /** U7 additions -- grouped controls (mounted via mountUIFXGroup). PillTabs/Stepper/
215
+ * RadioOrbit/BubbleRating re-home from vol.3 single-element fakes; SegmentedSlide
216
+ * is new. See decisions/0007. */
217
+ export declare const UIFXRecipes6: {
218
+ SegmentedSlide: typeof SegmentedSlide;
219
+ };
220
+
206
221
  // ===========================================================
207
222
  // RECIPE REGISTRY
208
223
  // ===========================================================
209
224
 
210
- // 'decorate' is not a UIType (it creates no native element); it is the registry
211
- // routing tag for a recipe mounted AROUND a live element via decorateUIFX. See
212
- // decisions/0004.
213
- export type RecipeType = 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob' | 'decorate';
225
+ // Beyond the UITypes there are two non-UIType routing tags: 'decorate' (U4b,
226
+ // mounted AROUND a live element via decorateUIFX -- 0004) and the four GroupTypes
227
+ // (U7, mounted as N native elements + one canvas via mountUIFXGroup -- 0007).
228
+ export type RecipeType =
229
+ | 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob'
230
+ | 'decorate'
231
+ | 'radio' | 'tabs' | 'stepper' | 'rating';
214
232
 
215
233
  export type RecipeFactory = (options?: Record<string, unknown>) => UIFXRecipe;
216
234
 
@@ -243,14 +261,15 @@ export declare function registerRecipe(
243
261
  * Resolve a recipe id to its factory + declared type and mount it. A hijack
244
262
  * recipe mounts via mountUIFX (the native element is created inside `container`);
245
263
  * a recipe whose meta.type is 'decorate' mounts via decorateUIFX, treating
246
- * `container` as the LIVE element to decorate (a canvas is placed AROUND it).
247
- * Fail closed: unknown id or a conflicting options.type throws.
264
+ * `container` as the LIVE element to decorate; a GROUP type (radio/tabs/stepper/
265
+ * rating) mounts via mountUIFXGroup with `container` as the parent and `items` in
266
+ * options. Fail closed: unknown id or a conflicting options.type throws.
248
267
  */
249
268
  export declare function mountRecipe(
250
269
  container: HTMLElement,
251
270
  id: string,
252
- options?: MountOptions & { type?: RecipeType },
253
- ): UIFXInstance;
271
+ options?: (MountOptions | GroupOptions) & { type?: RecipeType },
272
+ ): UIFXInstance | UIFXGroupInstance | DecorateInstance;
254
273
 
255
274
  // ===========================================================
256
275
  // DEFAULT EXPORT -- combined all-56 namespace
@@ -296,6 +315,7 @@ declare const UIFXAllRecipes: {
296
315
  SignalMeter: typeof SignalMeter;
297
316
  LiquidFill: typeof LiquidFill;
298
317
  PillTabs: typeof PillTabs;
318
+ SegmentedSlide: typeof SegmentedSlide;
299
319
  Stepper: typeof Stepper;
300
320
  RadioOrbit: typeof RadioOrbit;
301
321
  PasswordStrength: typeof PasswordStrength;
package/UIFXRecipes.js CHANGED
@@ -35,7 +35,7 @@
35
35
 
36
36
  import { lerp, clamp, easeOut, easeIn, easeInOut } from '@zakkster/lite-lerp';
37
37
  import { Random } from '@zakkster/lite-random';
38
- import { mountUIFX, decorateUIFX, UIType } from './UIFXController.js';
38
+ import { mountUIFX, decorateUIFX, mountUIFXGroup, UIType, GroupType } from './UIFXController.js';
39
39
 
40
40
 
41
41
  // ---------------------------------------------------------
@@ -1800,51 +1800,57 @@ export function GlitchCounter(o = {}) {
1800
1800
 
1801
1801
  /** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
1802
1802
  export function BubbleRating(o = {}) {
1803
+ // U7 GROUP (rating): N native radios (a rating is "pick 1 of N stars"); native
1804
+ // arrow keys move the selection (st.index), 0007. Bubbles fill to st.index+1;
1805
+ // onSelect pops the newly-chosen bubble. Fixed pool + preallocated sizes lane.
1803
1806
  const { seed = 42 } = o;
1804
1807
  const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8' });
1805
1808
  const rng = new Random(seed);
1806
- const R5 = ['0 / 5', '1 / 5', '2 / 5', '3 / 5', '4 / 5', '5 / 5']; // const labels
1807
1809
  const POPS = 32;
1808
1810
  const pop = [];
1809
1811
  for (let i = 0; i < POPS; i++) pop[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
1810
- const sizes = new Float32Array(5);
1811
- let lastW = 200;
1812
- function spawnPops(cx) {
1812
+ // per-bubble size lane, sized from st.count on first tick (the mount owns count,
1813
+ // 0007) -- a one-time grow absorbed by warm-up, then zero-alloc forever.
1814
+ let sizes = new Float32Array((o.items && o.items.length) || 5);
1815
+ function spawnPops(cx, cy) {
1813
1816
  for (let j = 0; j < 5; j++) {
1814
1817
  for (let i = 0; i < POPS; i++) {
1815
1818
  const p = pop[i];
1816
1819
  if (p.life <= 0) {
1817
- const a = rng.range(0, Math.PI * 2), v = rng.range(20, 50);
1818
- p.x = cx; p.y = 14; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
1820
+ const a = rng.range(0, PI2), v = rng.range(20, 50);
1821
+ p.x = cx; p.y = cy; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
1819
1822
  break;
1820
1823
  }
1821
1824
  }
1822
1825
  }
1823
1826
  }
1824
1827
  return {
1828
+ onSelect(i, st) {
1829
+ // Pop the newly selected bubble at its real centre (geometry lane).
1830
+ spawnPops(st.itemX[i] + st.itemW[i] / 2, st.h / 2);
1831
+ },
1825
1832
  tick(ctx, dt, now, st) {
1826
- lastW = st.w;
1827
- const rating = Math.round(st.val * 5);
1828
- const gap = st.w / 5;
1833
+ const filled = st.index + 1; // index 0 -> 1 filled bubble
1834
+ if (sizes.length < st.count) sizes = new Float32Array(st.count); // one-time grow (cold)
1829
1835
 
1830
- for (let i = 0; i < 5; i++) {
1831
- const active = i < rating;
1836
+ for (let i = 0; i < st.count; i++) {
1837
+ const active = i < filled;
1832
1838
  const targetSz = active ? 12 : 6;
1833
1839
  sizes[i] = lerp(sizes[i] || 6, targetSz, dt * 8);
1834
- const cx = gap * i + gap / 2, cy = st.h / 2;
1840
+ const cx = st.itemX[i] + st.itemW[i] / 2, cy = st.h / 2;
1835
1841
 
1836
1842
  // Bubble -- const color, active alpha via globalAlpha
1837
1843
  if (active) { ctx.fillStyle = P.accent; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
1838
1844
  else { ctx.fillStyle = 'rgba(255,255,255,.05)'; ctx.globalAlpha = 1; }
1839
- ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.fill();
1845
+ ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, PI2); ctx.fill();
1840
1846
  ctx.globalAlpha = 1;
1841
1847
  ctx.strokeStyle = active ? P.accent : 'rgba(255,255,255,.08)';
1842
- ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.stroke();
1848
+ ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, PI2); ctx.stroke();
1843
1849
 
1844
1850
  // Highlight
1845
1851
  if (active) {
1846
1852
  ctx.fillStyle = 'rgba(255,255,255,.15)';
1847
- ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, Math.PI * 2); ctx.fill();
1853
+ ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, PI2); ctx.fill();
1848
1854
  }
1849
1855
  }
1850
1856
 
@@ -1856,20 +1862,13 @@ export function BubbleRating(o = {}) {
1856
1862
  p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 3 * dt;
1857
1863
  if (p.life <= 0) continue;
1858
1864
  ctx.globalAlpha = p.life;
1859
- ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2); ctx.fill();
1865
+ ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, PI2); ctx.fill();
1860
1866
  }
1861
1867
  ctx.globalAlpha = 1;
1862
1868
 
1863
- label(ctx, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? P.accent : P.dim);
1869
+ label(ctx, st.labels[st.index], st.w / 2, st.h + 12, filled >= st.count ? P.accent : P.dim);
1864
1870
  if (st.focused) focusRing(ctx, st.w, st.h, 4);
1865
1871
  },
1866
- onDrag(val) {
1867
- const rating = Math.round(val * 5);
1868
- // Pop the newly activated bubble (position derived from st.w via lastW)
1869
- const gap = lastW / 5;
1870
- const cx = gap * (rating - 1) + gap / 2;
1871
- spawnPops(cx);
1872
- },
1873
1872
  };
1874
1873
  }
1875
1874
 
@@ -2180,122 +2179,154 @@ export function LiquidFill(o = {}) {
2180
2179
 
2181
2180
  /** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
2182
2181
  export function PillTabs(o = {}) {
2182
+ // U7 GROUP (tabs): N native role=tab buttons, one canvas. The recipe reads the
2183
+ // live selection (st.index) and the per-item geometry lanes (st.itemX/itemW)
2184
+ // -- selection + arrow-key roving are owned by the native tablist (0007), not
2185
+ // faked here. The indicator springs toward the selected tab's real box.
2183
2186
  const P = resolveTheme(o, { accent: '#c4b5fd', accent2: '#a78bfa', dim: '#9999b8' });
2184
2187
  const themed = !!(o.theme || o.colors);
2185
2188
  const indFill = themed ? rgbaOf(P.accent2, .12) : 'rgba(167,139,250,.12)';
2186
2189
  const indStroke = themed ? rgbaOf(P.accent2, .25) : 'rgba(167,139,250,.25)';
2187
2190
  const FONT = pickFont(o, "600 11px 'Space Grotesk',sans-serif");
2188
- let indicatorX=0, indicatorW=0, selected=0;
2189
- const labels=['Alpha','Beta','Gamma'];
2191
+ let indX = -1, indW = 0; // indX < 0 snaps to the selected tab on frame 1
2190
2192
  return {
2191
- onClick(x,y,st) {
2192
- const idx=Math.floor(x/(st.w/3));
2193
- selected=clamp(idx,0,2);
2194
- },
2195
2193
  tick(c,dt,now,st) {
2196
- const tw=st.w/3;
2197
- const tx=selected*tw, targetW=tw;
2198
- indicatorX=lerp(indicatorX,tx,dt*12);
2199
- indicatorW=lerp(indicatorW,targetW,dt*12);
2194
+ const sel = st.index;
2195
+ const tx = st.itemX[sel], tw = st.itemW[sel];
2196
+ if (indX < 0) { indX = tx; indW = tw; }
2197
+ else { indX = lerp(indX, tx, dt * 12); indW = lerp(indW, tw, dt * 12); }
2200
2198
 
2201
2199
  // Background
2202
2200
  c.fillStyle='rgba(255,255,255,.03)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
2203
2201
 
2204
- // Indicator
2202
+ // Indicator (springs to the real selected-tab box)
2205
2203
  c.fillStyle=indFill;c.strokeStyle=indStroke;c.lineWidth=1;
2206
- rr(c,indicatorX+2,2,indicatorW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
2204
+ rr(c,indX+2,2,indW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
2207
2205
 
2208
- // Labels
2206
+ // Labels (from the native tabs' own text, via st.labels)
2209
2207
  c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2210
- for(let i=0;i<3;i++){
2211
- c.fillStyle=i===selected?P.accent:P.dim;
2212
- c.fillText(labels[i],tw*i+tw/2,st.h/2);
2208
+ for(let i=0;i<st.count;i++){
2209
+ c.fillStyle=i===sel?P.accent:(i===st.hoverIndex?'#c4c4dc':P.dim);
2210
+ c.fillText(st.labels[i],st.itemX[i]+st.itemW[i]/2,st.h/2);
2213
2211
  }
2214
2212
  if(st.focused)fr(c,st.w,st.h,st.h/2);
2215
2213
  },
2216
2214
  };
2217
2215
  }
2218
2216
 
2219
- /** 7. Stepper -- +/- buttons with spring counter. */
2220
- export function Stepper(o = {}) {
2221
- const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#ff6b6b', dim: '#e2e2f0' });
2217
+ /** 6b. Segmented Slide (U7 GROUP tabs) -- a solid pill slides under the selected
2218
+ * segment. New in U7; reads st.index + the geometry lanes, zero-alloc tick. */
2219
+ export function SegmentedSlide(o = {}) {
2220
+ const P = resolveTheme(o, { accent: '#38bdf8', accent2: '#0ea5e9', dim: '#9999b8' });
2222
2221
  const themed = !!(o.theme || o.colors);
2223
- const minusFlash = themed ? rgbaOf(P.accent2, .15) : 'rgba(255,100,100,.15)';
2224
- const plusFlash = themed ? rgbaOf(P.accent, .15) : 'rgba(110,231,182,.15)';
2225
- const FONT = pickFont(o, "700 18px 'JetBrains Mono',monospace");
2226
- let count=0,displayCount=0,flashDir=0,flashTimer=0;
2222
+ const pill = themed ? rgbaOf(P.accent, .92) : 'rgba(56,189,248,.92)';
2223
+ const FONT = pickFont(o, "600 11px 'Space Grotesk',sans-serif");
2224
+ let sx = -1, sw = 0;
2227
2225
  return {
2228
- onClick(x,y,st) {
2229
- if(x<st.w*0.33){count=Math.max(0,count-1);flashDir=-1;}
2230
- else if(x>st.w*0.66){count++;flashDir=1;}
2231
- flashTimer=0.3;
2226
+ tick(c,dt,now,st) {
2227
+ const sel = st.index;
2228
+ const tx = st.itemX[sel], tw = st.itemW[sel];
2229
+ if (sx < 0) { sx = tx; sw = tw; }
2230
+ else { sx = lerp(sx, tx, dt * 14); sw = lerp(sw, tw, dt * 14); }
2231
+
2232
+ c.fillStyle='rgba(255,255,255,.05)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
2233
+ c.fillStyle=pill;rr(c,sx+3,3,sw-6,st.h-6,(st.h-6)/2);c.fill();
2234
+
2235
+ c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2236
+ for(let i=0;i<st.count;i++){
2237
+ c.fillStyle=i===sel?'#0b0b12':(i===st.hoverIndex?'#e2e2f0':P.dim);
2238
+ c.fillText(st.labels[i],st.itemX[i]+st.itemW[i]/2,st.h/2);
2239
+ }
2240
+ if(st.focused)fr(c,st.w,st.h,st.h/2);
2232
2241
  },
2242
+ };
2243
+ }
2244
+
2245
+ /** 7. Stepper (U7 GROUP: stepper) -- one native <input type=number> spinbutton;
2246
+ * ArrowUp/Down + typing drive st.index natively (0007). The recipe draws the
2247
+ * current step label + a pip row (one per step, filled to the selection) and
2248
+ * flashes on change via onSelect. Zero-alloc tick. */
2249
+ export function Stepper(o = {}) {
2250
+ const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#ff6b6b', dim: '#e2e2f0' });
2251
+ const FONT = pickFont(o, "700 20px 'JetBrains Mono',monospace");
2252
+ let flash = 0; // brief highlight after a step change (onSelect)
2253
+ return {
2254
+ onSelect() { flash = 0.35; },
2233
2255
  tick(c,dt,now,st) {
2234
- displayCount=lerp(displayCount,count,dt*10);
2235
- flashTimer=Math.max(0,flashTimer-dt);
2256
+ flash = flash > 0 ? flash - dt : 0;
2236
2257
 
2237
2258
  // Background
2238
2259
  c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,10);c.fill();
2239
2260
 
2240
- // Minus zone
2241
- const third=st.w/3;
2242
- c.fillStyle=flashDir===-1&&flashTimer>0?minusFlash:'rgba(255,255,255,.03)';
2243
- rr(c,2,2,third-4,st.h-4,8);c.fill();
2244
- c.fillStyle=P.accent2;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2245
- c.fillText('\u2212',third/2,st.h/2);
2261
+ // Up/Down chevron affordances (native spinbutton owns the behaviour)
2262
+ c.strokeStyle=P.accent;c.lineWidth=2;c.globalAlpha=0.7;
2263
+ const rx=st.w-14;
2264
+ c.beginPath();c.moveTo(rx-4,12);c.lineTo(rx,8);c.lineTo(rx+4,12);c.stroke();
2265
+ c.strokeStyle=P.accent2;
2266
+ c.beginPath();c.moveTo(rx-4,st.h-12);c.lineTo(rx,st.h-8);c.lineTo(rx+4,st.h-12);c.stroke();
2267
+ c.globalAlpha=1;
2246
2268
 
2247
- // Plus zone
2248
- c.fillStyle=flashDir===1&&flashTimer>0?plusFlash:'rgba(255,255,255,.03)';
2249
- rr(c,third*2+2,2,third-4,st.h-4,8);c.fill();
2250
- c.fillStyle=P.accent;c.fillText('+',third*2+third/2,st.h/2);
2269
+ // Step pips (one per step, filled up to the current index)
2270
+ for(let i=0;i<st.count;i++){
2271
+ const cx=st.itemX[i]+st.itemW[i]/2;
2272
+ c.globalAlpha=i===st.index?1:0.3;
2273
+ c.fillStyle=i<=st.index?P.accent:'rgba(255,255,255,.25)';
2274
+ c.beginPath();c.arc(cx,st.h-7,2.5,0,PI2);c.fill();
2275
+ }
2276
+ c.globalAlpha=1;
2251
2277
 
2252
- // Counter
2253
- c.fillStyle=P.dim;c.font="700 20px 'JetBrains Mono',monospace";
2254
- c.fillText(Math.round(displayCount),st.w/2,st.h/2);
2278
+ // Current step label, brightening on change
2279
+ c.fillStyle=flash>0?P.accent:P.dim;c.font=FONT;
2280
+ c.textAlign='center';c.textBaseline='middle';
2281
+ c.fillText(st.labels[st.index],st.w/2,st.h/2-3);
2255
2282
 
2256
2283
  if(st.focused)fr(c,st.w,st.h,10);
2257
2284
  },
2258
2285
  };
2259
2286
  }
2260
2287
 
2261
- /** 8. Radio Orbit -- 4 options arranged in a circle. Slider picks one. */
2288
+ /** 8. Radio Orbit (U7 GROUP: radio) -- N options in a circle over N native
2289
+ * radios; native arrow keys move the selection (st.index), 0007. The recipe
2290
+ * reads st.count/st.index/st.labels; per-node glow is a preallocated lane. */
2262
2291
  export function RadioOrbit(o = {}) {
2263
- let selectedGlow=new Float32Array(4);
2264
- const names=['A','B','C','D'];
2265
- // `colors` is the 4-node palette; `theme` seeds it. Normalised to length 4 so
2266
- // the body's colors[i] stays valid for any override.
2292
+ // per-node glow lane, sized from st.count on first tick (the mount owns count,
2293
+ // 0007) -- a one-time grow absorbed by warm-up, then zero-alloc forever.
2294
+ let glow = new Float32Array((o.items && o.items.length) || 4);
2295
+ // palette: `colors` is it; `theme` seeds it; else the default. Indexed base[i%bn]
2296
+ // so a node colour stays valid for any node count / override (no fixed array).
2267
2297
  const base = o.colors ? o.colors
2268
2298
  : (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#ff6b6b','#fbbf24','#6ee7b6','#38bdf8']);
2269
- const colors = base.length >= 4 ? base : [base[0], base[1 % base.length], base[2 % base.length], base[3 % base.length]];
2270
- const OPTS=['OPTION A','OPTION B','OPTION C','OPTION D']; // const (was 'OPTION '+name concat)
2299
+ const bn = base.length;
2271
2300
  return {
2272
2301
  tick(c,dt,now,st) {
2273
- const sel=Math.round(st.val*3);
2302
+ const sel=st.index, n=st.count;
2303
+ if (glow.length < n) glow = new Float32Array(n); // one-time grow (cold)
2274
2304
  const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-12;
2275
2305
 
2276
2306
  // Center
2277
2307
  c.fillStyle='rgba(255,255,255,.03)';c.beginPath();c.arc(cx,cy,8,0,PI2);c.fill();
2278
2308
 
2279
- for(let i=0;i<4;i++){
2280
- const a=-Math.PI/2+i*Math.PI/2;
2309
+ for(let i=0;i<n;i++){
2310
+ const a=-Math.PI/2+i*(PI2/n);
2281
2311
  const ox=cx+Math.cos(a)*R, oy=cy+Math.sin(a)*R;
2282
2312
  const active=i===sel;
2283
- selectedGlow[i]=lerp(selectedGlow[i],active?1:0,dt*10);
2313
+ const col=base[i%bn];
2314
+ glow[i]=lerp(glow[i],active?1:(i===st.hoverIndex?0.4:0),dt*10);
2284
2315
 
2285
2316
  // Orbit line -- const white, alpha via globalAlpha
2286
- c.strokeStyle='#ffffff';c.globalAlpha=.03+selectedGlow[i]*.05;c.lineWidth=1;
2317
+ c.strokeStyle='#ffffff';c.globalAlpha=.03+glow[i]*.05;c.lineWidth=1;
2287
2318
  c.beginPath();c.moveTo(cx,cy);c.lineTo(ox,oy);c.stroke();c.globalAlpha=1;
2288
2319
 
2289
2320
  // Node
2290
- const sz=6+selectedGlow[i]*4;
2291
- c.fillStyle=active?colors[i]:'rgba(255,255,255,.06)';
2321
+ const sz=6+glow[i]*4;
2322
+ c.fillStyle=active?col:'rgba(255,255,255,.06)';
2292
2323
  c.beginPath();c.arc(ox,oy,sz,0,PI2);c.fill();
2293
- if(active){c.strokeStyle=colors[i];c.lineWidth=1;c.beginPath();c.arc(ox,oy,sz+3,0,PI2);c.stroke()}
2324
+ if(active){c.strokeStyle=col;c.lineWidth=1;c.beginPath();c.arc(ox,oy,sz+3,0,PI2);c.stroke()}
2294
2325
 
2295
2326
  c.fillStyle=active?'#fff':'#9999b8';c.font="600 9px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
2296
- c.fillText(names[i],ox,oy);
2327
+ c.fillText(st.labels[i],ox,oy);
2297
2328
  }
2298
- lbl(c,OPTS[sel],cx,st.h+10,colors[sel]);
2329
+ lbl(c,st.labels[sel],cx,st.h+10,base[sel%bn]);
2299
2330
  if(st.focused)fr(c,st.w,st.h,4);
2300
2331
  },
2301
2332
  };
@@ -3021,6 +3052,14 @@ export const UIFXRecipes5 = {
3021
3052
  FocusHalo, ErrorShake, SuccessBloom,
3022
3053
  };
3023
3054
 
3055
+ // U7 additions -- grouped controls (N native elements + one canvas, mounted via
3056
+ // mountUIFXGroup). PillTabs/Stepper/RadioOrbit/BubbleRating re-homed from their
3057
+ // vol.3 single-element fakes to real group types (see RECIPE_META); SegmentedSlide
3058
+ // is new. Reachable via RECIPES / RECIPE_META and their named exports regardless.
3059
+ export const UIFXRecipes6 = {
3060
+ SegmentedSlide,
3061
+ };
3062
+
3024
3063
 
3025
3064
  // ===========================================================
3026
3065
  // DEFAULT EXPORT -- combined all-53 namespace
@@ -3066,6 +3105,7 @@ export default {
3066
3105
  SignalMeter,
3067
3106
  LiquidFill,
3068
3107
  PillTabs,
3108
+ SegmentedSlide,
3069
3109
  Stepper,
3070
3110
  RadioOrbit,
3071
3111
  PasswordStrength,
@@ -3135,6 +3175,7 @@ export const RECIPES = Object.assign(Object.create(null), {
3135
3175
  signalMeter: SignalMeter,
3136
3176
  liquidFill: LiquidFill,
3137
3177
  pillTabs: PillTabs,
3178
+ segmentedSlide: SegmentedSlide,
3138
3179
  stepper: Stepper,
3139
3180
  radioOrbit: RadioOrbit,
3140
3181
  passwordStrength: PasswordStrength,
@@ -3198,16 +3239,17 @@ export const RECIPE_META = [
3198
3239
  { id: 'indeterminateScan', name: 'Indeterminate Scan', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3199
3240
  { id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
3200
3241
  { id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
3201
- { id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: true, motionSafe: false },
3242
+ { id: 'bubbleRating', name: 'Bubble Rating', type: 'rating', family: 'Rating', themeable: true, motionSafe: false },
3202
3243
  { id: 'volumeKnob', name: 'Volume Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3203
3244
  { id: 'compassKnob', name: 'Compass Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3204
3245
  { id: 'ringProgress', name: 'Ring Progress', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3205
3246
  { id: 'batteryGauge', name: 'Battery Gauge', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3206
3247
  { id: 'signalMeter', name: 'Signal Meter', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3207
3248
  { id: 'liquidFill', name: 'Liquid Fill', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3208
- { id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3209
- { id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3210
- { id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
3249
+ { id: 'pillTabs', name: 'Pill Tabs', type: 'tabs', family: 'Controls', themeable: true, motionSafe: false },
3250
+ { id: 'segmentedSlide', name: 'Segmented Slide', type: 'tabs', family: 'Controls', themeable: true, motionSafe: false },
3251
+ { id: 'stepper', name: 'Stepper', type: 'stepper', family: 'Controls', themeable: true, motionSafe: false },
3252
+ { id: 'radioOrbit', name: 'Radio Orbit', type: 'radio', family: 'Controls', themeable: true, motionSafe: false },
3211
3253
  { id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: true },
3212
3254
  { id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
3213
3255
  { id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
@@ -3228,14 +3270,15 @@ export const RECIPE_META = [
3228
3270
  /** Names of every built-in recipe (the keys of RECIPES at load time). */
3229
3271
  export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
3230
3272
 
3231
- // The valid recipe/mount types, taken from the controller's UIType so the
3232
- // registry's fail-closed check and the controller's mount guard are one source
3233
- // of truth (they cannot drift as U4 adds types). Built once at load (cold).
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']);
3273
+ // The valid recipe/mount types, taken from the controller's UIType + GroupType so
3274
+ // the registry's fail-closed check and the controller's mount guards are one
3275
+ // source of truth (they cannot drift as U4/U7 add types). Built once at load
3276
+ // (cold). Beyond the UITypes there are two non-UIType routing tags: 'decorate'
3277
+ // (U4b, mounted AROUND a live element by decorateUIFX) and the four GroupTypes
3278
+ // (U7: radio/tabs/stepper/rating, mounted as N native elements + one canvas by
3279
+ // mountUIFXGroup). mountRecipe routes each family to the right mount (see below).
3280
+ const _GROUP_TYPES = new Set(Object.values(GroupType));
3281
+ const VALID_META_TYPES = new Set([...Object.values(UIType), 'decorate', ..._GROUP_TYPES]);
3239
3282
 
3240
3283
  /**
3241
3284
  * Register a custom recipe, or override a built-in. Instantly usable via
@@ -3265,7 +3308,7 @@ export function registerRecipe(id, factory, meta) {
3265
3308
  // valid set is UIType (VALID_META_TYPES), so registry + controller never
3266
3309
  // disagree about what a type is.
3267
3310
  if (!VALID_META_TYPES.has(type)) {
3268
- throw new TypeError('registerRecipe: type must be one of "button", "toggle", "slider", "checkbox", "progress", "knob"');
3311
+ throw new TypeError('registerRecipe: type must be a UIType ("button", "toggle", "slider", "checkbox", "progress", "knob"), "decorate", or a GroupType ("radio", "tabs", "stepper", "rating")');
3269
3312
  }
3270
3313
 
3271
3314
  RECIPES[id] = factory;
@@ -3367,10 +3410,18 @@ export function mountRecipe(container, id, options) {
3367
3410
  mountOptions = {};
3368
3411
  for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
3369
3412
  }
3413
+ // A grouped control (U7) is N native elements + one canvas, so it routes to
3414
+ // mountUIFXGroup with `container` as the parent and the group type selecting
3415
+ // the native structure (radios / tabs / spinbutton). It needs `items` in
3416
+ // options (fail closed inside mountUIFXGroup). mountUIFX/decorateUIFX reject a
3417
+ // group type via their own guards, so the three mount paths cannot cross.
3418
+ if (_GROUP_TYPES.has(type)) {
3419
+ return mountUIFXGroup(container, type, factory, mountOptions);
3420
+ }
3370
3421
  // A decoration is mounted AROUND a live element (no native element created),
3371
3422
  // so it routes to decorateUIFX with `container` as the host element. Every
3372
3423
  // other type is a hijack mount. mountUIFX keeps rejecting 'decorate' via its
3373
- // own _KNOWN_TYPES guard, so the two paths cannot cross.
3424
+ // own _KNOWN_TYPES guard, so the paths cannot cross.
3374
3425
  if (type === 'decorate') {
3375
3426
  return decorateUIFX(container, factory, mountOptions);
3376
3427
  }