@zakkster/lite-ui-fx 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,96 @@ All notable changes to `@zakkster/lite-ui-fx` are documented here.
5
5
  The format follows Keep a Changelog; this project adheres to Semantic
6
6
  Versioning.
7
7
 
8
+ ## [1.9.1] -- 2026-09-07
9
+
10
+ Patch: a rendering fix for the ReactionPicker recipe.
11
+
12
+ ### Added
13
+
14
+ none
15
+
16
+ ### Changed
17
+
18
+ - demo (not shipped in the package): the `passwordStrength` showcase input is
19
+ seeded with a weak value (`abc`) instead of an already-maximal one, so typing
20
+ visibly moves the strength meter. The recipe itself was unchanged and correct.
21
+
22
+ ### Fixed
23
+
24
+ - ReactionPicker: the emoji faces inherited the circle's translucent `fillStyle`
25
+ (0.04 alpha at rest), so a colour glyph rendered at 4% opacity and the faces
26
+ were invisible until hover. An opaque fill (`colors[i]`) is now set before each
27
+ glyph; all five faces render at rest, and hover still inflates and highlights
28
+ the selected one. One precomputed array read per glyph -- the frame path stays
29
+ zero-allocation (torture alloc = 0.8759765625 B/op, unchanged).
30
+
31
+ ### Removed
32
+
33
+ none
34
+
35
+ ## [1.9.0] -- 2026-09-07
36
+
37
+ Grouped controls (roadmap U7): a third mount mode for a control that is N native
38
+ elements sharing one canvas and one recipe. Additive -- `mountUIFX` and
39
+ `decorateUIFX` are byte-identical (the `UIFXController.js` diff is 524 insertions,
40
+ 0 deletions), the single-element API and the eight-hook recipe contract are
41
+ unchanged, so this is a minor. See decisions/0007.
42
+
43
+ ### Added
44
+
45
+ - `mountUIFXGroup(container, groupType, recipeFactory, options)` -- the group
46
+ mount, returning `{ els, canvas, wrapper, state, index, setIndex(i), tick, destroy }`.
47
+ `setIndex(i)` selects programmatically, updating the native element(s) and
48
+ `state.index` and firing `onSelect` once, without stealing focus.
49
+ - `GroupType` { RADIO, TABS, STEPPER, RATING }. RADIO/RATING build a
50
+ `<fieldset role=radiogroup>` of native radios (native roving selection); TABS a
51
+ `<div role=tablist>` of `<button role=tab>` with a hand-written APG roving
52
+ tabindex (Left/Right/Up/Down + Home/End); STEPPER one `<input type=number>`
53
+ spinbutton.
54
+ - `onSelect(index, state)` -- the ninth, group-only recipe hook, fired exactly
55
+ once per selection change (rejected by `mountUIFX`/`decorateUIFX`). Group state
56
+ is a superset of the scalar state plus `index`, `count`, `hoverIndex`, `labels`,
57
+ and the `itemX`/`itemY`/`itemW`/`itemH` Float32Array geometry lanes (read by
58
+ index, zero per-frame allocation).
59
+ - `SegmentedSlide` (a TABS recipe) -- 57 built-in recipes total. New `UIFXRecipes6`
60
+ barrel; the four group types added to `RECIPE_META`, `VALID_META_TYPES`, and
61
+ `mountRecipe` routing (a group `META.type` routes to `mountUIFXGroup`, and needs
62
+ `items`).
63
+ - `test/group.test.mjs` (31 cases): native structure + ARIA per pattern, the APG
64
+ keyboard walk, onSelect-exactly-once, `setIndex`, the scalar-state superset,
65
+ the three clock modes, and fail-closed validation. Torture gains group coverage
66
+ in t0/t1/t2/t3 (with a per-item allocation control that must fail the gate) and
67
+ t5 (20 groups of 5 on one shared ticker). `npm test` is 238 cases across 27
68
+ suites; the torture gate holds at `alloc=0.8759765625 B/op`, 0 major GCs.
69
+ - TypeScript declarations for the group surface (`GroupType`, `mountUIFXGroup`,
70
+ `UIFXGroupState`, `UIFXGroupRecipe`, `GroupOptions`, `UIFXGroupInstance`).
71
+
72
+ ### Changed
73
+
74
+ - `PillTabs`, `Stepper`, `RadioOrbit`, and `BubbleRating` re-home from their vol.3
75
+ single-element fakes to real group types (`RECIPE_META.type` slider/button ->
76
+ radio/tabs/stepper/rating); their bodies read group state (`state.index`/`count`
77
+ and the geometry lanes) instead of a faked `state.val`. `mountRecipe(container,
78
+ id, { items })` routes them by `META.type`. RadioOrbit and BubbleRating size
79
+ their per-item lane from `state.count` -- a one-time grow, warm-up-absorbed.
80
+ - `README.md` and `llms.txt` document three mount modes, 57 recipes, and the group
81
+ API + state fields. Measured sizes updated: controller ~7.1 KB min+gzip (its
82
+ three deps external), full catalog ~25 KB. `demo/index.html` renders the group
83
+ recipes and cycles their selection (`#profile` reports `violationCount 0`).
84
+
85
+ ### Fixed
86
+
87
+ - The four re-homed controls now carry correct native selection and keyboard: a
88
+ radio group and a tablist are N elements, so arrow-key roving is the browser's
89
+ own (radio/rating/stepper) or an APG-correct hand-written roving (tabs). The
90
+ single-element fakes had the wrong arrow-key behaviour.
91
+
92
+ ### Removed
93
+
94
+ - none. The single-element fake mount of the four re-homed recipes is superseded
95
+ by their group mount via `mountRecipe`/`mountUIFXGroup`; no public export or
96
+ option was removed.
97
+
8
98
  ## [1.8.0] -- 2026-09-07
9
99
 
10
100
  Documentation and demo (roadmap U6). No API, recipe, or behaviour change: the
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-ui-fx
2
2
 
3
- > Canvas microinteractions on real native controls. A DPR-aware canvas is hijacked over a hidden native element -- or decorated around a live one -- and painted by a pluggable, zero-GC **recipe**. The native element owns focus, keyboard, and pointer events; the canvas owns the visuals. 56 built-in recipes behind a tree-shakeable registry, one option convention for theming, one clock you can hand it, and reduced-motion built in.
3
+ > Canvas microinteractions on real native controls. A DPR-aware canvas is hijacked over a hidden native element -- decorated around a live one -- or shared across a group of them -- and painted by a pluggable, zero-GC **recipe**. The native element owns focus, keyboard, and pointer events; the canvas owns the visuals. 57 built-in recipes behind a tree-shakeable registry, one option convention for theming, one clock you can hand it, and reduced-motion built in.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-ui-fx.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
6
6
  ![Zero-GC](https://img.shields.io/badge/Zero--GC-Recipes-00C853?style=for-the-badge&logo=leaf&logoColor=white)
@@ -45,10 +45,11 @@ Three runtime dependencies, all zero-GC (`@zakkster/lite-ticker`, `lite-lerp`, `
45
45
 
46
46
  - [Why this exists](#why-this-exists)
47
47
  - [What you get](#what-you-get)
48
- - [Two mount modes and the recipe contract](#two-mount-modes-and-the-recipe-contract)
48
+ - [Three mount modes and the recipe contract](#three-mount-modes-and-the-recipe-contract)
49
49
  - [API reference](#api-reference)
50
50
  - [mountUIFX](#mountuifxcontainer-type-recipefactory-options)
51
51
  - [decorateUIFX](#decorateuifxel-recipefactory-options)
52
+ - [mountUIFXGroup](#mountuifxgroupcontainer-grouptype-recipefactory-options)
52
53
  - [The recipe registry](#the-recipe-registry)
53
54
  - [Constants: UITypes, state, META](#constants-uitypes-state-meta)
54
55
  - [Host clock and reduced motion](#host-clock-and-reduced-motion)
@@ -77,18 +78,19 @@ The alternative is a hand-rolled canvas threshold loop (no a11y, allocates freel
77
78
 
78
79
  - **`mountUIFX(container, type, recipeFactory, options?)`** -- the hijack mount. Creates a real native element (invisible, accessible) under a DPR-scaled canvas and drives the recipe. Six element types: `TOGGLE`, `BUTTON`, `SLIDER`, `CHECKBOX`, `PROGRESS`, `KNOB`.
79
80
  - **`decorateUIFX(el, recipeFactory, options?)`** -- the decorate mount. Places a canvas *around* an existing visible element (a live `<input>`), reading `state.text`/`state.valid` from the host's own events. The host is byte-identical before and after; `destroy()` removes only the overlay.
80
- - **56 built-in recipes** on the `./recipes` subpath, versioned, typed, and tree-shakeable. With `sideEffects: false`, importing one recipe drops the other 55. Families: Toggles (7), Buttons (9), Sliders (7), Knobs (2), Progress (4), Checkboxes (4), Loaders (2), Counters (2), Rating (1), Controls (3), Indicators (3), Mood (3), Feedback (3), Fun (3), Form decorations (3).
81
+ - **`mountUIFXGroup(container, groupType, recipeFactory, options)`** -- the group mount. N native elements + one canvas + one recipe: `RADIO`/`RATING` (a fieldset radiogroup), `TABS` (an APG tablist with roving tabindex), `STEPPER` (a spinbutton). The recipe reads `state.index`/`state.count`; selection and keyboard are the native elements' own.
82
+ - **57 built-in recipes** on the `./recipes` subpath, versioned, typed, and tree-shakeable. With `sideEffects: false`, importing one recipe drops the other 56. Families: Toggles (7), Buttons (9), Sliders (7), Knobs (2), Progress (4), Checkboxes (4), Loaders (2), Counters (2), Rating (1), Controls (4), Indicators (3), Mood (3), Feedback (3), Fun (3), Form decorations (3).
81
83
  - **A registry for data-driven UIs** -- `RECIPES` (id -> factory, null-prototype), `RECIPE_META` (`{ id, name, type, family, themeable, motionSafe }`), `RECIPE_NAMES`, `registerRecipe(id, factory, meta)`, and `mountRecipe(container, id, options?)` which resolves the id fail-closed (did-you-mean on a typo) and mounts it as its declared type.
82
- - **One option convention for theming** -- `{ colors, theme: { light, mid, dark }, text, font }` honoured by all 56 recipes, resolved once in `init` so a themed mount stays zero-GC and a bare mount is byte-identical to pre-theming.
84
+ - **One option convention for theming** -- `{ colors, theme: { light, mid, dark }, text, font }` honoured by all 57 recipes, resolved once in `init` so a themed mount stays zero-GC and a bare mount is byte-identical to pre-theming.
83
85
  - **Host integration** -- ride a caller-supplied `lite-ticker` (`{ ticker }`), drive frames by hand (`{ driven: true }` + `instance.tick(dtMs)`), or take the shared ref-counted ticker by default. Plus `state.reducedMotion` (matchMedia-watched) and `state.budget` (0..1 frame budget).
84
86
  - **Full TypeScript declarations** for both entry points, and a written recipe guide ([`UIFX-RECIPE-GUIDE.md`](UIFX-RECIPE-GUIDE.md)) shipped in the package.
85
87
 
86
88
  ---
87
89
 
88
- ## Two mount modes and the recipe contract
90
+ ## Three mount modes and the recipe contract
89
91
 
90
92
  <details>
91
- <summary>How hijack and decorate differ, and the eight-hook recipe interface both share.</summary>
93
+ <summary>How hijack, decorate, and group differ, and the recipe interface they share.</summary>
92
94
 
93
95
  ### Hijack (`mountUIFX`)
94
96
 
@@ -105,9 +107,13 @@ The native element is the source of truth. Every visual reads `state`; `state` r
105
107
 
106
108
  No native element is created and nothing is reparented. The canvas is a sibling positioned from the host's offset box and removed on `destroy()`, so the host is byte-identical before and after. `state` is wired from the host's own events; for a form control, `state.text` and `state.valid` mirror `el.value` and `el.validity` (read at event time, never per frame). This is the honest home for a decoration over a real input -- a visible text field cannot be `opacity:0`.
107
109
 
108
- ### The recipe contract (both modes)
110
+ ### Group (`mountUIFXGroup`)
109
111
 
110
- A recipe is a factory returning up to eight hooks. Only `tick` is required; it is the one HOT function.
112
+ A grouped control is *N* native elements sharing one canvas and one recipe -- radios in a `<fieldset>`, tabs in a `<div role="tablist">`, or a `<input type="number">` spinbutton. Selection and keyboard belong to the native elements (radio/rating roving is the browser's own; the tablist gets a hand-written APG roving tabindex with arrows and Home/End); the recipe paints from `state.index`, `state.count`, and the per-item geometry lanes (`state.itemX/itemY/itemW/itemH`, one entry per item). It adds one hook -- `onSelect(index, state)`, fired exactly once per selection change -- and `setIndex(i)` for programmatic selection.
113
+
114
+ ### The recipe contract (all three modes)
115
+
116
+ A recipe is a factory returning up to eight hooks (nine for a group -- the extra is `onSelect`). Only `tick` is required; it is the one HOT function.
111
117
 
112
118
  ```js
113
119
  export function MyRecipe() {
@@ -177,6 +183,28 @@ deco.destroy(); // removes ONLY the overlay; the input is untouched
177
183
 
178
184
  Built-in decorate recipes: `FocusHalo`, `ErrorShake`, `SuccessBloom`, `PasswordStrength`, `TypewriterField` (`RECIPE_META.type === 'decorate'`, so `mountRecipe(el, id)` routes them here automatically).
179
185
 
186
+ ### `mountUIFXGroup(container, groupType, recipeFactory, options)`
187
+
188
+ The third mount mode: a **grouped control** -- N native elements + one canvas + one recipe. `GroupType.RADIO`/`RATING` build a `<fieldset role="radiogroup">` of N radios (native roving); `GroupType.TABS` builds a `<div role="tablist">` of N `<button role="tab">` with hand-written APG roving tabindex (arrows, Home/End); `GroupType.STEPPER` is one `<input type="number">` spinbutton. The native elements own selection and keyboard; the recipe reads `state.index`, `state.count`, and the per-item geometry lanes (`state.itemX/itemY/itemW/itemH`).
189
+
190
+ `options`: `items` (`string[]`, **required**, >=2 labels -- its length is the item/step count), `index` (integer initial selection, default 0), plus `label`, `width`, `height`, `padding`, `disabled`, `seed`, `colors`, `theme`, `text`, `font`, `ticker`, `driven`. The hijack-only keys (`value`/`checked`/`knobMode`/`announce`) throw -- a group selects by `index`, not a float `value`.
191
+
192
+ Returns `{ els, canvas, wrapper, state, index, setIndex(i), tick(dtMs), destroy() }`. `setIndex(i)` selects item `i` programmatically -- it updates the native element(s) and `state.index` and fires `onSelect` exactly once, without stealing focus. A group recipe may add `onSelect(index, state)` -- the ninth, group-only hook (`mountUIFX`/`decorateUIFX` reject it).
193
+
194
+ ```js
195
+ import { mountUIFXGroup, GroupType } from '@zakkster/lite-ui-fx';
196
+ import { PillTabs } from '@zakkster/lite-ui-fx/recipes';
197
+
198
+ const tabs = mountUIFXGroup(document.getElementById('view-tabs'), GroupType.TABS, PillTabs, {
199
+ items: ['Overview', 'Activity', 'Settings'],
200
+ index: 0,
201
+ });
202
+ tabs.setIndex(2); // selects "Settings"; fires onSelect once, no focus steal
203
+ tabs.destroy();
204
+ ```
205
+
206
+ Built-in group recipes: `PillTabs`, `SegmentedSlide` (`TABS`), `RadioOrbit` (`RADIO`), `Stepper` (`STEPPER`), `BubbleRating` (`RATING`). The first four re-home from their vol.3 single-element fakes to real groups (so their arrow-key selection is finally correct); `mountRecipe(container, id, { items })` routes them here by `META.type`.
207
+
180
208
  ### The recipe registry
181
209
 
182
210
  ```js
@@ -187,7 +215,7 @@ const toggles = RECIPE_META.filter((m) => m.family === 'Toggles');
187
215
  mountRecipe(document.getElementById('picker'), 'sparkSlider', { value: 0.5 });
188
216
  ```
189
217
 
190
- `mountRecipe` resolves the id fail-closed (an unknown id throws with a did-you-mean over `RECIPE_NAMES`), asserts `META.type`, and routes to `mountUIFX` or `decorateUIFX` accordingly. `registerRecipe(id, factory, meta)` adds or overrides a recipe and merges its META in place, so a live picker built off `RECIPE_META` updates itself.
218
+ `mountRecipe` resolves the id fail-closed (an unknown id throws with a did-you-mean over `RECIPE_NAMES`), asserts `META.type`, and routes to `mountUIFX`, `decorateUIFX`, or `mountUIFXGroup` (a group type needs `items`) accordingly. `registerRecipe(id, factory, meta)` adds or overrides a recipe and merges its META in place, so a live picker built off `RECIPE_META` updates itself.
191
219
 
192
220
  ### Constants: UITypes, state, META
193
221
 
@@ -200,6 +228,7 @@ mountRecipe(document.getElementById('picker'), 'sparkSlider', { value: 0.5 });
200
228
  | `PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` (0..1) |
201
229
  | `KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0..1) |
202
230
  | *(decorate)* | none -- canvas around a live host | host events -> state | `state.focused`, `state.text`, `state.valid` |
231
+ | *(group)* `GroupType.{RADIO,TABS,STEPPER,RATING}` | N native elements (fieldset/tablist/spinbutton) | `onSelect(index, state)` | `state.index`, `state.count` |
203
232
 
204
233
  The `state` object passed to `tick(ctx, dt, now, state)` every frame:
205
234
 
@@ -213,8 +242,10 @@ The `state` object passed to `tick(ctx, dt, now, state)` every frame:
213
242
  | `reducedMotion` | `boolean` | user prefers reduced motion (matchMedia, watched) |
214
243
  | `budget` | `number` | 0..1 frame budget, 1 at ~60fps, lower as frames lengthen |
215
244
  | `text` / `valid` | `string` / `boolean` | decorate mode only: host value and validity |
245
+ | `index` / `count` / `hoverIndex` | `number` | group mode only: selection, item count, hovered item (-1 none) |
246
+ | `labels` / `itemX` / `itemY` / `itemW` / `itemH` | `string[]` / `Float32Array` | group mode only: item labels + per-item geometry lanes (read by index) |
216
247
 
217
- `RECIPE_META` rows: `{ id, name, type, family, themeable, motionSafe }`. `themeable` is true for all 56; `motionSafe` is true for exactly the recipes that ship a calm reduced-motion path (6 today: SwarmToggle plus the five decorate recipes) and honestly false for the rest.
248
+ `RECIPE_META` rows: `{ id, name, type, family, themeable, motionSafe }`. `themeable` is true for all 57; `motionSafe` is true for exactly the recipes that ship a calm reduced-motion path (6 today: SwarmToggle plus the five decorate recipes) and honestly false for the rest.
218
249
 
219
250
  ---
220
251
 
@@ -249,8 +280,8 @@ Passing both `ticker` and `driven`, a non-boolean `driven`, or a `ticker` withou
249
280
  One clock, a shared theme, several components -- the shape a game or a themed dashboard actually uses:
250
281
 
251
282
  ```js
252
- import { mountUIFX, decorateUIFX, UIType } from '@zakkster/lite-ui-fx';
253
- import { SwarmToggle, SparkSlider, PasswordStrength } from '@zakkster/lite-ui-fx/recipes';
283
+ import { mountUIFX, decorateUIFX, mountUIFXGroup, UIType, GroupType } from '@zakkster/lite-ui-fx';
284
+ import { SwarmToggle, SparkSlider, PasswordStrength, PillTabs } from '@zakkster/lite-ui-fx/recipes';
254
285
  import { Ticker } from '@zakkster/lite-ticker';
255
286
 
256
287
  // 1. One clock the host owns and controls (pause it, scale it, share it).
@@ -264,11 +295,14 @@ const theme = { light: '#a78bfa', mid: '#7c3aed', dark: '#4c1d95' };
264
295
  const mute = mountUIFX(document.getElementById('mute'), UIType.TOGGLE, SwarmToggle, { ticker: clock, theme });
265
296
  const volume = mountUIFX(document.getElementById('vol'), UIType.SLIDER, SparkSlider, { ticker: clock, theme, value: 0.6 });
266
297
  const pw = decorateUIFX(document.querySelector('#password'), PasswordStrength, { ticker: clock, theme });
298
+ // a grouped control on the SAME clock + theme (all three mount modes, one pipeline)
299
+ const tabs = mountUIFXGroup(document.getElementById('tabs'), GroupType.TABS, PillTabs, { ticker: clock, theme, items: ['Sound', 'Video', 'About'] });
267
300
 
268
301
  // 4. One teardown per component; the clock is yours to keep or stop.
269
302
  mute.destroy();
270
303
  volume.destroy();
271
304
  pw.destroy();
305
+ tabs.destroy();
272
306
  ```
273
307
 
274
308
  Every component rides `clock`; destroying one never touches the others or the clock. `colors` (a `string[]`) overrides `theme` when both are present. A bare mount -- no `theme`, no `colors` -- is byte-identical to the pre-theming rendering, so adopting a theme is opt-in and free when you skip it.
@@ -291,13 +325,13 @@ Everything a recipe needs is resolved in `init` (cold): the palette and any ramp
291
325
  | Pointer move / drag | **0** | arithmetic only; the bounding rect is cached on pointer-enter, not read per move |
292
326
  | `init` / theme resolve | once, cold | palette, ramps, gradients, pools -- then read-only in the loop |
293
327
 
294
- The `t3-frame-alloc` torture tier asserts, per recipe, **zero distinct `fillStyle` string allocations per frame at steady state** and **zero gradient constructions after `init`** -- in both a default and a themed mount, across all 56 recipes (a template-literal color fails this even when GC happens to hide it). The full harness (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`) proves **0 retained bytes, 0 major GCs, and ~0.86 B/op** across the whole mount / interact / destroy loop under `--expose-gc`:
328
+ The `t3-frame-alloc` torture tier asserts, per recipe, **zero distinct `fillStyle` string allocations per frame at steady state** and **zero gradient constructions after `init`** -- in both a default and a themed mount, across all 57 recipes (grouped controls included, driven through their selection). Two positive controls -- one allocating a color string per frame, one per group item per frame -- must FAIL the gate, or it would be decorative. The full harness (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`) proves **0 retained bytes, 0 major GCs, and ~0.88 B/op** across the whole mount / interact / destroy loop under `--expose-gc`:
295
329
 
296
330
  ```
297
- GATE leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | alloc=0.8564453125 B/op
331
+ GATE leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | alloc=0.8759765625 B/op
298
332
  ```
299
333
 
300
- For size: the controller alone is **~5.2 KB min+gzip** (its three deps external); the full catalog of 56 recipes is **~24 KB min+gzip**, and it tree-shakes -- import one recipe and the bundler drops the other 55.
334
+ For size: the controller alone is **~7.1 KB min+gzip** (its three deps external); the full catalog of 57 recipes is **~25 KB min+gzip**, and it tree-shakes -- import one recipe and the bundler drops the other 56.
301
335
 
302
336
  </details>
303
337
 
@@ -307,18 +341,19 @@ For size: the controller alone is **~5.2 KB min+gzip** (its three deps external)
307
341
 
308
342
  Each is an ADR under [`decisions/`](decisions/):
309
343
 
310
- - **[0001](decisions/0001-recipes-position.md) -- Recipes ship inside the package.** No more copy-paste-from-a-ZIP: 56 recipes are versioned, typed, and tree-shakeable behind the `./recipes` subpath, exactly the shape the sibling fx packages use.
311
- - **[0002](decisions/0002-recipe-options.md) -- One recipe option convention.** `{ colors, theme, text, font }` across all 56, resolved cold in `init`; defaults reproduce today's literals byte-for-byte; `text` closes the WCAG label-in-name gap.
344
+ - **[0001](decisions/0001-recipes-position.md) -- Recipes ship inside the package.** No more copy-paste-from-a-ZIP: 57 recipes are versioned, typed, and tree-shakeable behind the `./recipes` subpath, exactly the shape the sibling fx packages use.
345
+ - **[0002](decisions/0002-recipe-options.md) -- One recipe option convention.** `{ colors, theme, text, font }` across all 57, resolved cold in `init`; defaults reproduce today's literals byte-for-byte; `text` closes the WCAG label-in-name gap.
312
346
  - **[0003](decisions/0003-element-types.md) -- Real native element types.** CHECKBOX (tri-state), PROGRESS (`setValue`-driven, opt-in `aria-live`), KNOB (native arrows + pointer map) wrap the *correct* native element, not a faked toggle.
313
347
  - **[0004](decisions/0004-decorate-mode.md) -- Decorate mode.** A second mount mode for a canvas around a live element -- the honest home for a decoration over a real input, host byte-identical.
314
348
  - **[0005](decisions/0005-host-clock.md) -- Host clock, reduced motion, frame budget.** Three clock modes, `state.reducedMotion` as a flag the recipe reads, `state.budget` for graceful degradation -- all additive, default path byte-identical.
315
349
  - **[0006](decisions/0006-docs-and-demo.md) -- Blueprint docs + a demo that consumes the package.** This README on the blueprint spine, and one demo generated from `RECIPE_META` that imports only public exports (no more inline reimplementation).
350
+ - **[0007](decisions/0007-group-contract.md) -- Grouped controls: one canvas, N native elements.** A third mount mode (`mountUIFXGroup`) for radio/tabs/stepper/rating; `onSelect` is a ninth, group-only hook and group state a superset of scalar state, so the single-element API is byte-identical (additive, 1.9.0).
316
351
 
317
352
  ---
318
353
 
319
354
  ## Testing
320
355
 
321
- **205 deterministic node:test cases across 18 suites, all pass**, plus a torture gate that proves 0 B/op steady state and leak-freedom.
356
+ **237 deterministic node:test cases across 27 suites, all pass**, plus a torture gate that proves 0 B/op steady state and leak-freedom.
322
357
 
323
358
  ```bash
324
359
  npm test # node:test: contract, boundary, registry, theming, reduced-motion, docs
@@ -335,7 +370,7 @@ The suite covers the a11y state machine (one Space press = one `onToggle`, state
335
370
  - **Not a component framework.** It paints controls; it does not do layout, routing, or state management. Bring your own.
336
371
  - **Not a worker-mode renderer.** A 200x48 UI canvas does not amortise a worker hop; the shared main-thread ticker is the right tool. (`@zakkster/lite-ambient-fx` is the worker-mode fullscreen backdrop.)
337
372
  - **Not a chart or data-viz library.** These are interactive *controls*, not plots. Charts are `@zakkster/lite-charts`.
338
- - **Not an ARIA behaviour engine.** It renders; it does not own focus traps, dismiss stacks, or roving-tabindex logic. `@zakkster/lite-headless` owns behaviour, permanently -- and its 59 primitives are a decorate-mode skin target.
373
+ - **Not an ARIA behaviour engine.** It renders. The one keyboard behaviour it writes is the tablist roving-tabindex for a `TABS` group (radio/rating/stepper selection is the browser's own); it does not own focus traps, dismiss stacks, or listbox/combobox/menu patterns. `@zakkster/lite-headless` owns behaviour, permanently -- and its 59 primitives are a decorate-mode skin target.
339
374
 
340
375
  ---
341
376
 
@@ -14,6 +14,23 @@ export declare const UIType: Readonly<{
14
14
  KNOB: 'knob';
15
15
  }>;
16
16
 
17
+ export type GroupTypeValue = 'radio' | 'tabs' | 'stepper' | 'rating';
18
+
19
+ /**
20
+ * Grouped-control types (U7, decisions/0007). Each mounts N native elements + one
21
+ * canvas + one recipe via mountUIFXGroup (NOT a UIType -- routed separately).
22
+ */
23
+ export declare const GroupType: Readonly<{
24
+ /** fieldset + N <input type=radio>; native roving arrow-key selection. */
25
+ RADIO: 'radio';
26
+ /** role=tablist + N role=tab buttons; hand-written APG roving tabindex + arrows/Home/End. */
27
+ TABS: 'tabs';
28
+ /** one <input type=number> spinbutton; native Up/Down + typing. */
29
+ STEPPER: 'stepper';
30
+ /** radiogroup of N radios (rating semantics); native roving selection. */
31
+ RATING: 'rating';
32
+ }>;
33
+
17
34
  export interface UIFXState {
18
35
  hover: boolean;
19
36
  active: boolean;
@@ -215,4 +232,114 @@ export declare function decorateUIFX(
215
232
  options?: DecorateOptions
216
233
  ): DecorateInstance;
217
234
 
235
+ // =========================================================
236
+ // Grouped controls (U7, decisions/0007)
237
+ // =========================================================
238
+
239
+ /**
240
+ * Per-frame state for a grouped control: the scalar UIFXState SUPERSET plus the
241
+ * group fields. The single-value fields (val/toggled/indeterminate) are neutral
242
+ * for a group -- a group uses `index`/`count`. Geometry lanes are Float32Arrays of
243
+ * length `count` the recipe reads by index (zero per-frame allocation).
244
+ */
245
+ export interface UIFXGroupState extends UIFXState {
246
+ /** The selected item index, 0..count-1. */
247
+ index: number;
248
+ /** The number of items (multi-element groups) or steps (stepper). */
249
+ count: number;
250
+ /** The item currently under the pointer, or -1 when none. */
251
+ hoverIndex: number;
252
+ /** The item label strings (the mount's `items`); read by the recipe, never mutated. */
253
+ labels: string[];
254
+ /** Per-item x offset in strip coordinates (length count). */
255
+ itemX: Float32Array;
256
+ /** Per-item y offset (length count). */
257
+ itemY: Float32Array;
258
+ /** Per-item width (length count). */
259
+ itemW: Float32Array;
260
+ /** Per-item height (length count). */
261
+ itemH: Float32Array;
262
+ }
263
+
264
+ /**
265
+ * A group recipe: the eight standard hooks (with group state) PLUS onSelect, the
266
+ * ninth, group-only hook. onSelect is rejected by mountUIFX/decorateUIFX (fail
267
+ * closed), so a recipe carrying it mounts only as a group.
268
+ */
269
+ export interface UIFXGroupRecipe {
270
+ init?(ctx: CanvasRenderingContext2D, w: number, h: number, padding: number): void;
271
+ tick(ctx: CanvasRenderingContext2D, dt: number, now: number, state: UIFXGroupState, pointer: UIFXPointer): void;
272
+ onHover?(state: UIFXGroupState, pointer: UIFXPointer): void;
273
+ onLeave?(state: UIFXGroupState, pointer: UIFXPointer): void;
274
+ onClick?(x: number, y: number, state: UIFXGroupState): void;
275
+ onToggle?(checked: boolean, state: UIFXGroupState): void;
276
+ onDrag?(value: number, velocity: number, state: UIFXGroupState): void;
277
+ /** U7: fired exactly once per selection change, with the new index. */
278
+ onSelect?(index: number, state: UIFXGroupState): void;
279
+ destroy?(): void;
280
+ }
281
+
282
+ export type GroupRecipeFactory = () => UIFXGroupRecipe;
283
+
284
+ /**
285
+ * Options for mountUIFXGroup. `items` (the per-item labels) is REQUIRED, >=2
286
+ * strings; its length is the item/step count. The initial selection is `index`
287
+ * (an integer, distinct from the hijack float `value`). The hijack-only keys
288
+ * (value/checked/knobMode/announce) are rejected -- passing one throws.
289
+ */
290
+ export interface GroupOptions extends HostClockOptions {
291
+ /** The per-item labels; >=2 strings. Length = item/step count. Required. */
292
+ items: string[];
293
+ /** Initial selected index, integer in [0, items.length-1] (default 0). */
294
+ index?: number;
295
+ /** Accessible group label (fieldset/tablist aria-label). */
296
+ label?: string;
297
+ /** Total strip width in px (default: per-type item width * count). */
298
+ width?: number;
299
+ /** Strip height in px (default per group type). */
300
+ height?: number;
301
+ /** Canvas overflow padding in px (default 40). */
302
+ padding?: number;
303
+ /** Disable every native element + set state.disabled. */
304
+ disabled?: boolean;
305
+ seed?: number;
306
+ colors?: string[];
307
+ theme?: { light: string; mid: string; dark: string };
308
+ text?: string;
309
+ font?: string;
310
+ }
311
+
312
+ export interface UIFXGroupInstance {
313
+ /** The native interactive elements (radios / tabs, or the single spinbutton). */
314
+ els: HTMLElement[];
315
+ canvas: HTMLCanvasElement;
316
+ wrapper: HTMLDivElement;
317
+ state: UIFXGroupState;
318
+ /** The selected index right now (convenience over state.index). */
319
+ readonly index: number;
320
+ /** Drive one frame by hand (U5). Callable ONLY with { driven: true }. */
321
+ tick(dtMs: number): void;
322
+ /**
323
+ * Programmatically select item i in [0, count-1]: updates the native
324
+ * element(s), state.index, and fires onSelect exactly once (no native event,
325
+ * so no double fire). Does NOT steal focus. Throws on a bad index.
326
+ */
327
+ setIndex(i: number): void;
328
+ destroy(): void;
329
+ }
330
+
331
+ /**
332
+ * Mount a grouped control: N native elements (radios in a fieldset, tabs in a
333
+ * tablist, a spinbutton, a rating radiogroup) sharing ONE canvas and one recipe
334
+ * (decisions/0007). The native elements own selection + keyboard + a11y; the
335
+ * canvas paints the group by reading state.index/state.count and the per-item
336
+ * geometry lanes. Additive to mountUIFX/decorateUIFX -- neither is touched.
337
+ */
338
+ export declare function mountUIFXGroup(
339
+ container: HTMLElement,
340
+ groupType: GroupTypeValue,
341
+ recipeFactory: GroupRecipeFactory,
342
+ options: GroupOptions
343
+ ): UIFXGroupInstance;
344
+
218
345
  export default mountUIFX;