@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/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
  [![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
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Recipes-00C853?style=for-the-badge&logo=leaf&logoColor=white)
4
7
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-ui-fx?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-ui-fx)
5
8
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-ui-fx?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
6
9
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-ui-fx?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-ui-fx)
@@ -8,275 +11,383 @@
8
11
  ![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen)
9
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
10
13
 
11
- ## What is lite-ui-fx?
14
+ ## The canvas microinteraction layer the ecosystem was missing
12
15
 
13
- `@zakkster/lite-ui-fx` overlays a DPR-aware canvas on native HTML elements and renders them with pluggable, physics-driven **recipes**. The native element stays invisible but fully accessible -- handling focus, keyboard, and pointer events. The canvas handles all visuals.
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
- ## Live Demo (UI-FX)
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
- ## Live Demo (UI-FX vol.2)
19
- https://cdpn.io/pen/debug/yyaPKpB
20
+ ```bash
21
+ npm i @zakkster/lite-ui-fx
22
+ ```
20
23
 
21
- ## Live Demo (UI-FX vol3.)
22
- https://cdpn.io/pen/debug/YPGEaYY
24
+ ```js
25
+ import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
26
+ import { SwarmToggle } from '@zakkster/lite-ui-fx/recipes';
23
27
 
24
- **56 recipes** across UI element categories:
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
- - **Toggles** -- Swarm, Liquid, Neon Pulse, Pendulum, Circuit, Lightning, DNA
27
- - **Buttons** -- Magnetic, Shatter, Confetti, Glitch, Heartbeat, Breathing, Ink Splash, Pixel Dissolve, Firework
28
- - **Sliders** -- Spark, Cosmic Void, Laser, Aurora, Wave, Elastic Band, Gravity
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
- Every recipe is zero-GC, uses `dt`-based animation, and includes accessibility indicators (focus rings, state labels).
40
+ Three runtime dependencies, all zero-GC (`@zakkster/lite-ticker`, `lite-lerp`, `lite-random`). Nothing else.
43
41
 
44
- All 56 recipes ship in the package on the `./recipes` subpath -- versioned,
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
- ```javascript
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
- The `./recipes` entry also exports a registry for data-driven pickers:
54
- `RECIPES` (id -> factory), `RECIPE_META` (`{ id, name, type, family }`),
55
- `RECIPE_NAMES`, `registerRecipe(id, factory, meta)`, and
56
- `mountRecipe(container, id, options?)` -- which resolves the id fail-closed
57
- (did-you-mean on a typo) and mounts it as its declared type.
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
- **How to write your own:** see [UIFX-RECIPE-GUIDE.md](UIFX-RECIPE-GUIDE.md), shipped in the package.
63
+ ---
60
64
 
61
- Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
65
+ ## Why this exists
62
66
 
63
- ## Install
67
+ Two problems no small library solves at once:
64
68
 
65
- ```bash
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
- > The 56 recipes ship in the same package on the `./recipes` subpath and
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
- ## Quick Start
75
+ ---
74
76
 
75
- ```javascript
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
- // Mount a canvas-rendered toggle onto a container
80
- const instance = mountUIFX(
81
- document.getElementById('my-container'),
82
- UIType.TOGGLE,
83
- SwarmToggle,
84
- { label: 'Sound effects', width: 64, height: 36 }
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
- // The native checkbox is invisible but fully accessible.
88
- // Screen readers see: <input type="checkbox" role="switch" aria-label="Sound effects">
89
- // Canvas renders: 150 particles forming a knob that explodes on toggle.
88
+ ---
89
+
90
+ ## Three mount modes and the recipe contract
90
91
 
91
- // Cleanup when done:
92
- instance.destroy();
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
- ## Import Map
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
- ```javascript
98
- // Controller (always needed)
99
- import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
106
+ ### Decorate (`decorateUIFX`)
100
107
 
101
- // All 56 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
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
- // Registry surface for data-driven pickers:
107
- import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
108
- ```
110
+ ### Group (`mountUIFXGroup`)
109
111
 
110
- ## How It Works
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
- ## API
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` | `'button' \| 'toggle' \| 'slider' \| 'checkbox' \| 'progress' \| 'knob'` | Determines native element type |
141
- | `recipeFactory` | `() => Recipe` | Factory function (controller calls it) |
142
- | `options.width` | `number` | Element width (auto from type if omitted) |
143
- | `options.height` | `number` | Element height |
144
- | `options.padding` | `number` | Canvas overflow (default: 40px) |
145
- | `options.label` | `string` | Accessible label (aria-label) |
146
- | `options.value` | `number` | Slider initial value, 0..1 (default 0.5); out-of-range throws |
147
- | `options.checked` | `boolean` | Toggle initial state (default false) |
148
- | `options.disabled` | `boolean` | Disables the native element; sets `state.disabled` |
149
- | `options.knobMode` | `'rotate' \| 'vertical'` | KNOB only: pointer-to-value mapping (default `'rotate'`); wrong type throws |
150
- | `options.announce` | `boolean` | PROGRESS only: opt-in `aria-live` announcements at 10% steps; wrong type throws |
151
-
152
- Recipe theming options (`seed`, `colors`, `theme`, `text`, `font`) are also
153
- accepted and forwarded to the recipe -- see `llms.txt` for the full option surface.
154
-
155
- Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), destroy() }`.
156
-
157
- - `setValue(v)` -- SLIDER/KNOB/PROGRESS: set `v` in `0..1` (updates the element +
158
- `state.val`, fires `onDrag` once). CHECKBOX: `setValue(null)` sets indeterminate.
159
- - `setChecked(b)` -- TOGGLE/CHECKBOX: set checked (updates the element +
160
- `state.toggled`, fires `onToggle` once).
161
-
162
- ### `decorateUIFX(el, recipeFactory, options?)` -- the second mount mode
163
-
164
- Where `mountUIFX` **hijacks** (creates a hidden native element under a canvas),
165
- `decorateUIFX` **decorates**: it positions a canvas *around* an existing, visible
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, { theme });
179
- // ... input stays fully usable; the meter tracks what the user types ...
180
- deco.destroy(); // removes ONLY the overlay; the input is untouched
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
- `options` is a subset: `padding`, `seed`, `colors`, `theme`, `text`, `font`. The
184
- hijack-only keys (`width`/`height`/`value`/`checked`/`disabled`/`knobMode`/
185
- `announce`/`label`) throw in decorate mode. Returns
186
- `{ el, canvas, state, setValue, setChecked, destroy() }`, where `setValue` and
187
- `setChecked` are hijack-only and throw (a decoration reflects the host; it does
188
- not drive it). Built-in decorate recipes: `FocusHalo`, `ErrorShake`,
189
- `SuccessBloom`, `PasswordStrength`, `TypewriterField` (`RECIPE_META.type` =
190
- `'decorate'`, so `mountRecipe(el, id)` routes them here automatically).
191
-
192
- ### Element Types
193
-
194
- | Type | Native Element | Recipe Hooks | Key State |
195
- |------|---------------|-------------|-----------|
196
- | `UIType.TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
197
- | `UIType.BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
198
- | `UIType.SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
199
- | `UIType.CHECKBOX` | `<input type="checkbox">` (no `role=switch`) | `onToggle(checked)` | `state.toggled`, `state.indeterminate` |
200
- | `UIType.PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` (0-1) |
201
- | `UIType.KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
202
- | *(decorate)* | none -- a canvas AROUND a live host (`decorateUIFX`) | host events -> state | `state.focused`, `state.text`, `state.valid` |
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
- ## Comparison
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
- | Library | Size | Approach | Recipes | A11y | Install |
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
- ## Writing Custom Recipes
210
+ ```js
211
+ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
237
212
 
238
- See the full [UIFX-RECIPE-GUIDE.md](UIFX-RECIPE-GUIDE.md) (included in the package).
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
- Minimal recipe:
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
- ```javascript
243
- export function MyButton() {
244
- let pressScale = 1;
245
- return {
246
- onClick() { pressScale = 0.85; },
247
- tick(ctx, dt, now, state) {
248
- pressScale += (1 - pressScale) * dt * 10;
249
- ctx.translate(state.w/2, state.h/2);
250
- ctx.scale(pressScale, pressScale);
251
- ctx.translate(-state.w/2, -state.h/2);
252
- ctx.fillStyle = state.hover ? '#a78bfa' : '#333';
253
- ctx.beginPath(); ctx.roundRect(0, 0, state.w, state.h, 10); ctx.fill();
254
- ctx.fillStyle = '#fff'; ctx.font = '600 13px sans-serif';
255
- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
256
- ctx.fillText('PRESS ME', state.w/2, state.h/2);
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
- ## TypeScript
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
- Full TypeScript declarations are included for:
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
- - `mountUIFX`
267
- - `decorateUIFX` (+ `DecorateOptions`, `DecorateInstance`)
268
- - `UIType`
269
- - `UIFXState`
270
- - `UIFXPointer`
271
- - `UIFXRecipe`
375
+ ---
272
376
 
273
- Recipe types ship too, on the `./recipes` subpath (`UIFXRecipes.d.ts`).
377
+ ## Ecosystem
274
378
 
379
+ Part of the **@zakkster** zero-GC stack:
275
380
 
276
- ## LLM-Friendly Documentation
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
- See `llms.txt` for AI-optimized metadata and the complete recipe catalog.
389
+ ---
279
390
 
280
391
  ## License
281
392
 
282
- MIT
393
+ MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>