@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/CHANGELOG.md +99 -0
- package/README.md +323 -212
- 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/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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 -- 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
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
|
|
6
|
+

|
|
4
7
|
[](https://bundlephobia.com/result?p=@zakkster/lite-ui-fx)
|
|
5
8
|
[](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
|
|
6
9
|
[](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
|
|
@@ -8,275 +11,383 @@
|
|
|
8
11
|

|
|
9
12
|
[](https://opensource.org/licenses/MIT)
|
|
10
13
|
|
|
11
|
-
##
|
|
14
|
+
## The canvas microinteraction layer the ecosystem was missing
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Every flashy-component library forces a trade. The React/Tailwind kits (React Bits, Aceternity, Magic UI) are extraordinary and framework-locked. The copy-paste CSS galleries (uiverse and friends) are framework-free but ship no behaviour -- no focus management, no keyboard, no screen-reader semantics. `lite-ui-fx` takes the corner nobody holds: **canvas-grade visuals on a real native control**, framework-free, zero-GC, reduced-motion aware, and agent-readable (`llms.txt` + a `RECIPE_META` registry).
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
https://cdpn.io/pen/debug/RNGjMjQ
|
|
18
|
+
The native element is never faked. A toggle is a real `<input type="checkbox" role="switch">`; a slider is a real `<input type="range">`. It stays invisible (`opacity:0`) but keeps every accessibility guarantee the browser gives it. The canvas sits on top and renders the recipe. For an element that must stay visible -- a live text input -- the second mount mode **decorates** instead: the canvas is placed around the host, which is left byte-identical.
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
```bash
|
|
21
|
+
npm i @zakkster/lite-ui-fx
|
|
22
|
+
```
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
```js
|
|
25
|
+
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
26
|
+
import { SwarmToggle } from '@zakkster/lite-ui-fx/recipes';
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
// A real <input type="checkbox" role="switch">, invisible but fully accessible,
|
|
29
|
+
// painted by 150 particles that swarm into a knob and explode on toggle.
|
|
30
|
+
const toggle = mountUIFX(document.getElementById('sound-toggle'), UIType.TOGGLE, SwarmToggle, {
|
|
31
|
+
label: 'Sound effects',
|
|
32
|
+
width: 64,
|
|
33
|
+
height: 36,
|
|
34
|
+
});
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- **Knobs** -- Volume dial, Compass needle
|
|
30
|
-
- **Progress** -- Ring, Battery, Signal meter, Liquid Fill
|
|
31
|
-
- **Controls** -- Pill tabs, Stepper, Radio orbit
|
|
32
|
-
- **Indicators** -- Water level, Heat map
|
|
33
|
-
- **Mood** -- Day/night, Reaction picker, Notification bell
|
|
34
|
-
- **Feedback** -- Sound wave, Upload progress
|
|
35
|
-
- **Fun** -- Scratch reveal, Timer countdown, Pull refresh
|
|
36
|
-
- **Checkboxes** -- Ripple, Morph (X to check), Tick Draw, Indeterminate Scan
|
|
37
|
-
- **Loaders** -- Orbit planets, DNA helix
|
|
38
|
-
- **Counters** -- Flame heat, Glitch signal
|
|
39
|
-
- **Rating** -- Bubble inflate
|
|
40
|
-
- **Form (decorate)** -- Focus halo, Error shake, Success bloom, Password strength, Typewriter field (mounted via `decorateUIFX`, around a live element)
|
|
36
|
+
// Screen readers see: <input type="checkbox" role="switch" aria-label="Sound effects">
|
|
37
|
+
toggle.destroy(); // removes the overlay + native element, tears down every listener
|
|
38
|
+
```
|
|
41
39
|
|
|
42
|
-
|
|
40
|
+
Three runtime dependencies, all zero-GC (`@zakkster/lite-ticker`, `lite-lerp`, `lite-random`). Nothing else.
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
typed, and tree-shakeable. With `sideEffects: false`, importing one recipe pulls
|
|
46
|
-
in only that recipe, so a controller-only install stays tiny.
|
|
42
|
+
---
|
|
47
43
|
|
|
48
|
-
|
|
49
|
-
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
50
|
-
import { SwarmToggle } from '@zakkster/lite-ui-fx/recipes';
|
|
51
|
-
```
|
|
44
|
+
## Table of contents
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
(
|
|
46
|
+
- [Why this exists](#why-this-exists)
|
|
47
|
+
- [What you get](#what-you-get)
|
|
48
|
+
- [Three mount modes and the recipe contract](#three-mount-modes-and-the-recipe-contract)
|
|
49
|
+
- [API reference](#api-reference)
|
|
50
|
+
- [mountUIFX](#mountuifxcontainer-type-recipefactory-options)
|
|
51
|
+
- [decorateUIFX](#decorateuifxel-recipefactory-options)
|
|
52
|
+
- [mountUIFXGroup](#mountuifxgroupcontainer-grouptype-recipefactory-options)
|
|
53
|
+
- [The recipe registry](#the-recipe-registry)
|
|
54
|
+
- [Constants: UITypes, state, META](#constants-uitypes-state-meta)
|
|
55
|
+
- [Host clock and reduced motion](#host-clock-and-reduced-motion)
|
|
56
|
+
- [Composability](#composability)
|
|
57
|
+
- [Zero-GC design notes](#zero-gc-design-notes)
|
|
58
|
+
- [Design decisions worth knowing](#design-decisions-worth-knowing)
|
|
59
|
+
- [Testing](#testing)
|
|
60
|
+
- [What this is not](#what-this-is-not)
|
|
61
|
+
- [Ecosystem](#ecosystem)
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
---
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
## Why this exists
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
Two problems no small library solves at once:
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
npm i @zakkster/lite-ui-fx
|
|
67
|
-
```
|
|
69
|
+
1. **Extraordinary visuals usually cost your accessibility.** The moment a control becomes a canvas, it stops being a control: no focus ring, no Space-to-toggle, no `role`, nothing a screen reader can announce. Most "animated component" libraries either lean on a framework's a11y or quietly drop it. `lite-ui-fx` keeps the real native element under the paint, so the keyboard and the screen-reader tree are the browser's own -- not a reimplementation that drifts. A `mountUIFX` toggle activates on Space because it *is* a checkbox.
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
> tree-shake, so importing one adds only that one.
|
|
71
|
+
2. **Canvas UI usually lies about being cheap.** A widget that allocates a color string, a gradient, or a particle object every frame drops frames under GC pressure exactly when the animation is busiest. Every built-in recipe here is zero-GC on its hot path -- `const` color strings with `globalAlpha`, preallocated typed-array particle pools, gradients built once in `init` -- and that claim is a gated torture test, not a README adjective.
|
|
71
72
|
|
|
73
|
+
The alternative is a hand-rolled canvas threshold loop (no a11y, allocates freely), a full animation framework (heavy, framework-bound), or copy-paste CSS (no behaviour). `lite-ui-fx` is the API for this specific job: a flashy control that is still a control.
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
---
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
77
|
-
import { SwarmToggle } from '@zakkster/lite-ui-fx/recipes';
|
|
77
|
+
## What you get
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
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`.
|
|
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.
|
|
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).
|
|
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.
|
|
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.
|
|
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).
|
|
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.
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Three mount modes and the recipe contract
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
<details>
|
|
93
|
+
<summary>How hijack, decorate, and group differ, and the recipe interface they share.</summary>
|
|
94
|
+
|
|
95
|
+
### Hijack (`mountUIFX`)
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
+--- wrapper div ---------------------------------+
|
|
99
|
+
| native element (opacity:0, z-index:2) | <- pointer, keyboard, focus, a11y
|
|
100
|
+
| canvas overlay (z-index:1, DPR-scaled) | <- recipe.tick() every frame
|
|
101
|
+
+-------------------------------------------------+
|
|
93
102
|
```
|
|
94
103
|
|
|
95
|
-
|
|
104
|
+
The native element is the source of truth. Every visual reads `state`; `state` reads the native element. Keyboard and assistive-tech behaviour is identical to a bare native control, because it *is* one. A `padding` (default 40) lets particles overflow the element box.
|
|
96
105
|
|
|
97
|
-
|
|
98
|
-
// Controller (always needed)
|
|
99
|
-
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
106
|
+
### Decorate (`decorateUIFX`)
|
|
100
107
|
|
|
101
|
-
|
|
102
|
-
import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
103
|
-
import { PendulumToggle, HeartbeatButton, RippleCheck } from '@zakkster/lite-ui-fx/recipes';
|
|
104
|
-
import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
|
|
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`.
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
|
|
108
|
-
```
|
|
110
|
+
### Group (`mountUIFXGroup`)
|
|
109
111
|
|
|
110
|
-
|
|
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.
|
|
111
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.
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
export function MyRecipe() {
|
|
120
|
+
// closed-over per-instance scratch, allocated once here (cold)
|
|
121
|
+
let pressScale = 1;
|
|
122
|
+
return {
|
|
123
|
+
init(ctx, w, h, padding) {}, // cold: resolve theme, build gradients, size pools
|
|
124
|
+
onHover(entering, state) {}, // pointer enter/leave
|
|
125
|
+
onClick(x, y, state) {}, // BUTTON activation
|
|
126
|
+
onToggle(checked, state) {}, // TOGGLE / CHECKBOX
|
|
127
|
+
onDrag(val, velocity, state) {}, // SLIDER / KNOB
|
|
128
|
+
tick(ctx, dt, now, state) { // HOT: paint one frame, allocate nothing
|
|
129
|
+
pressScale += (1 - pressScale) * dt * 10;
|
|
130
|
+
},
|
|
131
|
+
destroy() {}, // cold: release anything init created
|
|
132
|
+
};
|
|
133
|
+
}
|
|
112
134
|
```
|
|
113
|
-
+--------------------------------------------------+
|
|
114
|
-
| mountUIFX(container, type, recipeFactory, opts) |
|
|
115
|
-
| |
|
|
116
|
-
| +---- Wrapper div ----------------------------+ |
|
|
117
|
-
| | | |
|
|
118
|
-
| | Native element (opacity:0, z-index:2) | |
|
|
119
|
-
| | -> receives pointer, keyboard, focus events | |
|
|
120
|
-
| | -> accessible to screen readers | |
|
|
121
|
-
| | | |
|
|
122
|
-
| | Canvas overlay (z-index:1, DPR-scaled) | |
|
|
123
|
-
| | -> recipe.tick() renders every frame | |
|
|
124
|
-
| | -> padding allows particle overflow | |
|
|
125
|
-
| | | |
|
|
126
|
-
| +----------------------------------------------+ |
|
|
127
|
-
| |
|
|
128
|
-
| Shared Ticker (ref-counted, one RAF for all) |
|
|
129
|
-
| AbortController (all events cleaned on destroy) |
|
|
130
|
-
+--------------------------------------------------+
|
|
131
|
-
```
|
|
132
135
|
|
|
133
|
-
|
|
136
|
+
An unknown hook key on the returned object is an error at mount with a did-you-mean hint -- a typo'd `onClik` never silently does nothing.
|
|
137
|
+
|
|
138
|
+
</details>
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## API reference
|
|
134
143
|
|
|
135
144
|
### `mountUIFX(container, type, recipeFactory, options?)`
|
|
136
145
|
|
|
137
146
|
| Parameter | Type | Description |
|
|
138
147
|
|-----------|------|-------------|
|
|
139
148
|
| `container` | `HTMLElement` | Parent to mount into |
|
|
140
|
-
| `type` | `
|
|
141
|
-
| `recipeFactory` | `() => Recipe` | Factory
|
|
142
|
-
| `options
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
element without hijacking it -- no `opacity:0`, no reparenting. The overlay is a
|
|
167
|
-
sibling placed from the host's offset box and removed on `destroy()`, so the host
|
|
168
|
-
is byte-identical before and after. Recipe `state` is wired from the host's own
|
|
169
|
-
events; for a form-control host, `state.text` and `state.valid` mirror `el.value`
|
|
170
|
-
and `el.validity` (read at event time, never per frame). This is the honest home
|
|
171
|
-
for a decoration over a real input.
|
|
172
|
-
|
|
173
|
-
```javascript
|
|
149
|
+
| `type` | `UIType` | One of `TOGGLE`, `BUTTON`, `SLIDER`, `CHECKBOX`, `PROGRESS`, `KNOB` |
|
|
150
|
+
| `recipeFactory` | `() => Recipe` | Factory; the controller calls it |
|
|
151
|
+
| `options` | `object?` | See below |
|
|
152
|
+
|
|
153
|
+
Options: `width`, `height`, `padding` (40), `label`, `value` (slider start, 0..1), `checked` (toggle start), `disabled`, `seed`, `colors` (`string[]`), `theme` (`{ light, mid, dark }`), `text`, `font`, `knobMode` (`'rotate'|'vertical'`, KNOB), `announce` (`boolean`, PROGRESS), `ticker`, `driven`. An unknown option key -- or a recipe-hook key passed as an option -- throws a did-you-mean; a malformed `theme`/`colors`/`seed`/`knobMode`/`announce`, or an unknown `UIType`, throws. `null` is never coerced to a default.
|
|
154
|
+
|
|
155
|
+
Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), tick(dtMs), destroy() }`.
|
|
156
|
+
|
|
157
|
+
- `setValue(v)` -- SLIDER/KNOB/PROGRESS: set `v` in `0..1` (updates the element and `state.val`, fires `onDrag` once). CHECKBOX: `setValue(null)` sets indeterminate.
|
|
158
|
+
- `setChecked(b)` -- TOGGLE/CHECKBOX: set checked (updates the element and `state.toggled`, fires `onToggle` once).
|
|
159
|
+
- `tick(dtMs)` -- driven mode only (`{ driven: true }`): paints one frame. Throws otherwise.
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
163
|
+
import { SparkSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
164
|
+
|
|
165
|
+
const slider = mountUIFX(document.getElementById('volume'), UIType.SLIDER, SparkSlider, { value: 0.3 });
|
|
166
|
+
slider.setValue(0.75); // moves the native <input type="range"> and fires onDrag once
|
|
167
|
+
slider.destroy();
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### `decorateUIFX(el, recipeFactory, options?)`
|
|
171
|
+
|
|
172
|
+
Decorates an existing visible element. `options` is the subset `{ padding, seed, colors, theme, text, font, ticker, driven }`; the hijack-only keys (`width`/`height`/`value`/`checked`/`disabled`/`knobMode`/`announce`/`label`) throw in decorate mode -- geometry comes from the host, value is read from it. Returns `{ el, canvas, state, setValue, setChecked, tick, destroy() }`, where `setValue`/`setChecked` are hijack-only and throw (a decoration reflects the host; it does not drive it).
|
|
173
|
+
|
|
174
|
+
```js
|
|
174
175
|
import { decorateUIFX } from '@zakkster/lite-ui-fx';
|
|
175
176
|
import { PasswordStrength } from '@zakkster/lite-ui-fx/recipes';
|
|
176
177
|
|
|
177
178
|
const input = document.querySelector('#password');
|
|
178
|
-
const deco = decorateUIFX(input, PasswordStrength
|
|
179
|
-
//
|
|
180
|
-
deco.destroy();
|
|
179
|
+
const deco = decorateUIFX(input, PasswordStrength);
|
|
180
|
+
// the input stays fully usable; the meter tracks what the user types
|
|
181
|
+
deco.destroy(); // removes ONLY the overlay; the input is untouched
|
|
181
182
|
```
|
|
182
183
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
`
|
|
186
|
-
|
|
187
|
-
`
|
|
188
|
-
|
|
189
|
-
`
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*(decorate)* is a mount mode, not a `UIType`: it creates no native element. A recipe
|
|
205
|
-
with `RECIPE_META.type === 'decorate'` is mounted via `decorateUIFX`.
|
|
206
|
-
|
|
207
|
-
### State Object (provided to `tick()` every frame)
|
|
208
|
-
|
|
209
|
-
```typescript
|
|
210
|
-
{
|
|
211
|
-
hover: boolean; // Pointer inside element
|
|
212
|
-
active: boolean; // Pointer pressed
|
|
213
|
-
focused: boolean; // Keyboard focus
|
|
214
|
-
toggled: boolean; // Checkbox/toggle state
|
|
215
|
-
indeterminate: boolean; // CHECKBOX only: native indeterminate (setValue(null))
|
|
216
|
-
disabled: boolean; // Disabled via options.disabled
|
|
217
|
-
val: number; // Slider value (0-1)
|
|
218
|
-
w: number; // Element width
|
|
219
|
-
h: number; // Element height
|
|
220
|
-
padding: number; // Canvas padding
|
|
221
|
-
dpr: number; // Device pixel ratio
|
|
222
|
-
text?: string; // decorate mode only: the host value string
|
|
223
|
-
valid?: boolean; // decorate mode only: the host validity
|
|
224
|
-
}
|
|
184
|
+
Built-in decorate recipes: `FocusHalo`, `ErrorShake`, `SuccessBloom`, `PasswordStrength`, `TypewriterField` (`RECIPE_META.type === 'decorate'`, so `mountRecipe(el, id)` routes them here automatically).
|
|
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();
|
|
225
204
|
```
|
|
226
205
|
|
|
227
|
-
|
|
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`.
|
|
228
207
|
|
|
229
|
-
|
|
230
|
-
|---------|------|----------|---------|------|---------|
|
|
231
|
-
| Framer Motion | ~45 KB | React HOC | 0 | Via React | `npm i framer-motion` |
|
|
232
|
-
| GSAP | ~25 KB | Timeline | 0 | Manual | `npm i gsap` |
|
|
233
|
-
| Lottie | ~55 KB | JSON animation | After Effects | Manual | `npm i lottie-web` |
|
|
234
|
-
| **lite-ui-fx** | **< 5 KB** | **Canvas hijack + decorate** | **56 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
|
|
208
|
+
### The recipe registry
|
|
235
209
|
|
|
236
|
-
|
|
210
|
+
```js
|
|
211
|
+
import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
|
|
237
212
|
|
|
238
|
-
|
|
213
|
+
const swarm = RECIPES.swarmToggle; // id -> factory (null-prototype map)
|
|
214
|
+
const toggles = RECIPE_META.filter((m) => m.family === 'Toggles');
|
|
215
|
+
mountRecipe(document.getElementById('picker'), 'sparkSlider', { value: 0.5 });
|
|
216
|
+
```
|
|
239
217
|
|
|
240
|
-
|
|
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.
|
|
241
219
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
220
|
+
### Constants: UITypes, state, META
|
|
221
|
+
|
|
222
|
+
| `UIType` | Native element | Recipe hook | Key state |
|
|
223
|
+
|----------|---------------|-------------|-----------|
|
|
224
|
+
| `TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
|
|
225
|
+
| `BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
|
|
226
|
+
| `SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0..1) |
|
|
227
|
+
| `CHECKBOX` | `<input type="checkbox">` (no `role`) | `onToggle(checked)` | `state.toggled`, `state.indeterminate` |
|
|
228
|
+
| `PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` (0..1) |
|
|
229
|
+
| `KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0..1) |
|
|
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` |
|
|
232
|
+
|
|
233
|
+
The `state` object passed to `tick(ctx, dt, now, state)` every frame:
|
|
234
|
+
|
|
235
|
+
| Field | Type | Meaning |
|
|
236
|
+
|-------|------|---------|
|
|
237
|
+
| `hover` / `active` / `focused` | `boolean` | pointer inside / pressed / keyboard focus |
|
|
238
|
+
| `toggled` / `indeterminate` | `boolean` | checkbox and toggle state (indeterminate: CHECKBOX only) |
|
|
239
|
+
| `disabled` | `boolean` | mounted with `disabled` |
|
|
240
|
+
| `val` | `number` | slider/knob/progress value, 0..1 |
|
|
241
|
+
| `w` / `h` / `padding` / `dpr` | `number` | geometry and device pixel ratio |
|
|
242
|
+
| `reducedMotion` | `boolean` | user prefers reduced motion (matchMedia, watched) |
|
|
243
|
+
| `budget` | `number` | 0..1 frame budget, 1 at ~60fps, lower as frames lengthen |
|
|
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) |
|
|
247
|
+
|
|
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.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Host clock and reduced motion
|
|
253
|
+
|
|
254
|
+
Three mutually-exclusive clock modes, in both mount modes. Omit both options for the default shared ref-counted ticker (one RAF for every component, byte-identical to earlier versions).
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
258
|
+
import { MagneticButton } from '@zakkster/lite-ui-fx/recipes';
|
|
259
|
+
import { Ticker } from '@zakkster/lite-ticker';
|
|
260
|
+
|
|
261
|
+
// { ticker }: hand it your game's clock. destroy() removes this component's
|
|
262
|
+
// frame but NEVER destroys your ticker -- ownership stays with you.
|
|
263
|
+
const clock = new Ticker();
|
|
264
|
+
const a = mountUIFX(document.getElementById('fire'), UIType.BUTTON, MagneticButton, { ticker: clock });
|
|
265
|
+
|
|
266
|
+
// { driven }: no ticker, no RAF -- you call tick(dtMs) from your own loop.
|
|
267
|
+
const b = mountUIFX(document.getElementById('jump'), UIType.BUTTON, MagneticButton, { driven: true });
|
|
268
|
+
b.tick(16.7); // paints exactly one frame, the same body the ticker would call
|
|
269
|
+
|
|
270
|
+
a.destroy();
|
|
271
|
+
b.destroy();
|
|
260
272
|
```
|
|
261
273
|
|
|
262
|
-
|
|
274
|
+
Passing both `ticker` and `driven`, a non-boolean `driven`, or a `ticker` without an `.add()` method throws at mount (fail closed). `state.reducedMotion` is read before `init` and watched for changes; a calm-path recipe renders statically when it is set (ErrorShake stops shaking, SwarmToggle/SuccessBloom/FocusHalo drop their motion). `state.budget` lets a budget-aware recipe shed particles or glow before frames actually drop. Reduced motion is a *state flag the recipe reads*, never a mode that forces behaviour -- the host owns the toggle, the recipe owns the calm render.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Composability
|
|
279
|
+
|
|
280
|
+
One clock, a shared theme, several components -- the shape a game or a themed dashboard actually uses:
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
import { mountUIFX, decorateUIFX, mountUIFXGroup, UIType, GroupType } from '@zakkster/lite-ui-fx';
|
|
284
|
+
import { SwarmToggle, SparkSlider, PasswordStrength, PillTabs } from '@zakkster/lite-ui-fx/recipes';
|
|
285
|
+
import { Ticker } from '@zakkster/lite-ticker';
|
|
286
|
+
|
|
287
|
+
// 1. One clock the host owns and controls (pause it, scale it, share it).
|
|
288
|
+
const clock = new Ticker();
|
|
289
|
+
|
|
290
|
+
// 2. A theme object -- the { light, mid, dark } shape lite-scratch-fx also takes,
|
|
291
|
+
// so one object themes both packages.
|
|
292
|
+
const theme = { light: '#a78bfa', mid: '#7c3aed', dark: '#4c1d95' };
|
|
293
|
+
|
|
294
|
+
// 3. Mount several components on that one clock, all themed from that one object.
|
|
295
|
+
const mute = mountUIFX(document.getElementById('mute'), UIType.TOGGLE, SwarmToggle, { ticker: clock, theme });
|
|
296
|
+
const volume = mountUIFX(document.getElementById('vol'), UIType.SLIDER, SparkSlider, { ticker: clock, theme, value: 0.6 });
|
|
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'] });
|
|
300
|
+
|
|
301
|
+
// 4. One teardown per component; the clock is yours to keep or stop.
|
|
302
|
+
mute.destroy();
|
|
303
|
+
volume.destroy();
|
|
304
|
+
pw.destroy();
|
|
305
|
+
tabs.destroy();
|
|
306
|
+
```
|
|
307
|
+
|
|
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.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Zero-GC design notes
|
|
313
|
+
|
|
314
|
+
<details>
|
|
315
|
+
<summary>What the hot path allocates (nothing), and how the gate proves it.</summary>
|
|
316
|
+
|
|
317
|
+
Everything a recipe needs is resolved in `init` (cold): the palette and any ramp arrays, gradients, particle pools sized to the recipe's own maximum. The per-frame `tick` afterward does nothing but arithmetic and canvas calls on those preallocated buffers.
|
|
318
|
+
|
|
319
|
+
| Hot operation | Steady-state allocations | How |
|
|
320
|
+
| ------------- | ------------------------ | --- |
|
|
321
|
+
| `tick()` fill colors | **0** | `const` color strings + `ctx.globalAlpha`, never a per-frame template literal |
|
|
322
|
+
| Particle recipes (Swarm, Firework, ...) | **0** | fixed `Float64Array` lanes, power-of-2 bitmask index -- no push/splice |
|
|
323
|
+
| Gradient recipes | **0** after `init` | gradients built in `init`, rebuilt only when a driving value crosses a threshold |
|
|
324
|
+
| Value-label text | **0** per frame | label strings rebuilt at ~10Hz via a frame-counter mask, not every frame |
|
|
325
|
+
| Pointer move / drag | **0** | arithmetic only; the bounding rect is cached on pointer-enter, not read per move |
|
|
326
|
+
| `init` / theme resolve | once, cold | palette, ramps, gradients, pools -- then read-only in the loop |
|
|
327
|
+
|
|
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`:
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
GATE leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | alloc=0.8759765625 B/op
|
|
332
|
+
```
|
|
333
|
+
|
|
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.
|
|
335
|
+
|
|
336
|
+
</details>
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Design decisions worth knowing
|
|
341
|
+
|
|
342
|
+
Each is an ADR under [`decisions/`](decisions/):
|
|
343
|
+
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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).
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Testing
|
|
355
|
+
|
|
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.
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
npm test # node:test: contract, boundary, registry, theming, reduced-motion, docs
|
|
360
|
+
npm run torture # @zakkster/lite-leak + lite-gc-profiler: 0 B/op steady state, gated
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The suite covers the a11y state machine (one Space press = one `onToggle`, state flipped once), the fail-closed option surface (unknown key -> did-you-mean; malformed theme/value/knobMode throw), the registry (resolve, override, type-check, tree-shake), theming (a bare mount is byte-identical; a themed mount honours `colors`/`theme`), reduced motion (calm-path recipes collapse their motion under `reducedMotion`), and host integration (default rides the shared ticker; `{ ticker }` never destroys the caller's clock; `{ driven }` paints without a RAF). A doc-snippet gate extracts every code block in this README and executes it against the package, so a drifted example fails CI. The torture harness runs seven deliberately-broken controls that must each exit non-zero -- a gate that cannot fail is decorative.
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## What this is not
|
|
263
368
|
|
|
264
|
-
|
|
369
|
+
- **Not a React or framework binding.** No components, no hooks, no JSX. It mounts onto a DOM element; wire it into any framework's ref, or none.
|
|
370
|
+
- **Not a component framework.** It paints controls; it does not do layout, routing, or state management. Bring your own.
|
|
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.)
|
|
372
|
+
- **Not a chart or data-viz library.** These are interactive *controls*, not plots. Charts are `@zakkster/lite-charts`.
|
|
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.
|
|
265
374
|
|
|
266
|
-
|
|
267
|
-
- `decorateUIFX` (+ `DecorateOptions`, `DecorateInstance`)
|
|
268
|
-
- `UIType`
|
|
269
|
-
- `UIFXState`
|
|
270
|
-
- `UIFXPointer`
|
|
271
|
-
- `UIFXRecipe`
|
|
375
|
+
---
|
|
272
376
|
|
|
273
|
-
|
|
377
|
+
## Ecosystem
|
|
274
378
|
|
|
379
|
+
Part of the **@zakkster** zero-GC stack:
|
|
275
380
|
|
|
276
|
-
|
|
381
|
+
- [`lite-ticker`](https://www.npmjs.com/package/@zakkster/lite-ticker) -- the shared RAF scheduler; hand your own instance in via `{ ticker }`
|
|
382
|
+
- [`lite-lerp`](https://www.npmjs.com/package/@zakkster/lite-lerp) -- zero-dep game-math primitives (recipe easing)
|
|
383
|
+
- [`lite-random`](https://www.npmjs.com/package/@zakkster/lite-random) -- seeded Mulberry32 RNG (deterministic particle recipes)
|
|
384
|
+
- [`lite-scratch-fx`](https://www.npmjs.com/package/@zakkster/lite-scratch-fx) -- canvas scratch-reveal recipes; shares the `{ light, mid, dark }` theme shape
|
|
385
|
+
- [`lite-ambient-fx`](https://www.npmjs.com/package/@zakkster/lite-ambient-fx) -- fullscreen ambient backdrops (the worker-mode sibling)
|
|
386
|
+
- [`lite-headless`](https://www.npmjs.com/package/@zakkster/lite-headless) -- 59 ARIA-correct primitives; a decorate-mode skin target
|
|
387
|
+
- **`lite-ui-fx`** -- this package
|
|
277
388
|
|
|
278
|
-
|
|
389
|
+
---
|
|
279
390
|
|
|
280
391
|
## License
|
|
281
392
|
|
|
282
|
-
MIT
|
|
393
|
+
MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|