@zakkster/lite-ui-fx 1.8.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/CHANGELOG.md +63 -0
- package/README.md +54 -19
- package/UIFXController.d.ts +127 -0
- package/UIFXController.js +525 -1
- package/UIFXRecipes.d.ts +33 -13
- package/UIFXRecipes.js +149 -98
- package/llms.txt +61 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,69 @@ 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.0] -- 2026-09-07
|
|
9
|
+
|
|
10
|
+
Grouped controls (roadmap U7): a third mount mode for a control that is N native
|
|
11
|
+
elements sharing one canvas and one recipe. Additive -- `mountUIFX` and
|
|
12
|
+
`decorateUIFX` are byte-identical (the `UIFXController.js` diff is 524 insertions,
|
|
13
|
+
0 deletions), the single-element API and the eight-hook recipe contract are
|
|
14
|
+
unchanged, so this is a minor. See decisions/0007.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `mountUIFXGroup(container, groupType, recipeFactory, options)` -- the group
|
|
19
|
+
mount, returning `{ els, canvas, wrapper, state, index, setIndex(i), tick, destroy }`.
|
|
20
|
+
`setIndex(i)` selects programmatically, updating the native element(s) and
|
|
21
|
+
`state.index` and firing `onSelect` once, without stealing focus.
|
|
22
|
+
- `GroupType` { RADIO, TABS, STEPPER, RATING }. RADIO/RATING build a
|
|
23
|
+
`<fieldset role=radiogroup>` of native radios (native roving selection); TABS a
|
|
24
|
+
`<div role=tablist>` of `<button role=tab>` with a hand-written APG roving
|
|
25
|
+
tabindex (Left/Right/Up/Down + Home/End); STEPPER one `<input type=number>`
|
|
26
|
+
spinbutton.
|
|
27
|
+
- `onSelect(index, state)` -- the ninth, group-only recipe hook, fired exactly
|
|
28
|
+
once per selection change (rejected by `mountUIFX`/`decorateUIFX`). Group state
|
|
29
|
+
is a superset of the scalar state plus `index`, `count`, `hoverIndex`, `labels`,
|
|
30
|
+
and the `itemX`/`itemY`/`itemW`/`itemH` Float32Array geometry lanes (read by
|
|
31
|
+
index, zero per-frame allocation).
|
|
32
|
+
- `SegmentedSlide` (a TABS recipe) -- 57 built-in recipes total. New `UIFXRecipes6`
|
|
33
|
+
barrel; the four group types added to `RECIPE_META`, `VALID_META_TYPES`, and
|
|
34
|
+
`mountRecipe` routing (a group `META.type` routes to `mountUIFXGroup`, and needs
|
|
35
|
+
`items`).
|
|
36
|
+
- `test/group.test.mjs` (31 cases): native structure + ARIA per pattern, the APG
|
|
37
|
+
keyboard walk, onSelect-exactly-once, `setIndex`, the scalar-state superset,
|
|
38
|
+
the three clock modes, and fail-closed validation. Torture gains group coverage
|
|
39
|
+
in t0/t1/t2/t3 (with a per-item allocation control that must fail the gate) and
|
|
40
|
+
t5 (20 groups of 5 on one shared ticker). `npm test` is 238 cases across 27
|
|
41
|
+
suites; the torture gate holds at `alloc=0.8759765625 B/op`, 0 major GCs.
|
|
42
|
+
- TypeScript declarations for the group surface (`GroupType`, `mountUIFXGroup`,
|
|
43
|
+
`UIFXGroupState`, `UIFXGroupRecipe`, `GroupOptions`, `UIFXGroupInstance`).
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `PillTabs`, `Stepper`, `RadioOrbit`, and `BubbleRating` re-home from their vol.3
|
|
48
|
+
single-element fakes to real group types (`RECIPE_META.type` slider/button ->
|
|
49
|
+
radio/tabs/stepper/rating); their bodies read group state (`state.index`/`count`
|
|
50
|
+
and the geometry lanes) instead of a faked `state.val`. `mountRecipe(container,
|
|
51
|
+
id, { items })` routes them by `META.type`. RadioOrbit and BubbleRating size
|
|
52
|
+
their per-item lane from `state.count` -- a one-time grow, warm-up-absorbed.
|
|
53
|
+
- `README.md` and `llms.txt` document three mount modes, 57 recipes, and the group
|
|
54
|
+
API + state fields. Measured sizes updated: controller ~7.1 KB min+gzip (its
|
|
55
|
+
three deps external), full catalog ~25 KB. `demo/index.html` renders the group
|
|
56
|
+
recipes and cycles their selection (`#profile` reports `violationCount 0`).
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- The four re-homed controls now carry correct native selection and keyboard: a
|
|
61
|
+
radio group and a tablist are N elements, so arrow-key roving is the browser's
|
|
62
|
+
own (radio/rating/stepper) or an APG-correct hand-written roving (tabs). The
|
|
63
|
+
single-element fakes had the wrong arrow-key behaviour.
|
|
64
|
+
|
|
65
|
+
### Removed
|
|
66
|
+
|
|
67
|
+
- none. The single-element fake mount of the four re-homed recipes is superseded
|
|
68
|
+
by their group mount via `mountRecipe`/`mountUIFXGroup`; no public export or
|
|
69
|
+
option was removed.
|
|
70
|
+
|
|
8
71
|
## [1.8.0] -- 2026-09-07
|
|
9
72
|
|
|
10
73
|
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 --
|
|
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
|
[](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
|
|
6
6
|

|
|
@@ -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
|
-
- [
|
|
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
|
-
-
|
|
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
|
|
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
|
-
##
|
|
90
|
+
## Three mount modes and the recipe contract
|
|
89
91
|
|
|
90
92
|
<details>
|
|
91
|
-
<summary>How hijack and
|
|
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
|
-
###
|
|
110
|
+
### Group (`mountUIFXGroup`)
|
|
109
111
|
|
|
110
|
-
A
|
|
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 `
|
|
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
|
|
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
|
|
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.
|
|
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 **~
|
|
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:
|
|
311
|
-
- **[0002](decisions/0002-recipe-options.md) -- One recipe option convention.** `{ colors, theme, text, font }` across all
|
|
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
|
-
**
|
|
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
|
|
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
|
|
package/UIFXController.d.ts
CHANGED
|
@@ -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;
|