@zakkster/lite-ui-fx 1.7.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/CHANGELOG.md +36 -0
- package/README.md +288 -212
- package/UIFXController.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ All notable changes to `@zakkster/lite-ui-fx` are documented here.
|
|
|
5
5
|
The format follows Keep a Changelog; this project adheres to Semantic
|
|
6
6
|
Versioning.
|
|
7
7
|
|
|
8
|
+
## [1.8.0] -- 2026-09-07
|
|
9
|
+
|
|
10
|
+
Documentation and demo (roadmap U6). No API, recipe, or behaviour change: the
|
|
11
|
+
module code (`UIFXController.js`, `UIFXRecipes.js`) is byte-identical to 1.7.0.
|
|
12
|
+
Closes finding U-12 (demos that reimplemented the library inline).
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `README.md` rewritten on the LiteSepforge blueprint spine: positioning H2 with
|
|
17
|
+
a runnable quick-start, table of contents, why/what-you-get, a mount-modes
|
|
18
|
+
deep-dive, an API reference with UIType/state/`RECIPE_META` constant tables, a
|
|
19
|
+
composability example, a zero-GC allocation table carrying the gated torture
|
|
20
|
+
GATE line, design-decision links, testing, what-this-is-not, ecosystem. Size
|
|
21
|
+
claims are measured (controller ~5.2 KB min+gzip, catalog of 56 recipes ~24 KB).
|
|
22
|
+
- `demo/index.html`: one demo that consumes the package. It imports only the
|
|
23
|
+
public `.` and `./recipes` entry points and generates the gallery from
|
|
24
|
+
`RECIPE_META`, mounting each recipe by its declared type. Includes a theme
|
|
25
|
+
switcher, a reduced-motion toggle, and a `#profile` forced-reflow hook
|
|
26
|
+
(dev-only, dormant unless the URL carries `#profile`; a full drive of the hot
|
|
27
|
+
paths reports `violationCount 0`).
|
|
28
|
+
- `test/docs.test.mjs`: an executable doc gate. It extracts every fenced js block
|
|
29
|
+
in `README.md`, rewrites the package specifiers to the local files, and imports
|
|
30
|
+
each under the DOM stub, so a drifted example fails CI; a control block with a
|
|
31
|
+
bad import name proves the gate can fail. The suite is now 205 node:test cases
|
|
32
|
+
across 18 suites.
|
|
33
|
+
- `decisions/0006-docs-and-demo.md`.
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
37
|
+
- The three inline demo pages (`demo/demo-lite-ui-fx.html`,
|
|
38
|
+
`demo/demo-lite-uifx-vol2.html`, `demo/demo-lite-uifx-vol3.html`) that
|
|
39
|
+
reimplemented the controller and recipes inline (U-12), replaced by the single
|
|
40
|
+
consuming `demo/index.html`.
|
|
41
|
+
- The README's three CodePen "Live Demo" links and the competitor-size comparison
|
|
42
|
+
table (unmeasured claims; the shipped demo is the showcase).
|
|
43
|
+
|
|
8
44
|
## [1.7.0] -- 2026-09-07
|
|
9
45
|
|
|
10
46
|
Host integration (roadmap U5). Two host-clock modes plus reduced-motion and a
|
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
|
[](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,348 @@
|
|
|
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
|
+
- [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
|
-
|
|
62
|
+
---
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
## Why this exists
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
Two problems no small library solves at once:
|
|
64
67
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
---
|
|
74
75
|
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
// Controller (always needed)
|
|
99
|
-
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
104
|
+
### Decorate (`decorateUIFX`)
|
|
100
105
|
|
|
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';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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` | `
|
|
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
|
|
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
|
|
179
|
-
//
|
|
180
|
-
deco.destroy();
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
+
The `state` object passed to `tick(ctx, dt, now, state)` every frame:
|
|
239
205
|
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
- `decorateUIFX` (+ `DecorateOptions`, `DecorateInstance`)
|
|
268
|
-
- `UIType`
|
|
269
|
-
- `UIFXState`
|
|
270
|
-
- `UIFXPointer`
|
|
271
|
-
- `UIFXRecipe`
|
|
340
|
+
---
|
|
272
341
|
|
|
273
|
-
|
|
342
|
+
## Ecosystem
|
|
274
343
|
|
|
344
|
+
Part of the **@zakkster** zero-GC stack:
|
|
275
345
|
|
|
276
|
-
|
|
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
|
-
|
|
354
|
+
---
|
|
279
355
|
|
|
280
356
|
## License
|
|
281
357
|
|
|
282
|
-
MIT
|
|
358
|
+
MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
package/UIFXController.js
CHANGED
|
@@ -22,7 +22,7 @@ import { Ticker } from '@zakkster/lite-ticker';
|
|
|
22
22
|
|
|
23
23
|
// Three-place version sync: this constant, package.json "version", and the
|
|
24
24
|
// VERSION line in llms.txt must always match. /release keeps them locked.
|
|
25
|
-
export const VERSION = '1.
|
|
25
|
+
export const VERSION = '1.8.0';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------
|
|
28
28
|
// SHARED TICKER (ref-counted, one RAF for all UI components)
|
package/llms.txt
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-ui-fx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Canvas-hijacked UI components with a pluggable recipe system. 50 built-in recipes across toggles, buttons, sliders, knobs, loaders, checkboxes, counters, and ratings.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|