@zakkster/lite-ui-fx 1.6.0 → 1.8.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 -- or decorated around a live one -- and painted by a pluggable, zero-GC **recipe**. The native element owns focus, keyboard, and pointer events; the canvas owns the visuals. 56 built-in recipes behind a tree-shakeable registry, one option convention for theming, one clock you can hand it, and reduced-motion built in.
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,348 @@
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
+ - [Two mount modes and the recipe contract](#two-mount-modes-and-the-recipe-contract)
49
+ - [API reference](#api-reference)
50
+ - [mountUIFX](#mountuifxcontainer-type-recipefactory-options)
51
+ - [decorateUIFX](#decorateuifxel-recipefactory-options)
52
+ - [The recipe registry](#the-recipe-registry)
53
+ - [Constants: UITypes, state, META](#constants-uitypes-state-meta)
54
+ - [Host clock and reduced motion](#host-clock-and-reduced-motion)
55
+ - [Composability](#composability)
56
+ - [Zero-GC design notes](#zero-gc-design-notes)
57
+ - [Design decisions worth knowing](#design-decisions-worth-knowing)
58
+ - [Testing](#testing)
59
+ - [What this is not](#what-this-is-not)
60
+ - [Ecosystem](#ecosystem)
58
61
 
59
- **How to write your own:** see [UIFX-RECIPE-GUIDE.md](UIFX-RECIPE-GUIDE.md), shipped in the package.
62
+ ---
60
63
 
61
- Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
64
+ ## Why this exists
62
65
 
63
- ## Install
66
+ Two problems no small library solves at once:
64
67
 
65
- ```bash
66
- npm i @zakkster/lite-ui-fx
67
- ```
68
+ 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
69
 
69
- > The 56 recipes ship in the same package on the `./recipes` subpath and
70
- > tree-shake, so importing one adds only that one.
70
+ 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
71
 
72
+ 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
73
 
73
- ## Quick Start
74
+ ---
74
75
 
75
- ```javascript
76
- import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
77
- import { SwarmToggle } from '@zakkster/lite-ui-fx/recipes';
76
+ ## What you get
78
77
 
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
- );
78
+ - **`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
+ - **`decorateUIFX(el, recipeFactory, options?)`** -- the decorate mount. Places a canvas *around* an existing visible element (a live `<input>`), reading `state.text`/`state.valid` from the host's own events. The host is byte-identical before and after; `destroy()` removes only the overlay.
80
+ - **56 built-in recipes** on the `./recipes` subpath, versioned, typed, and tree-shakeable. With `sideEffects: false`, importing one recipe drops the other 55. Families: Toggles (7), Buttons (9), Sliders (7), Knobs (2), Progress (4), Checkboxes (4), Loaders (2), Counters (2), Rating (1), Controls (3), Indicators (3), Mood (3), Feedback (3), Fun (3), Form decorations (3).
81
+ - **A registry for data-driven UIs** -- `RECIPES` (id -> factory, null-prototype), `RECIPE_META` (`{ id, name, type, family, themeable, motionSafe }`), `RECIPE_NAMES`, `registerRecipe(id, factory, meta)`, and `mountRecipe(container, id, options?)` which resolves the id fail-closed (did-you-mean on a typo) and mounts it as its declared type.
82
+ - **One option convention for theming** -- `{ colors, theme: { light, mid, dark }, text, font }` honoured by all 56 recipes, resolved once in `init` so a themed mount stays zero-GC and a bare mount is byte-identical to pre-theming.
83
+ - **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
+ - **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
85
 
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.
86
+ ---
87
+
88
+ ## Two mount modes and the recipe contract
89
+
90
+ <details>
91
+ <summary>How hijack and decorate differ, and the eight-hook recipe interface both share.</summary>
90
92
 
91
- // Cleanup when done:
92
- instance.destroy();
93
+ ### Hijack (`mountUIFX`)
94
+
95
+ ```
96
+ +--- wrapper div ---------------------------------+
97
+ | native element (opacity:0, z-index:2) | <- pointer, keyboard, focus, a11y
98
+ | canvas overlay (z-index:1, DPR-scaled) | <- recipe.tick() every frame
99
+ +-------------------------------------------------+
93
100
  ```
94
101
 
95
- ## Import Map
102
+ 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
103
 
97
- ```javascript
98
- // Controller (always needed)
99
- import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
104
+ ### Decorate (`decorateUIFX`)
100
105
 
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';
106
+ 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
107
 
106
- // Registry surface for data-driven pickers:
107
- import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
108
- ```
108
+ ### The recipe contract (both modes)
109
109
 
110
- ## How It Works
110
+ A recipe is a factory returning up to eight hooks. Only `tick` is required; it is the one HOT function.
111
111
 
112
- ```
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
- +--------------------------------------------------+
112
+ ```js
113
+ export function MyRecipe() {
114
+ // closed-over per-instance scratch, allocated once here (cold)
115
+ let pressScale = 1;
116
+ return {
117
+ init(ctx, w, h, padding) {}, // cold: resolve theme, build gradients, size pools
118
+ onHover(entering, state) {}, // pointer enter/leave
119
+ onClick(x, y, state) {}, // BUTTON activation
120
+ onToggle(checked, state) {}, // TOGGLE / CHECKBOX
121
+ onDrag(val, velocity, state) {}, // SLIDER / KNOB
122
+ tick(ctx, dt, now, state) { // HOT: paint one frame, allocate nothing
123
+ pressScale += (1 - pressScale) * dt * 10;
124
+ },
125
+ destroy() {}, // cold: release anything init created
126
+ };
127
+ }
131
128
  ```
132
129
 
133
- ## API
130
+ 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.
131
+
132
+ </details>
133
+
134
+ ---
135
+
136
+ ## API reference
134
137
 
135
138
  ### `mountUIFX(container, type, recipeFactory, options?)`
136
139
 
137
140
  | Parameter | Type | Description |
138
141
  |-----------|------|-------------|
139
142
  | `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
143
+ | `type` | `UIType` | One of `TOGGLE`, `BUTTON`, `SLIDER`, `CHECKBOX`, `PROGRESS`, `KNOB` |
144
+ | `recipeFactory` | `() => Recipe` | Factory; the controller calls it |
145
+ | `options` | `object?` | See below |
146
+
147
+ 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.
148
+
149
+ Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), tick(dtMs), destroy() }`.
150
+
151
+ - `setValue(v)` -- SLIDER/KNOB/PROGRESS: set `v` in `0..1` (updates the element and `state.val`, fires `onDrag` once). CHECKBOX: `setValue(null)` sets indeterminate.
152
+ - `setChecked(b)` -- TOGGLE/CHECKBOX: set checked (updates the element and `state.toggled`, fires `onToggle` once).
153
+ - `tick(dtMs)` -- driven mode only (`{ driven: true }`): paints one frame. Throws otherwise.
154
+
155
+ ```js
156
+ import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
157
+ import { SparkSlider } from '@zakkster/lite-ui-fx/recipes';
158
+
159
+ const slider = mountUIFX(document.getElementById('volume'), UIType.SLIDER, SparkSlider, { value: 0.3 });
160
+ slider.setValue(0.75); // moves the native <input type="range"> and fires onDrag once
161
+ slider.destroy();
162
+ ```
163
+
164
+ ### `decorateUIFX(el, recipeFactory, options?)`
165
+
166
+ 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).
167
+
168
+ ```js
174
169
  import { decorateUIFX } from '@zakkster/lite-ui-fx';
175
170
  import { PasswordStrength } from '@zakkster/lite-ui-fx/recipes';
176
171
 
177
172
  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
173
+ const deco = decorateUIFX(input, PasswordStrength);
174
+ // the input stays fully usable; the meter tracks what the user types
175
+ deco.destroy(); // removes ONLY the overlay; the input is untouched
181
176
  ```
182
177
 
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
- }
178
+ Built-in decorate recipes: `FocusHalo`, `ErrorShake`, `SuccessBloom`, `PasswordStrength`, `TypewriterField` (`RECIPE_META.type === 'decorate'`, so `mountRecipe(el, id)` routes them here automatically).
179
+
180
+ ### The recipe registry
181
+
182
+ ```js
183
+ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
184
+
185
+ const swarm = RECIPES.swarmToggle; // id -> factory (null-prototype map)
186
+ const toggles = RECIPE_META.filter((m) => m.family === 'Toggles');
187
+ mountRecipe(document.getElementById('picker'), 'sparkSlider', { value: 0.5 });
225
188
  ```
226
189
 
227
- ## Comparison
190
+ `mountRecipe` resolves the id fail-closed (an unknown id throws with a did-you-mean over `RECIPE_NAMES`), asserts `META.type`, and routes to `mountUIFX` or `decorateUIFX` accordingly. `registerRecipe(id, factory, meta)` adds or overrides a recipe and merges its META in place, so a live picker built off `RECIPE_META` updates itself.
228
191
 
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`** |
192
+ ### Constants: UITypes, state, META
235
193
 
236
- ## Writing Custom Recipes
194
+ | `UIType` | Native element | Recipe hook | Key state |
195
+ |----------|---------------|-------------|-----------|
196
+ | `TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
197
+ | `BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
198
+ | `SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0..1) |
199
+ | `CHECKBOX` | `<input type="checkbox">` (no `role`) | `onToggle(checked)` | `state.toggled`, `state.indeterminate` |
200
+ | `PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` (0..1) |
201
+ | `KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0..1) |
202
+ | *(decorate)* | none -- canvas around a live host | host events -> state | `state.focused`, `state.text`, `state.valid` |
237
203
 
238
- See the full [UIFX-RECIPE-GUIDE.md](UIFX-RECIPE-GUIDE.md) (included in the package).
204
+ The `state` object passed to `tick(ctx, dt, now, state)` every frame:
239
205
 
240
- Minimal recipe:
206
+ | Field | Type | Meaning |
207
+ |-------|------|---------|
208
+ | `hover` / `active` / `focused` | `boolean` | pointer inside / pressed / keyboard focus |
209
+ | `toggled` / `indeterminate` | `boolean` | checkbox and toggle state (indeterminate: CHECKBOX only) |
210
+ | `disabled` | `boolean` | mounted with `disabled` |
211
+ | `val` | `number` | slider/knob/progress value, 0..1 |
212
+ | `w` / `h` / `padding` / `dpr` | `number` | geometry and device pixel ratio |
213
+ | `reducedMotion` | `boolean` | user prefers reduced motion (matchMedia, watched) |
214
+ | `budget` | `number` | 0..1 frame budget, 1 at ~60fps, lower as frames lengthen |
215
+ | `text` / `valid` | `string` / `boolean` | decorate mode only: host value and validity |
241
216
 
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
- }
217
+ `RECIPE_META` rows: `{ id, name, type, family, themeable, motionSafe }`. `themeable` is true for all 56; `motionSafe` is true for exactly the recipes that ship a calm reduced-motion path (6 today: SwarmToggle plus the five decorate recipes) and honestly false for the rest.
218
+
219
+ ---
220
+
221
+ ## Host clock and reduced motion
222
+
223
+ 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).
224
+
225
+ ```js
226
+ import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
227
+ import { MagneticButton } from '@zakkster/lite-ui-fx/recipes';
228
+ import { Ticker } from '@zakkster/lite-ticker';
229
+
230
+ // { ticker }: hand it your game's clock. destroy() removes this component's
231
+ // frame but NEVER destroys your ticker -- ownership stays with you.
232
+ const clock = new Ticker();
233
+ const a = mountUIFX(document.getElementById('fire'), UIType.BUTTON, MagneticButton, { ticker: clock });
234
+
235
+ // { driven }: no ticker, no RAF -- you call tick(dtMs) from your own loop.
236
+ const b = mountUIFX(document.getElementById('jump'), UIType.BUTTON, MagneticButton, { driven: true });
237
+ b.tick(16.7); // paints exactly one frame, the same body the ticker would call
238
+
239
+ a.destroy();
240
+ b.destroy();
241
+ ```
242
+
243
+ 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.
244
+
245
+ ---
246
+
247
+ ## Composability
248
+
249
+ One clock, a shared theme, several components -- the shape a game or a themed dashboard actually uses:
250
+
251
+ ```js
252
+ import { mountUIFX, decorateUIFX, UIType } from '@zakkster/lite-ui-fx';
253
+ import { SwarmToggle, SparkSlider, PasswordStrength } from '@zakkster/lite-ui-fx/recipes';
254
+ import { Ticker } from '@zakkster/lite-ticker';
255
+
256
+ // 1. One clock the host owns and controls (pause it, scale it, share it).
257
+ const clock = new Ticker();
258
+
259
+ // 2. A theme object -- the { light, mid, dark } shape lite-scratch-fx also takes,
260
+ // so one object themes both packages.
261
+ const theme = { light: '#a78bfa', mid: '#7c3aed', dark: '#4c1d95' };
262
+
263
+ // 3. Mount several components on that one clock, all themed from that one object.
264
+ const mute = mountUIFX(document.getElementById('mute'), UIType.TOGGLE, SwarmToggle, { ticker: clock, theme });
265
+ const volume = mountUIFX(document.getElementById('vol'), UIType.SLIDER, SparkSlider, { ticker: clock, theme, value: 0.6 });
266
+ const pw = decorateUIFX(document.querySelector('#password'), PasswordStrength, { ticker: clock, theme });
267
+
268
+ // 4. One teardown per component; the clock is yours to keep or stop.
269
+ mute.destroy();
270
+ volume.destroy();
271
+ pw.destroy();
260
272
  ```
261
273
 
262
- ## TypeScript
274
+ 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.
275
+
276
+ ---
277
+
278
+ ## Zero-GC design notes
279
+
280
+ <details>
281
+ <summary>What the hot path allocates (nothing), and how the gate proves it.</summary>
282
+
283
+ 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.
284
+
285
+ | Hot operation | Steady-state allocations | How |
286
+ | ------------- | ------------------------ | --- |
287
+ | `tick()` fill colors | **0** | `const` color strings + `ctx.globalAlpha`, never a per-frame template literal |
288
+ | Particle recipes (Swarm, Firework, ...) | **0** | fixed `Float64Array` lanes, power-of-2 bitmask index -- no push/splice |
289
+ | Gradient recipes | **0** after `init` | gradients built in `init`, rebuilt only when a driving value crosses a threshold |
290
+ | Value-label text | **0** per frame | label strings rebuilt at ~10Hz via a frame-counter mask, not every frame |
291
+ | Pointer move / drag | **0** | arithmetic only; the bounding rect is cached on pointer-enter, not read per move |
292
+ | `init` / theme resolve | once, cold | palette, ramps, gradients, pools -- then read-only in the loop |
293
+
294
+ The `t3-frame-alloc` torture tier asserts, per recipe, **zero distinct `fillStyle` string allocations per frame at steady state** and **zero gradient constructions after `init`** -- in both a default and a themed mount, across all 56 recipes (a template-literal color fails this even when GC happens to hide it). The full harness (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`) proves **0 retained bytes, 0 major GCs, and ~0.86 B/op** across the whole mount / interact / destroy loop under `--expose-gc`:
295
+
296
+ ```
297
+ GATE leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | alloc=0.8564453125 B/op
298
+ ```
299
+
300
+ For size: the controller alone is **~5.2 KB min+gzip** (its three deps external); the full catalog of 56 recipes is **~24 KB min+gzip**, and it tree-shakes -- import one recipe and the bundler drops the other 55.
301
+
302
+ </details>
303
+
304
+ ---
305
+
306
+ ## Design decisions worth knowing
307
+
308
+ Each is an ADR under [`decisions/`](decisions/):
309
+
310
+ - **[0001](decisions/0001-recipes-position.md) -- Recipes ship inside the package.** No more copy-paste-from-a-ZIP: 56 recipes are versioned, typed, and tree-shakeable behind the `./recipes` subpath, exactly the shape the sibling fx packages use.
311
+ - **[0002](decisions/0002-recipe-options.md) -- One recipe option convention.** `{ colors, theme, text, font }` across all 56, resolved cold in `init`; defaults reproduce today's literals byte-for-byte; `text` closes the WCAG label-in-name gap.
312
+ - **[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
+ - **[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
+ - **[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
+ - **[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).
316
+
317
+ ---
318
+
319
+ ## Testing
320
+
321
+ **205 deterministic node:test cases across 18 suites, all pass**, plus a torture gate that proves 0 B/op steady state and leak-freedom.
322
+
323
+ ```bash
324
+ npm test # node:test: contract, boundary, registry, theming, reduced-motion, docs
325
+ npm run torture # @zakkster/lite-leak + lite-gc-profiler: 0 B/op steady state, gated
326
+ ```
327
+
328
+ 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.
329
+
330
+ ---
331
+
332
+ ## What this is not
263
333
 
264
- Full TypeScript declarations are included for:
334
+ - **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.
335
+ - **Not a component framework.** It paints controls; it does not do layout, routing, or state management. Bring your own.
336
+ - **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
+ - **Not a chart or data-viz library.** These are interactive *controls*, not plots. Charts are `@zakkster/lite-charts`.
338
+ - **Not an ARIA behaviour engine.** It renders; it does not own focus traps, dismiss stacks, or roving-tabindex logic. `@zakkster/lite-headless` owns behaviour, permanently -- and its 59 primitives are a decorate-mode skin target.
265
339
 
266
- - `mountUIFX`
267
- - `decorateUIFX` (+ `DecorateOptions`, `DecorateInstance`)
268
- - `UIType`
269
- - `UIFXState`
270
- - `UIFXPointer`
271
- - `UIFXRecipe`
340
+ ---
272
341
 
273
- Recipe types ship too, on the `./recipes` subpath (`UIFXRecipes.d.ts`).
342
+ ## Ecosystem
274
343
 
344
+ Part of the **@zakkster** zero-GC stack:
275
345
 
276
- ## LLM-Friendly Documentation
346
+ - [`lite-ticker`](https://www.npmjs.com/package/@zakkster/lite-ticker) -- the shared RAF scheduler; hand your own instance in via `{ ticker }`
347
+ - [`lite-lerp`](https://www.npmjs.com/package/@zakkster/lite-lerp) -- zero-dep game-math primitives (recipe easing)
348
+ - [`lite-random`](https://www.npmjs.com/package/@zakkster/lite-random) -- seeded Mulberry32 RNG (deterministic particle recipes)
349
+ - [`lite-scratch-fx`](https://www.npmjs.com/package/@zakkster/lite-scratch-fx) -- canvas scratch-reveal recipes; shares the `{ light, mid, dark }` theme shape
350
+ - [`lite-ambient-fx`](https://www.npmjs.com/package/@zakkster/lite-ambient-fx) -- fullscreen ambient backdrops (the worker-mode sibling)
351
+ - [`lite-headless`](https://www.npmjs.com/package/@zakkster/lite-headless) -- 59 ARIA-correct primitives; a decorate-mode skin target
352
+ - **`lite-ui-fx`** -- this package
277
353
 
278
- See `llms.txt` for AI-optimized metadata and the complete recipe catalog.
354
+ ---
279
355
 
280
356
  ## License
281
357
 
282
- MIT
358
+ MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
@@ -70,12 +70,44 @@ The controller provides this every frame:
70
70
  h: number, // Element height
71
71
  padding: number, // Canvas overflow padding
72
72
  dpr: number, // Device pixel ratio
73
+ reducedMotion: boolean, // U5: user prefers reduced motion (see below)
74
+ budget: number, // U5: 0--1 frame budget (1 at ~60fps, lower under load)
73
75
  // Decorate mode only (decorateUIFX): the live host's value + validity.
74
76
  text: string, // the host form-control's value string ('' if none)
75
77
  valid: boolean, // the host's validity (el.validity.valid, else true)
76
78
  }
77
79
  ```
78
80
 
81
+ ## Reduced Motion & Frame Budget (U5)
82
+
83
+ Two state fields let a recipe be a good citizen without changing the interface.
84
+
85
+ - **`state.reducedMotion`** is `true` when the user has set
86
+ `prefers-reduced-motion: reduce`. A recipe that animates should read it and
87
+ render a **static** alternative -- no continuous motion, no bursts, no shakes.
88
+ Fades and instant state changes are fine; sustained or positional motion is not.
89
+ When your recipe ships such a calm path, set its `RECIPE_META.motionSafe: true`;
90
+ that flag is a promise the calm path exists, so keep them in sync.
91
+
92
+ ```javascript
93
+ tick(c, dt, now, st) {
94
+ // full motion vs. a steady, motion-free render
95
+ const wobble = st.reducedMotion ? 0 : Math.sin(now / 200) * 4;
96
+ // ... draw using `wobble` (0 = no motion) ...
97
+ }
98
+ ```
99
+
100
+ - **`state.budget`** is `1` when frames are healthy and drops toward `0` as they
101
+ lengthen. A budget-aware recipe scales expensive work by it (fewer particles,
102
+ less glow) so it degrades before the host drops frames. Consuming it is optional.
103
+
104
+ ```javascript
105
+ const live = (this.count = Math.floor(MAX_PARTICLES * st.budget));
106
+ ```
107
+
108
+ Both are read-only per-frame numbers -- never write them, never allocate to honour
109
+ them (a branch on a boolean/number is free; a new array per frame is not).
110
+
79
111
  ## The Pointer Object
80
112
 
81
113
  ```javascript