@zakkster/lite-ui-fx 1.0.4 → 1.1.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 +142 -0
- package/LICENSE +21 -0
- package/README.md +71 -67
- package/UIFXController.d.ts +9 -0
- package/UIFXController.js +277 -55
- package/llms.txt +20 -12
- package/package.json +16 -5
- package/recipes/UIFX-RECIPE-GUIDE.md +188 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@zakkster/lite-ui-fx` are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows Keep a Changelog; this project adheres to Semantic
|
|
6
|
+
Versioning.
|
|
7
|
+
|
|
8
|
+
## [1.1.0] -- unreleased
|
|
9
|
+
|
|
10
|
+
Controller correctness: the two S1 defects (U-01, U-02) and three
|
|
11
|
+
controller-level S3s (U-09, U-10, U-11). No visual change at default mounts.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- U-01 (keyboard): a toggle activates on one Space press with exactly one
|
|
16
|
+
`onToggle`, and the native checkbox is the sole source of truth. The manual
|
|
17
|
+
keydown checked-flip -- which fired a second `onToggle` -- is removed; Enter
|
|
18
|
+
is bridged to the same native activation via `el.click()`.
|
|
19
|
+
- U-02 (loop survival): one malformed recipe can no longer freeze the page.
|
|
20
|
+
Invalid recipes are rejected at mount (fail closed -- every side effect is
|
|
21
|
+
unwound, so no orphan DOM and no leaked refcount); a `tick()` that throws
|
|
22
|
+
quarantines only that component (one `console.error`, its canvas cleared)
|
|
23
|
+
while the shared ticker and every other component keep running.
|
|
24
|
+
- U-09 (style leak): the slider-thumb `<style>` is one shared, ref-counted
|
|
25
|
+
node -- injected on first slider mount, removed when the last slider
|
|
26
|
+
unmounts; `document.head` child count nets to zero.
|
|
27
|
+
- U-10 (fail-open options): unknown option keys and unknown recipe-hook keys
|
|
28
|
+
are now errors with a did-you-mean hint; `value` must be a number in [0,1].
|
|
29
|
+
- U-11 (forced reflow): the bounding rect is cached on pointerenter and
|
|
30
|
+
refreshed on scroll/resize (passive listeners); `pointermove` does zero
|
|
31
|
+
layout reads.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Mount options `value` (slider initial, 0..1), `checked` (toggle initial),
|
|
36
|
+
and `disabled` -- each lands in the native element and `state` before the
|
|
37
|
+
first frame. New `state.disabled` for recipes to render a disabled look.
|
|
38
|
+
- DPR re-read: the canvas re-scales on a display-density change
|
|
39
|
+
(`matchMedia`, feature-detected; a silent no-op where unavailable).
|
|
40
|
+
- Torture tiers t2 (the accessibility contract) and t5 (100-component scale
|
|
41
|
+
plus the U-02 quarantine regression); two t9 controls (double-toggle,
|
|
42
|
+
validation-bypass).
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Mount validates every input before any side effect (fail closed):
|
|
47
|
+
container, options, factory, and recipe shape are checked before the DOM,
|
|
48
|
+
the shared style/ticker refcounts, or the render loop are touched.
|
|
49
|
+
|
|
50
|
+
## [1.0.5] -- 2026-09-06
|
|
51
|
+
|
|
52
|
+
Truth pass, law pass, and the torture skeleton. No runtime behaviour
|
|
53
|
+
changes: this release makes the package honest, lawful, and provable.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- U-04 (docs sell what does not ship): recipe count corrected to 50
|
|
58
|
+
everywhere (`package.json` description, `llms.txt`); the recipe-guide
|
|
59
|
+
link now points at `recipes/UIFX-RECIPE-GUIDE.md` (was a root-level
|
|
60
|
+
404); the broken import quote in `llms.txt` is repaired.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- U-08 (packaging law): restored `"sideEffects": false` (dropped by
|
|
65
|
+
mistake in 1.0.4 -- see below); added `"engines": { "node": ">=18" }`;
|
|
66
|
+
`UIFX-RECIPE-GUIDE.md` now ships in the package `files[]` so the
|
|
67
|
+
"included in the package" claim is true; `LICENSE`, `README.md`, and
|
|
68
|
+
`CHANGELOG.md` ship as well.
|
|
69
|
+
- U-08 (source law): ASCII-only sweep across the controller, recipe
|
|
70
|
+
volumes, guide, README, and `llms.txt` (em/en dashes, arrows,
|
|
71
|
+
box-drawing, emoji removed). `demo/` is exempt until U6.
|
|
72
|
+
- U-07 (test law): the vitest suite is ported to `node:test` +
|
|
73
|
+
`assert/strict`; the dead `_tickAll`/`_clear` imports are replaced by a
|
|
74
|
+
deterministic RAF stub. `vitest` removed from devDependencies.
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
|
|
78
|
+
- `export const VERSION = '1.0.5'` in `UIFXController.js` (three-place
|
|
79
|
+
version sync: `package.json`, this const, and the `llms.txt` VERSION
|
|
80
|
+
line).
|
|
81
|
+
- `test/` node:test suite plus the torture skeleton
|
|
82
|
+
(`node --expose-gc test/torture.mjs`) gated by `@zakkster/lite-leak` and
|
|
83
|
+
`@zakkster/lite-gc-profiler`.
|
|
84
|
+
|
|
85
|
+
### Known issues (see ROADMAP.md)
|
|
86
|
+
|
|
87
|
+
- U-01: Space cannot toggle a toggle; each press fires two spurious
|
|
88
|
+
`onToggle`s. (Fixed in U1.)
|
|
89
|
+
- U-02: one malformed recipe (no `tick`) throws inside the shared ticker
|
|
90
|
+
and permanently freezes every UIFX component on the page. (Fixed in U1.)
|
|
91
|
+
- U-03: "zero-GC in all built-in recipes" is false for 49 of 50 recipes.
|
|
92
|
+
(Fixed in U3.)
|
|
93
|
+
- U-05: recipes misrender at any non-default size (hardcoded geometry).
|
|
94
|
+
(Fixed in U3.)
|
|
95
|
+
- U-06: theming is half-built with three conventions; hardcoded fonts and
|
|
96
|
+
canvas labels. (Fixed in U3.)
|
|
97
|
+
- U-09: every slider mount leaks a `<style>` into `document.head`; never
|
|
98
|
+
removed on destroy. (Fixed in U1.)
|
|
99
|
+
- U-10: fail-open option surface -- unknown option keys silently ignored;
|
|
100
|
+
no initial-value options; DPR read once. (Fixed in U1.)
|
|
101
|
+
- U-11: `getBoundingClientRect()` runs on every pointermove. (Fixed in U1.)
|
|
102
|
+
- U-12: the demos reimplement the library inline instead of consuming it.
|
|
103
|
+
(Fixed in U6.)
|
|
104
|
+
- U-13: recipes are distributed as a GitHub ZIP, not shipped code.
|
|
105
|
+
(Fixed in U2.)
|
|
106
|
+
|
|
107
|
+
## [1.0.4] -- 2026-03-26
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- Metadata patch.
|
|
112
|
+
|
|
113
|
+
### Regression
|
|
114
|
+
|
|
115
|
+
- Dropped `"sideEffects": false` from `package.json` (present in 1.0.3).
|
|
116
|
+
Restored in 1.0.5.
|
|
117
|
+
|
|
118
|
+
## [1.0.3] -- 2026-03-25
|
|
119
|
+
|
|
120
|
+
### Changed
|
|
121
|
+
|
|
122
|
+
- README-only patch.
|
|
123
|
+
|
|
124
|
+
## [1.0.2] -- 2026-03-25
|
|
125
|
+
|
|
126
|
+
### Changed
|
|
127
|
+
|
|
128
|
+
- README-only patch.
|
|
129
|
+
|
|
130
|
+
## [1.0.1] -- 2026-03-25
|
|
131
|
+
|
|
132
|
+
### Changed
|
|
133
|
+
|
|
134
|
+
- README-only patch.
|
|
135
|
+
|
|
136
|
+
## [1.0.0] -- 2026-03-25
|
|
137
|
+
|
|
138
|
+
### Added
|
|
139
|
+
|
|
140
|
+
- Initial release: the `mountUIFX` controller plus 50 GitHub-hosted
|
|
141
|
+
recipes across toggles, buttons, sliders, knobs, loaders, checkboxes,
|
|
142
|
+
counters, and ratings.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zahary Shinikchiev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -8,72 +8,72 @@
|
|
|
8
8
|

|
|
9
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## What is lite-ui-fx?
|
|
12
12
|
|
|
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
|
|
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.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Live Demo (UI-FX)
|
|
16
16
|
https://cdpn.io/pen/debug/RNGjMjQ
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## Live Demo (UI-FX vol.2)
|
|
19
19
|
https://cdpn.io/pen/debug/yyaPKpB
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## Live Demo (UI-FX vol3.)
|
|
22
22
|
https://cdpn.io/pen/debug/YPGEaYY
|
|
23
23
|
|
|
24
24
|
**50 recipes** across UI element categories:
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
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
|
|
31
|
+
- **Controls** -- Pill tabs, Stepper, Radio orbit
|
|
32
|
+
- **Indicators** -- Password strength, Water level, Heat map
|
|
33
|
+
- **Mood** -- Day/night, Reaction picker, Notification bell
|
|
34
|
+
- **Feedback** -- Typewriter, Sound wave, Upload progress
|
|
35
|
+
- **Fun** -- Scratch reveal, Timer countdown, Pull refresh
|
|
36
|
+
- **Checkboxes** -- Ripple, Morph (X to check)
|
|
37
|
+
- **Loaders** -- Orbit planets, DNA helix
|
|
38
|
+
- **Counters** -- Flame heat, Glitch signal
|
|
39
|
+
- **Rating** -- Bubble inflate
|
|
40
40
|
|
|
41
41
|
Every recipe is zero-GC, uses `dt`-based animation, and includes accessibility indicators (focus rings, state labels).
|
|
42
42
|
|
|
43
|
-
`@zakkster/lite-ui-fx` ships only the core controller on npm (zero bloat).
|
|
43
|
+
`@zakkster/lite-ui-fx` ships only the core controller on npm (zero bloat).
|
|
44
44
|
All visual effects live in the GitHub repo as **recipes**.
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
- Vol. 1 (10 recipes):
|
|
48
|
-
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFXRecipes.js
|
|
49
|
-
- Vol. 2 (20 recipes):
|
|
50
|
-
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFXRecipes2.js
|
|
51
|
-
- Vol. 3 (20 recipes):
|
|
52
|
-
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFXRecipes3.js
|
|
46
|
+
**Recipe Collections:**
|
|
47
|
+
- Vol. 1 (10 recipes):
|
|
48
|
+
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFXRecipes.js
|
|
49
|
+
- Vol. 2 (20 recipes):
|
|
50
|
+
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFXRecipes2.js
|
|
51
|
+
- Vol. 3 (20 recipes):
|
|
52
|
+
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFXRecipes3.js
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/UIFX-RECIPE-GUIDE.md
|
|
54
|
+
**How to write your own:**
|
|
55
|
+
https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFX-RECIPE-GUIDE.md
|
|
56
56
|
|
|
57
|
-
Recipes are **optional**, **open-source**, and **not included in the npm package**
|
|
58
|
-
to keep the install size tiny (<2 KB).
|
|
57
|
+
Recipes are **optional**, **open-source**, and **not included in the npm package**
|
|
58
|
+
to keep the install size tiny (<2 KB).
|
|
59
59
|
You can copy/paste any recipe into your project or use them as inspiration.
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Download all recipes as a ZIP
|
|
62
62
|
https://github.com/PeshoVurtoleta/lite-ui-fx/archive/refs/heads/main.zip
|
|
63
63
|
|
|
64
64
|
Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
|
|
65
65
|
|
|
66
|
-
##
|
|
66
|
+
## Install
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
npm i @zakkster/lite-ui-fx
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
> Looking for the visual effects?
|
|
73
|
-
> Recipes live in the GitHub repo
|
|
72
|
+
> Looking for the visual effects?
|
|
73
|
+
> Recipes live in the GitHub repo -- not in the npm package -- to keep the library tiny.
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
##
|
|
76
|
+
## Quick Start
|
|
77
77
|
|
|
78
78
|
```javascript
|
|
79
79
|
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
@@ -95,7 +95,7 @@ const instance = mountUIFX(
|
|
|
95
95
|
instance.destroy();
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
##
|
|
98
|
+
## Import Map
|
|
99
99
|
|
|
100
100
|
```javascript
|
|
101
101
|
// Controller (always needed)
|
|
@@ -104,40 +104,40 @@ import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
|
104
104
|
// Recipes are NOT included in the npm package.
|
|
105
105
|
// Copy them from the GitHub repo into your own ./recipes folder:
|
|
106
106
|
|
|
107
|
-
// Vol. 1
|
|
107
|
+
// Vol. 1 -- 10 recipes (toggles, buttons, sliders)
|
|
108
108
|
import { SwarmToggle, MagneticButton, SparkSlider } from './recipes/UIFXRecipes.js';
|
|
109
109
|
|
|
110
|
-
// Vol. 2
|
|
110
|
+
// Vol. 2 -- 20 recipes (+ loaders, checkboxes, counters, rating)
|
|
111
111
|
import { PendulumToggle, HeartbeatButton, RippleCheck } from './recipes/UIFXRecipes2.js';
|
|
112
112
|
|
|
113
|
-
// Vol. 3
|
|
113
|
+
// Vol. 3 -- 20 recipes (knobs, progress, controls, indicators, mood, feedback, fun)
|
|
114
114
|
import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.js';
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
##
|
|
117
|
+
## How It Works
|
|
118
118
|
|
|
119
119
|
```
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
120
|
+
+--------------------------------------------------+
|
|
121
|
+
| mountUIFX(container, type, recipeFactory, opts) |
|
|
122
|
+
| |
|
|
123
|
+
| +---- Wrapper div ----------------------------+ |
|
|
124
|
+
| | | |
|
|
125
|
+
| | Native element (opacity:0, z-index:2) | |
|
|
126
|
+
| | -> receives pointer, keyboard, focus events | |
|
|
127
|
+
| | -> accessible to screen readers | |
|
|
128
|
+
| | | |
|
|
129
|
+
| | Canvas overlay (z-index:1, DPR-scaled) | |
|
|
130
|
+
| | -> recipe.tick() renders every frame | |
|
|
131
|
+
| | -> padding allows particle overflow | |
|
|
132
|
+
| | | |
|
|
133
|
+
| +----------------------------------------------+ |
|
|
134
|
+
| |
|
|
135
|
+
| Shared Ticker (ref-counted, one RAF for all) |
|
|
136
|
+
| AbortController (all events cleaned on destroy) |
|
|
137
|
+
+--------------------------------------------------+
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
##
|
|
140
|
+
## API
|
|
141
141
|
|
|
142
142
|
### `mountUIFX(container, type, recipeFactory, options?)`
|
|
143
143
|
|
|
@@ -150,6 +150,9 @@ import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.j
|
|
|
150
150
|
| `options.height` | `number` | Element height |
|
|
151
151
|
| `options.padding` | `number` | Canvas overflow (default: 40px) |
|
|
152
152
|
| `options.label` | `string` | Accessible label (aria-label) |
|
|
153
|
+
| `options.value` | `number` | Slider initial value, 0..1 (default 0.5); out-of-range throws |
|
|
154
|
+
| `options.checked` | `boolean` | Toggle initial state (default false) |
|
|
155
|
+
| `options.disabled` | `boolean` | Disables the native element; sets `state.disabled` |
|
|
153
156
|
|
|
154
157
|
Returns `{ el, canvas, wrapper, state, destroy() }`.
|
|
155
158
|
|
|
@@ -159,7 +162,7 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
|
|
|
159
162
|
|------|---------------|-------------|-----------|
|
|
160
163
|
| `UIType.TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
|
|
161
164
|
| `UIType.BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
|
|
162
|
-
| `UIType.SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0
|
|
165
|
+
| `UIType.SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
|
|
163
166
|
|
|
164
167
|
### State Object (provided to `tick()` every frame)
|
|
165
168
|
|
|
@@ -169,7 +172,8 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
|
|
|
169
172
|
active: boolean; // Pointer pressed
|
|
170
173
|
focused: boolean; // Keyboard focus
|
|
171
174
|
toggled: boolean; // Checkbox state
|
|
172
|
-
|
|
175
|
+
disabled: boolean; // Disabled via options.disabled
|
|
176
|
+
val: number; // Slider value (0-1)
|
|
173
177
|
w: number; // Element width
|
|
174
178
|
h: number; // Element height
|
|
175
179
|
padding: number; // Canvas padding
|
|
@@ -177,16 +181,16 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
|
|
|
177
181
|
}
|
|
178
182
|
```
|
|
179
183
|
|
|
180
|
-
##
|
|
184
|
+
## Comparison
|
|
181
185
|
|
|
182
186
|
| Library | Size | Approach | Recipes | A11y | Install |
|
|
183
187
|
|---------|------|----------|---------|------|---------|
|
|
184
188
|
| Framer Motion | ~45 KB | React HOC | 0 | Via React | `npm i framer-motion` |
|
|
185
189
|
| GSAP | ~25 KB | Timeline | 0 | Manual | `npm i gsap` |
|
|
186
190
|
| Lottie | ~55 KB | JSON animation | After Effects | Manual | `npm i lottie-web` |
|
|
187
|
-
| **lite-ui-fx** | **< 5 KB** | **Canvas hijack** |
|
|
191
|
+
| **lite-ui-fx** | **< 5 KB** | **Canvas hijack** | **50 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
|
|
188
192
|
|
|
189
|
-
##
|
|
193
|
+
## Writing Custom Recipes
|
|
190
194
|
|
|
191
195
|
See the full [UIFX-RECIPE-GUIDE.md](recipes/UIFX-RECIPE-GUIDE.md) (included in the package).
|
|
192
196
|
|
|
@@ -212,7 +216,7 @@ export function MyButton() {
|
|
|
212
216
|
}
|
|
213
217
|
```
|
|
214
218
|
|
|
215
|
-
##
|
|
219
|
+
## TypeScript
|
|
216
220
|
|
|
217
221
|
Full TypeScript declarations are included for:
|
|
218
222
|
|
|
@@ -225,7 +229,7 @@ Full TypeScript declarations are included for:
|
|
|
225
229
|
(Recipes are not part of the npm package, so their types are not included.)
|
|
226
230
|
|
|
227
231
|
|
|
228
|
-
##
|
|
232
|
+
## LLM-Friendly Documentation
|
|
229
233
|
|
|
230
234
|
See `llms.txt` for AI-optimized metadata and the complete recipe catalog.
|
|
231
235
|
|
package/UIFXController.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const VERSION: string;
|
|
2
|
+
|
|
1
3
|
export type UITypeValue = 'button' | 'toggle' | 'slider';
|
|
2
4
|
|
|
3
5
|
export declare const UIType: Readonly<{
|
|
@@ -11,6 +13,7 @@ export interface UIFXState {
|
|
|
11
13
|
active: boolean;
|
|
12
14
|
focused: boolean;
|
|
13
15
|
toggled: boolean;
|
|
16
|
+
disabled: boolean;
|
|
14
17
|
val: number;
|
|
15
18
|
w: number;
|
|
16
19
|
h: number;
|
|
@@ -50,6 +53,12 @@ export interface MountOptions {
|
|
|
50
53
|
height?: number;
|
|
51
54
|
padding?: number;
|
|
52
55
|
label?: string;
|
|
56
|
+
/** Slider initial value, 0..1 (default 0.5). Out-of-range or non-number throws. */
|
|
57
|
+
value?: number;
|
|
58
|
+
/** Toggle initial checked state (default false). */
|
|
59
|
+
checked?: boolean;
|
|
60
|
+
/** Disables the native element and sets state.disabled for recipes. */
|
|
61
|
+
disabled?: boolean;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
export interface UIFXInstance {
|
package/UIFXController.js
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-ui-fx
|
|
2
|
+
* @zakkster/lite-ui-fx -- Canvas-Hijacked UI Components
|
|
3
3
|
*
|
|
4
4
|
* Overlays a DPR-aware canvas on top of native HTML elements (buttons,
|
|
5
5
|
* checkboxes, sliders). The native element handles accessibility, focus,
|
|
6
6
|
* and events. The canvas handles visuals via a pluggable recipe system.
|
|
7
7
|
*
|
|
8
8
|
* Architecture:
|
|
9
|
-
* Native element (opacity:0, z-index:2)
|
|
10
|
-
* Canvas overlay (z-index:1)
|
|
11
|
-
* Recipe factory
|
|
9
|
+
* Native element (opacity:0, z-index:2) -- receives all pointer/keyboard events
|
|
10
|
+
* Canvas overlay (z-index:1) -- renders the visual recipe
|
|
11
|
+
* Recipe factory -> { init?, tick, onHover?, onLeave?, onClick?, onToggle?, onDrag?, destroy? }
|
|
12
12
|
*
|
|
13
13
|
* Uses:
|
|
14
|
-
* @zakkster/lite-lerp
|
|
15
|
-
* @zakkster/lite-random
|
|
16
|
-
* @zakkster/lite-color
|
|
14
|
+
* @zakkster/lite-lerp -- interpolation in recipes
|
|
15
|
+
* @zakkster/lite-random -- deterministic particle effects
|
|
16
|
+
* @zakkster/lite-color -- OKLCH color math (optional per recipe)
|
|
17
17
|
*
|
|
18
18
|
* Depends on: @zakkster/lite-ticker (shared RAF loop)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { Ticker } from '@zakkster/lite-ticker';
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Three-place version sync: this constant, package.json "version", and the
|
|
24
|
+
// VERSION line in llms.txt must always match. /release keeps them locked.
|
|
25
|
+
export const VERSION = '1.1.0';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------
|
|
24
28
|
// SHARED TICKER (ref-counted, one RAF for all UI components)
|
|
25
|
-
//
|
|
29
|
+
// ---------------------------------------------------------
|
|
26
30
|
|
|
27
31
|
let _sharedTicker = null;
|
|
28
32
|
let _sharedRefs = 0;
|
|
@@ -46,9 +50,93 @@ function releaseTicker() {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
|
|
49
|
-
//
|
|
53
|
+
// ---------------------------------------------------------
|
|
54
|
+
// SHARED SLIDER STYLE (ref-counted, one <style> for all sliders)
|
|
55
|
+
// ---------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
let _sliderStyle = null;
|
|
58
|
+
let _sliderRefs = 0;
|
|
59
|
+
|
|
60
|
+
function acquireSliderStyle() {
|
|
61
|
+
if (!_sliderStyle) {
|
|
62
|
+
_sliderStyle = document.createElement('style');
|
|
63
|
+
_sliderStyle.textContent = `
|
|
64
|
+
.uifx-slider::-webkit-slider-thumb { -webkit-appearance:none; width:24px; height:24px; cursor:grab; }
|
|
65
|
+
.uifx-slider::-moz-range-thumb { width:24px; height:24px; cursor:grab; border:none; background:transparent; }
|
|
66
|
+
`;
|
|
67
|
+
document.head.appendChild(_sliderStyle);
|
|
68
|
+
}
|
|
69
|
+
_sliderRefs++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function releaseSliderStyle() {
|
|
73
|
+
_sliderRefs--;
|
|
74
|
+
if (_sliderRefs <= 0 && _sliderStyle) {
|
|
75
|
+
_sliderStyle.remove();
|
|
76
|
+
_sliderStyle = null;
|
|
77
|
+
_sliderRefs = 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------
|
|
83
|
+
// MOUNT-TIME VALIDATION (cold path only -- never a hot body)
|
|
84
|
+
// ---------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const KNOWN_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'destroy'];
|
|
87
|
+
const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled'];
|
|
88
|
+
|
|
89
|
+
// Levenshtein edit distance. Cold: only reached on the error path.
|
|
90
|
+
function _editDistance(a, b) {
|
|
91
|
+
const al = a.length;
|
|
92
|
+
const bl = b.length;
|
|
93
|
+
if (al === 0) return bl;
|
|
94
|
+
if (bl === 0) return al;
|
|
95
|
+
let prev = new Array(bl + 1);
|
|
96
|
+
for (let j = 0; j <= bl; j++) prev[j] = j;
|
|
97
|
+
for (let i = 1; i <= al; i++) {
|
|
98
|
+
const cur = new Array(bl + 1);
|
|
99
|
+
cur[0] = i;
|
|
100
|
+
for (let j = 1; j <= bl; j++) {
|
|
101
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
102
|
+
let m = prev[j] + 1;
|
|
103
|
+
const del = cur[j - 1] + 1;
|
|
104
|
+
if (del < m) m = del;
|
|
105
|
+
const sub = prev[j - 1] + cost;
|
|
106
|
+
if (sub < m) m = sub;
|
|
107
|
+
cur[j] = m;
|
|
108
|
+
}
|
|
109
|
+
prev = cur;
|
|
110
|
+
}
|
|
111
|
+
return prev[bl];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Nearest known key within edit distance <=2, else one sharing a >=2-char
|
|
115
|
+
// prefix, else null. Cold path.
|
|
116
|
+
function _suggest(name, known) {
|
|
117
|
+
let best = null;
|
|
118
|
+
let bestD = Infinity;
|
|
119
|
+
for (let i = 0; i < known.length; i++) {
|
|
120
|
+
const d = _editDistance(name, known[i]);
|
|
121
|
+
if (d < bestD) { bestD = d; best = known[i]; }
|
|
122
|
+
}
|
|
123
|
+
if (bestD <= 2) return best;
|
|
124
|
+
const head = name.length >= 2 ? name.slice(0, 2) : name;
|
|
125
|
+
for (let i = 0; i < known.length; i++) {
|
|
126
|
+
if (known[i].indexOf(head) === 0) return known[i];
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _didYouMean(prefix, name, known) {
|
|
132
|
+
const s = _suggest(name, known);
|
|
133
|
+
return prefix + ' "' + name + '"' + (s ? '. Did you mean "' + s + '"?' : '');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------
|
|
50
138
|
// ELEMENT TYPES
|
|
51
|
-
//
|
|
139
|
+
// ---------------------------------------------------------
|
|
52
140
|
|
|
53
141
|
/** @enum {string} */
|
|
54
142
|
export const UIType = Object.freeze({
|
|
@@ -58,9 +146,9 @@ export const UIType = Object.freeze({
|
|
|
58
146
|
});
|
|
59
147
|
|
|
60
148
|
|
|
61
|
-
//
|
|
62
|
-
// UIFXController
|
|
63
|
-
//
|
|
149
|
+
// =========================================================
|
|
150
|
+
// UIFXController -- The Canvas Hijacker
|
|
151
|
+
// =========================================================
|
|
64
152
|
|
|
65
153
|
/**
|
|
66
154
|
* Mount a canvas-rendered recipe onto a native HTML element.
|
|
@@ -75,34 +163,109 @@ export const UIType = Object.freeze({
|
|
|
75
163
|
* @param {string} [options.label] Accessible label for the element
|
|
76
164
|
* @returns {{ el: HTMLElement, destroy: Function }}
|
|
77
165
|
*/
|
|
78
|
-
export function mountUIFX(container, type, recipeFactory, {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
166
|
+
export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
167
|
+
// =====================================================================
|
|
168
|
+
// PHASE 1 -- VALIDATION ONLY. No side effect runs until every check
|
|
169
|
+
// below has passed: no createElement, no appendChild, no
|
|
170
|
+
// acquireSliderStyle, no ticker acquire, no recipe.init. A rejected
|
|
171
|
+
// mount must leave the DOM and every shared refcount exactly as it
|
|
172
|
+
// found them (fail closed -- BLOCKER 1).
|
|
173
|
+
// =====================================================================
|
|
174
|
+
|
|
175
|
+
// 1. container
|
|
176
|
+
if (!container || typeof container.appendChild !== 'function') {
|
|
177
|
+
throw new Error('mountUIFX: container must be a DOM element');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. options: unknown keys -> did-you-mean; value/checked/disabled
|
|
181
|
+
// validated and coerced HERE, before any element exists.
|
|
182
|
+
for (const k in options) {
|
|
183
|
+
if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
|
|
184
|
+
if (KNOWN_OPTIONS.indexOf(k) === -1) {
|
|
185
|
+
throw new Error(_didYouMean('mountUIFX: unknown option', k, KNOWN_OPTIONS));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const value = options.value;
|
|
189
|
+
if (value !== undefined &&
|
|
190
|
+
(typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1)) {
|
|
191
|
+
// null is not zero: an out-of-range or non-numeric value is an error,
|
|
192
|
+
// never a silent coercion.
|
|
193
|
+
throw new Error('mountUIFX: option "value" must be a number in [0,1]');
|
|
194
|
+
}
|
|
195
|
+
const checked = options.checked === undefined ? false : !!options.checked;
|
|
196
|
+
const disabled = options.disabled === undefined ? false : !!options.disabled;
|
|
197
|
+
const width = options.width;
|
|
198
|
+
const height = options.height;
|
|
199
|
+
const padding = options.padding === undefined ? 40 : options.padding;
|
|
200
|
+
const label = options.label === undefined ? '' : options.label;
|
|
201
|
+
|
|
202
|
+
// 3. recipeFactory
|
|
203
|
+
if (typeof recipeFactory !== 'function') {
|
|
204
|
+
throw new Error('mountUIFX: recipeFactory must be a function');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. recipe object + hooks. Created now so a bad recipe throws BEFORE any
|
|
208
|
+
// DOM/refcount side effect; .init is deferred to phase 2 (needs ctx).
|
|
209
|
+
const recipe = recipeFactory();
|
|
210
|
+
if (!recipe || typeof recipe !== 'object') {
|
|
211
|
+
throw new Error('mountUIFX: recipe must be an object');
|
|
212
|
+
}
|
|
213
|
+
if (typeof recipe.tick !== 'function') {
|
|
214
|
+
throw new Error('mountUIFX: recipe.tick must be a function');
|
|
215
|
+
}
|
|
216
|
+
for (const k in recipe) {
|
|
217
|
+
if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
|
|
218
|
+
if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
|
|
219
|
+
throw new Error(_didYouMean('mountUIFX: unknown recipe hook', k, KNOWN_HOOKS));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// =====================================================================
|
|
224
|
+
// PHASE 2 -- SIDE EFFECTS. Every check above has passed; only now do
|
|
225
|
+
// we allocate DOM, bump refcounts, and wire events.
|
|
226
|
+
//
|
|
227
|
+
// This region is ALSO fail-closed: if any step throws (realistically a
|
|
228
|
+
// user recipe.init, but anything here), we UNWIND every side effect that
|
|
229
|
+
// actually landed -- in reverse acquisition order, each guarded by its own
|
|
230
|
+
// flag so nothing underflows a refcount or double-frees -- then re-throw
|
|
231
|
+
// the ORIGINAL error. A try/catch is free on the success path; this is all
|
|
232
|
+
// cold mount code with zero hot-path impact.
|
|
233
|
+
// =====================================================================
|
|
234
|
+
|
|
235
|
+
let styleAcquired = false; // acquireSliderStyle() bumped _sliderRefs
|
|
236
|
+
let wrapperAppended = false; // wrapper is in container.children
|
|
237
|
+
let acCreated = false; // AbortController exists (listeners may be on it)
|
|
238
|
+
let tickerAcquired = false; // acquireTicker() bumped _sharedRefs
|
|
239
|
+
let wrapper = null;
|
|
240
|
+
let ac = null;
|
|
241
|
+
let removeTick = null;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
// -- Resolve dimensions --
|
|
85
245
|
const w = width || (type === UIType.BUTTON ? 160 : type === UIType.SLIDER ? 200 : 64);
|
|
86
246
|
const h = height || (type === UIType.BUTTON ? 48 : type === UIType.SLIDER ? 28 : 36);
|
|
87
|
-
|
|
247
|
+
let dpr = window.devicePixelRatio || 1;
|
|
88
248
|
|
|
89
|
-
//
|
|
249
|
+
// -- Create native element (invisible, accessible, receives events) --
|
|
90
250
|
let el;
|
|
91
251
|
if (type === UIType.TOGGLE) {
|
|
92
252
|
el = document.createElement('input');
|
|
93
253
|
el.type = 'checkbox';
|
|
94
254
|
el.setAttribute('role', 'switch');
|
|
255
|
+
el.checked = checked; // coerced boolean; lands before frame 1
|
|
95
256
|
if (label) el.setAttribute('aria-label', label);
|
|
96
257
|
} else if (type === UIType.SLIDER) {
|
|
97
258
|
el = document.createElement('input');
|
|
98
259
|
el.type = 'range';
|
|
99
|
-
el.min = '0'; el.max = '100';
|
|
260
|
+
el.min = '0'; el.max = '100';
|
|
261
|
+
el.value = value !== undefined ? String(value * 100) : '50'; // 0..1 -> 0..100
|
|
100
262
|
if (label) el.setAttribute('aria-label', label);
|
|
101
263
|
} else {
|
|
102
264
|
el = document.createElement('button');
|
|
103
265
|
el.textContent = label || 'Action';
|
|
104
266
|
el.type = 'button';
|
|
105
267
|
}
|
|
268
|
+
if (disabled) el.disabled = true; // lands before frame 1
|
|
106
269
|
|
|
107
270
|
Object.assign(el.style, {
|
|
108
271
|
position: 'relative', zIndex: '2',
|
|
@@ -113,18 +276,16 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
113
276
|
WebkitAppearance: 'none', appearance: 'none',
|
|
114
277
|
});
|
|
115
278
|
|
|
116
|
-
// Slider thumb needs explicit sizing for hit area
|
|
279
|
+
// Slider thumb needs explicit sizing for hit area. One shared, ref-counted
|
|
280
|
+
// <style> for all sliders (U-09): released in destroy() when the last slider
|
|
281
|
+
// goes -- head child count nets to zero across mount/destroy.
|
|
117
282
|
if (type === UIType.SLIDER) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
.uifx-slider::-webkit-slider-thumb { -webkit-appearance:none; width:24px; height:24px; cursor:grab; }
|
|
121
|
-
.uifx-slider::-moz-range-thumb { width:24px; height:24px; cursor:grab; border:none; background:transparent; }
|
|
122
|
-
`;
|
|
123
|
-
document.head.appendChild(thumbCSS);
|
|
283
|
+
acquireSliderStyle();
|
|
284
|
+
styleAcquired = true;
|
|
124
285
|
el.classList.add('uifx-slider');
|
|
125
286
|
}
|
|
126
287
|
|
|
127
|
-
//
|
|
288
|
+
// -- Create canvas overlay (DPR-aware) --
|
|
128
289
|
const canvas = document.createElement('canvas');
|
|
129
290
|
const cw = w + padding * 2;
|
|
130
291
|
const ch = h + padding * 2;
|
|
@@ -140,8 +301,8 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
140
301
|
const ctx = canvas.getContext('2d');
|
|
141
302
|
ctx.scale(dpr, dpr);
|
|
142
303
|
|
|
143
|
-
//
|
|
144
|
-
|
|
304
|
+
// -- Wrapper --
|
|
305
|
+
wrapper = document.createElement('div');
|
|
145
306
|
Object.assign(wrapper.style, {
|
|
146
307
|
position: 'relative', display: 'inline-block',
|
|
147
308
|
width: `${w}px`, height: `${h}px`,
|
|
@@ -149,40 +310,58 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
149
310
|
wrapper.appendChild(el);
|
|
150
311
|
wrapper.appendChild(canvas);
|
|
151
312
|
container.appendChild(wrapper);
|
|
313
|
+
wrapperAppended = true;
|
|
152
314
|
|
|
153
|
-
//
|
|
315
|
+
// -- State (value/checked/disabled land here BEFORE frame 1) --
|
|
154
316
|
const state = {
|
|
155
317
|
hover: false,
|
|
156
318
|
active: false, // pointer is down
|
|
157
319
|
focused: false, // keyboard focus
|
|
158
|
-
toggled:
|
|
159
|
-
|
|
320
|
+
toggled: checked, // coerced boolean; element + state AGREE
|
|
321
|
+
disabled, // recipes can render a disabled look
|
|
322
|
+
val: value !== undefined ? value : (type === UIType.SLIDER ? 0.5 : 0), // 0-1
|
|
160
323
|
w, h, padding, dpr,
|
|
161
324
|
};
|
|
162
325
|
|
|
163
326
|
const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
|
|
327
|
+
// Cached bounding rect. Refreshed on pointerenter + scroll/resize (cold);
|
|
328
|
+
// pointermove reads it with ZERO layout reads (U-11). null until the first
|
|
329
|
+
// pointer event, then lazily filled once (see updatePointer).
|
|
330
|
+
let rect = null;
|
|
164
331
|
|
|
165
|
-
//
|
|
166
|
-
|
|
332
|
+
// -- Initialize recipe (already validated in phase 1: object, tick fn,
|
|
333
|
+
// only known hooks). ctx exists now, so init can run. --
|
|
167
334
|
if (recipe.init) recipe.init(ctx, w, h, padding);
|
|
168
335
|
|
|
169
|
-
//
|
|
170
|
-
|
|
336
|
+
// -- Events (all via AbortController) --
|
|
337
|
+
ac = new AbortController();
|
|
338
|
+
acCreated = true;
|
|
171
339
|
const signal = ac.signal;
|
|
172
340
|
|
|
173
341
|
function updatePointer(e) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
342
|
+
// Lazily fill the rect on the first pointer event (e.g. a pointerdown
|
|
343
|
+
// with no prior pointerenter). Fires getBoundingClientRect at most once
|
|
344
|
+
// until the next scroll/resize/enter nulls or refreshes it -- steady-
|
|
345
|
+
// state pointermove does ZERO layout reads (U-11 / NIT 1).
|
|
346
|
+
if (!rect) rect = el.getBoundingClientRect();
|
|
347
|
+
const nx = e.clientX - rect.left;
|
|
348
|
+
const ny = e.clientY - rect.top;
|
|
177
349
|
pointer.vx = nx - pointer.x;
|
|
178
350
|
pointer.vy = ny - pointer.y;
|
|
179
351
|
pointer.x = nx;
|
|
180
352
|
pointer.y = ny;
|
|
181
353
|
}
|
|
354
|
+
function refreshRect() { rect = el.getBoundingClientRect(); }
|
|
355
|
+
|
|
356
|
+
// Rect invalidation on layout shift -- cold path, through ac.signal so
|
|
357
|
+
// destroy()'s abort removes them (no orphaned window listeners).
|
|
358
|
+
window.addEventListener('scroll', refreshRect, { passive: true, signal });
|
|
359
|
+
window.addEventListener('resize', refreshRect, { passive: true, signal });
|
|
182
360
|
|
|
183
361
|
el.addEventListener('pointermove', updatePointer, { signal });
|
|
184
362
|
el.addEventListener('pointerenter', (e) => {
|
|
185
363
|
state.hover = true;
|
|
364
|
+
refreshRect(); // one layout read per enter
|
|
186
365
|
updatePointer(e);
|
|
187
366
|
if (recipe.onHover) recipe.onHover(state, pointer);
|
|
188
367
|
}, { signal });
|
|
@@ -207,13 +386,11 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
207
386
|
state.toggled = el.checked;
|
|
208
387
|
if (recipe.onToggle) recipe.onToggle(state.toggled, state);
|
|
209
388
|
}, { signal });
|
|
210
|
-
//
|
|
389
|
+
// Space activates the checkbox natively (browser fires click -> change ->
|
|
390
|
+
// the listener above). Enter is not native for a checkbox; route it
|
|
391
|
+
// through the SAME activation path so there is one onToggle per press.
|
|
211
392
|
el.addEventListener('keydown', (e) => {
|
|
212
|
-
if (e.code === '
|
|
213
|
-
el.checked = !el.checked;
|
|
214
|
-
state.toggled = el.checked;
|
|
215
|
-
if (recipe.onToggle) recipe.onToggle(state.toggled, state);
|
|
216
|
-
}
|
|
393
|
+
if (e.code === 'Enter') el.click();
|
|
217
394
|
}, { signal });
|
|
218
395
|
}
|
|
219
396
|
|
|
@@ -225,12 +402,29 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
225
402
|
}, { signal });
|
|
226
403
|
}
|
|
227
404
|
|
|
228
|
-
//
|
|
405
|
+
// -- DPR re-read on display change (cold, feature-detected). Absent
|
|
406
|
+
// matchMedia is a silent no-op: the canvas stays at mount DPR (fail
|
|
407
|
+
// closed, never throw). Listener bound to signal for teardown. --
|
|
408
|
+
if (typeof window.matchMedia === 'function') {
|
|
409
|
+
const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
|
|
410
|
+
mq.addEventListener('change', () => {
|
|
411
|
+
const nd = window.devicePixelRatio || 1;
|
|
412
|
+
dpr = nd;
|
|
413
|
+
canvas.width = cw * nd;
|
|
414
|
+
canvas.height = ch * nd;
|
|
415
|
+
ctx.setTransform(nd, 0, 0, nd, 0, 0);
|
|
416
|
+
state.dpr = nd;
|
|
417
|
+
}, { signal });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// -- Render loop (shared ticker) --
|
|
229
421
|
const ticker = acquireTicker();
|
|
422
|
+
tickerAcquired = true;
|
|
230
423
|
let destroyed = false;
|
|
424
|
+
let quarantined = false; // a recipe.tick throw quarantines only this one
|
|
231
425
|
|
|
232
|
-
|
|
233
|
-
if (destroyed) return;
|
|
426
|
+
removeTick = ticker.add((dtMs) => {
|
|
427
|
+
if (destroyed || quarantined) return;
|
|
234
428
|
const dt = dtMs / 1000;
|
|
235
429
|
const now = performance.now();
|
|
236
430
|
|
|
@@ -238,11 +432,21 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
238
432
|
ctx.clearRect(0, 0, cw, ch);
|
|
239
433
|
ctx.save();
|
|
240
434
|
ctx.translate(padding, padding); // Origin = native element's top-left
|
|
241
|
-
|
|
435
|
+
try {
|
|
436
|
+
recipe.tick(ctx, dt, now, state, pointer);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
// U-02B: contain the throw. The shared Ticker never sees it, so its
|
|
439
|
+
// RAF reschedules and every other component keeps running.
|
|
440
|
+
quarantined = true;
|
|
441
|
+
console.error('mountUIFX: recipe.tick threw for type "' + type + '"; component quarantined', err);
|
|
442
|
+
ctx.restore();
|
|
443
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
242
446
|
ctx.restore();
|
|
243
447
|
});
|
|
244
448
|
|
|
245
|
-
//
|
|
449
|
+
// -- Public API --
|
|
246
450
|
return {
|
|
247
451
|
/** The native HTML element (for external state reads). */
|
|
248
452
|
el,
|
|
@@ -264,9 +468,27 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
264
468
|
removeTick();
|
|
265
469
|
if (recipe.destroy) recipe.destroy();
|
|
266
470
|
releaseTicker();
|
|
471
|
+
if (type === UIType.SLIDER) releaseSliderStyle();
|
|
267
472
|
wrapper.remove();
|
|
268
473
|
},
|
|
269
474
|
};
|
|
475
|
+
} catch (err) {
|
|
476
|
+
// A step in phase 2 threw (realistically recipe.init -- user code).
|
|
477
|
+
// Unwind ONLY what was actually acquired, in reverse acquisition order,
|
|
478
|
+
// each guarded by its flag so an early throw (e.g. at init, before ac /
|
|
479
|
+
// ticker exist) never releases a ticker or aborts an ac that was never
|
|
480
|
+
// created. recipe.destroy() is deliberately NOT called: init did not
|
|
481
|
+
// succeed, so there is no initialised recipe to tear down.
|
|
482
|
+
if (tickerAcquired) {
|
|
483
|
+
if (removeTick) removeTick();
|
|
484
|
+
releaseTicker();
|
|
485
|
+
}
|
|
486
|
+
if (acCreated) ac.abort();
|
|
487
|
+
if (wrapperAppended) wrapper.remove();
|
|
488
|
+
if (styleAcquired) releaseSliderStyle();
|
|
489
|
+
// Re-throw the ORIGINAL error, preserved verbatim (never wrapped).
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
270
492
|
}
|
|
271
493
|
|
|
272
494
|
export default mountUIFX;
|
package/llms.txt
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @zakkster/lite-ui-fx
|
|
2
|
-
> Canvas-hijacked UI components with pluggable recipe system.
|
|
2
|
+
> Canvas-hijacked UI components with pluggable recipe system. 50 built-in recipes.
|
|
3
|
+
|
|
4
|
+
VERSION 1.1.0
|
|
3
5
|
|
|
4
6
|
## Install
|
|
5
7
|
npm i @zakkster/lite-ui-fx
|
|
@@ -9,11 +11,11 @@ Native HTML element (opacity:0, z-index:2) handles events + a11y.
|
|
|
9
11
|
Canvas overlay (z-index:1) renders visuals via a recipe factory function.
|
|
10
12
|
Recipe = { tick(), init?(), onHover?(), onClick?(), onToggle?(), onDrag?(), destroy?() }
|
|
11
13
|
|
|
12
|
-
## Import
|
|
14
|
+
## Import -- Controller
|
|
13
15
|
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
14
16
|
|
|
15
|
-
## Import
|
|
16
|
-
import { SwarmToggle, MagneticButton, SparkSlider } from ./recipes/UIFXRecipes.js';
|
|
17
|
+
## Import -- Recipes (3 volumes, 50 total)
|
|
18
|
+
import { SwarmToggle, MagneticButton, SparkSlider } from './recipes/UIFXRecipes.js';
|
|
17
19
|
import { PendulumToggle, HeartbeatButton, AuroraSlider } from './recipes/UIFXRecipes2.js';
|
|
18
20
|
import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.js';
|
|
19
21
|
|
|
@@ -21,22 +23,28 @@ import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.j
|
|
|
21
23
|
const instance = mountUIFX(container, UIType.TOGGLE, SwarmToggle, { label: 'Sound' });
|
|
22
24
|
instance.destroy(); // cleanup
|
|
23
25
|
|
|
26
|
+
## Options (4th arg; unknown option or recipe-hook keys throw a did-you-mean -- fail closed)
|
|
27
|
+
width, height, padding=40, label // geometry + accessible label
|
|
28
|
+
value // slider start, number 0..1 (default 0.5); out-of-range or non-number throws
|
|
29
|
+
checked // toggle start, boolean (default false)
|
|
30
|
+
disabled // native disabled attr + state.disabled for recipes to render
|
|
31
|
+
|
|
24
32
|
## Element Types
|
|
25
|
-
UIType.TOGGLE
|
|
26
|
-
UIType.BUTTON
|
|
27
|
-
UIType.SLIDER
|
|
33
|
+
UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
|
|
34
|
+
UIType.BUTTON -> <button> -> state.active, onClick(x, y, state)
|
|
35
|
+
UIType.SLIDER -> <input type="range"> -> state.val (0-1), onDrag(val, velocity)
|
|
28
36
|
|
|
29
37
|
## State Object (provided to tick every frame)
|
|
30
|
-
{ hover, active, focused, toggled, val, w, h, padding, dpr }
|
|
38
|
+
{ hover, active, focused, toggled, disabled, val, w, h, padding, dpr }
|
|
31
39
|
|
|
32
|
-
##
|
|
40
|
+
## 50 Built-in Recipes
|
|
33
41
|
|
|
34
|
-
### Vol. 1 (UIFXRecipes.js)
|
|
42
|
+
### Vol. 1 (UIFXRecipes.js) -- 10 recipes
|
|
35
43
|
Toggles: SwarmToggle, LiquidToggle, NeonPulseToggle
|
|
36
44
|
Buttons: MagneticButton, ShatterButton, ConfettiButton, GlitchButton
|
|
37
45
|
Sliders: SparkSlider, CosmicSlider, LaserSlider
|
|
38
46
|
|
|
39
|
-
### Vol. 2 (UIFXRecipes2.js)
|
|
47
|
+
### Vol. 2 (UIFXRecipes2.js) -- 20 recipes
|
|
40
48
|
Toggles: PendulumToggle, CircuitToggle, LightningToggle, DNAToggle
|
|
41
49
|
Buttons: HeartbeatButton, BreathingButton, InkSplashButton, PixelDissolveButton, FireworkButton
|
|
42
50
|
Sliders: AuroraSlider, WaveSlider, ElasticBandSlider, GravitySlider
|
|
@@ -45,7 +53,7 @@ Checkboxes: RippleCheck, MorphCheck
|
|
|
45
53
|
Counters: FlameCounter, GlitchCounter
|
|
46
54
|
Rating: BubbleRating
|
|
47
55
|
|
|
48
|
-
### Vol. 3 (UIFXRecipes3.js)
|
|
56
|
+
### Vol. 3 (UIFXRecipes3.js) -- 20 recipes
|
|
49
57
|
Knobs: VolumeKnob, CompassKnob
|
|
50
58
|
Progress: RingProgress, BatteryGauge, SignalMeter
|
|
51
59
|
Controls: PillTabs, Stepper, RadioOrbit
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-ui-fx",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Canvas-hijacked UI components with a pluggable recipe system.
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
|
7
7
|
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
8
9
|
"main": "UIFXController.js",
|
|
9
10
|
"types": "UIFXController.d.ts",
|
|
10
11
|
"exports": {
|
|
@@ -16,7 +17,11 @@
|
|
|
16
17
|
"files": [
|
|
17
18
|
"UIFXController.js",
|
|
18
19
|
"UIFXController.d.ts",
|
|
19
|
-
"llms.txt"
|
|
20
|
+
"llms.txt",
|
|
21
|
+
"recipes/UIFX-RECIPE-GUIDE.md",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
20
25
|
],
|
|
21
26
|
"keywords": [
|
|
22
27
|
"ui",
|
|
@@ -32,16 +37,22 @@
|
|
|
32
37
|
"game",
|
|
33
38
|
"a11y"
|
|
34
39
|
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
35
43
|
"dependencies": {
|
|
36
44
|
"@zakkster/lite-ticker": "^1.0.0",
|
|
37
45
|
"@zakkster/lite-lerp": "^1.0.0",
|
|
38
46
|
"@zakkster/lite-random": "^1.0.0"
|
|
39
47
|
},
|
|
40
48
|
"devDependencies": {
|
|
41
|
-
"
|
|
49
|
+
"@zakkster/lite-gc-profiler": "^1.16.0",
|
|
50
|
+
"@zakkster/lite-leak": "^1.10.0",
|
|
51
|
+
"@zakkster/lite-signal": "^1.5.1"
|
|
42
52
|
},
|
|
43
53
|
"scripts": {
|
|
44
|
-
"test": "
|
|
54
|
+
"test": "node --test test/*.test.mjs",
|
|
55
|
+
"torture": "node --expose-gc test/torture.mjs"
|
|
45
56
|
},
|
|
46
57
|
"homepage": "https://github.com/PeshoVurtoleta/lite-ui-fx#readme",
|
|
47
58
|
"repository": {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Writing a UI-FX Recipe
|
|
2
|
+
|
|
3
|
+
A recipe is a plain factory function that returns an object with a `tick()` method and optional lifecycle hooks. The UIFXController handles events, DPR scaling, and the render loop -- the recipe just draws.
|
|
4
|
+
|
|
5
|
+
## Minimal Recipe (copy-paste starter)
|
|
6
|
+
|
|
7
|
+
```javascript
|
|
8
|
+
export function MyToggle() {
|
|
9
|
+
let knobX = 18;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
tick(ctx, dt, now, state, pointer) {
|
|
13
|
+
const { w, h, toggled } = state;
|
|
14
|
+
|
|
15
|
+
// Animate knob position
|
|
16
|
+
const target = toggled ? w - 18 : 18;
|
|
17
|
+
knobX += (target - knobX) * dt * 12;
|
|
18
|
+
|
|
19
|
+
// Draw track
|
|
20
|
+
ctx.fillStyle = toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
|
|
21
|
+
ctx.beginPath();
|
|
22
|
+
ctx.roundRect(0, 0, w, h, h / 2);
|
|
23
|
+
ctx.fill();
|
|
24
|
+
|
|
25
|
+
// Draw knob
|
|
26
|
+
ctx.fillStyle = toggled ? '#6ee7b6' : '#999';
|
|
27
|
+
ctx.beginPath();
|
|
28
|
+
ctx.arc(knobX, h / 2, 14, 0, Math.PI * 2);
|
|
29
|
+
ctx.fill();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## The Recipe Interface
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
interface Recipe {
|
|
39
|
+
// REQUIRED -- called every frame (60fps)
|
|
40
|
+
tick(ctx: CanvasRenderingContext2D, dt: number, now: number, state: State, pointer: Pointer): void;
|
|
41
|
+
|
|
42
|
+
// OPTIONAL -- called once after mount
|
|
43
|
+
init?(ctx: CanvasRenderingContext2D, w: number, h: number, padding: number): void;
|
|
44
|
+
|
|
45
|
+
// OPTIONAL -- event hooks
|
|
46
|
+
onHover?(state: State, pointer: Pointer): void;
|
|
47
|
+
onLeave?(state: State, pointer: Pointer): void;
|
|
48
|
+
onClick?(x: number, y: number, state: State): void;
|
|
49
|
+
onToggle?(checked: boolean, state: State): void; // Toggles only
|
|
50
|
+
onDrag?(value: number, velocity: number, state: State): void; // Sliders only
|
|
51
|
+
|
|
52
|
+
// OPTIONAL -- cleanup
|
|
53
|
+
destroy?(): void;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## The State Object
|
|
58
|
+
|
|
59
|
+
The controller provides this every frame:
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
{
|
|
63
|
+
hover: boolean, // Pointer is inside the element
|
|
64
|
+
active: boolean, // Pointer is pressed down
|
|
65
|
+
focused: boolean, // Element has keyboard focus
|
|
66
|
+
toggled: boolean, // Checkbox checked state (toggles)
|
|
67
|
+
val: number, // 0--1 slider value (sliders)
|
|
68
|
+
w: number, // Element width in CSS pixels
|
|
69
|
+
h: number, // Element height
|
|
70
|
+
padding: number, // Canvas overflow padding
|
|
71
|
+
dpr: number, // Device pixel ratio
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## The Pointer Object
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
{
|
|
79
|
+
x: number, // X position relative to element's top-left
|
|
80
|
+
y: number, // Y position relative to element's top-left
|
|
81
|
+
vx: number, // X velocity (pixels since last move)
|
|
82
|
+
vy: number, // Y velocity
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Coordinate System
|
|
87
|
+
|
|
88
|
+
The canvas is larger than the element (by `padding` on each side) to allow particles to overflow. The controller translates the context so that `(0, 0)` is the element's top-left corner. You draw as if the element starts at origin.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
Canvas memory:
|
|
92
|
+
+-------------------------------+
|
|
93
|
+
| padding |
|
|
94
|
+
| +-------------------+ |
|
|
95
|
+
| | (0,0) (w,0)| |
|
|
96
|
+
| | | |
|
|
97
|
+
| | Your drawing space | |
|
|
98
|
+
| | | |
|
|
99
|
+
| | (0,h) (w,h)| |
|
|
100
|
+
| +-------------------+ |
|
|
101
|
+
| padding |
|
|
102
|
+
+-------------------------------+
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Particles that fly outside `(0,0)--(w,h)` are visible because the canvas extends by `padding` in each direction. Default padding is 40px.
|
|
106
|
+
|
|
107
|
+
## Mounting a Recipe
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
import { mountUIFX, UIType } from './UIFXController.js';
|
|
111
|
+
import { MyToggle } from './my-recipe.js';
|
|
112
|
+
|
|
113
|
+
const instance = mountUIFX(
|
|
114
|
+
document.getElementById('container'),
|
|
115
|
+
UIType.TOGGLE,
|
|
116
|
+
MyToggle, // Factory function (NOT called -- the controller calls it)
|
|
117
|
+
{ label: 'Dark mode', width: 64, height: 36 }
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Later:
|
|
121
|
+
instance.destroy();
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Three Element Types
|
|
125
|
+
|
|
126
|
+
| Type | Native Element | Recipe Gets | Key State |
|
|
127
|
+
|------|----------------|-------------|-----------|
|
|
128
|
+
| `UIType.TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
|
|
129
|
+
| `UIType.BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
|
|
130
|
+
| `UIType.SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0--1) |
|
|
131
|
+
|
|
132
|
+
## Using @zakkster Libraries in Recipes
|
|
133
|
+
|
|
134
|
+
Recipes can import any @zakkster library:
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
import { lerp, clamp, easeOut } from '@zakkster/lite-lerp';
|
|
138
|
+
import { Random } from '@zakkster/lite-random';
|
|
139
|
+
import { toCssOklch, lerpOklch } from '@zakkster/lite-color';
|
|
140
|
+
|
|
141
|
+
export function OklchToggle() {
|
|
142
|
+
const rng = new Random(42);
|
|
143
|
+
const off = { l: 0.4, c: 0.05, h: 250 };
|
|
144
|
+
const on = { l: 0.7, c: 0.2, h: 160 };
|
|
145
|
+
let t = 0;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
tick(ctx, dt, now, state) {
|
|
149
|
+
t = lerp(t, state.toggled ? 1 : 0, dt * 8);
|
|
150
|
+
const color = lerpOklch(off, on, easeOut(t));
|
|
151
|
+
|
|
152
|
+
ctx.fillStyle = toCssOklch(color);
|
|
153
|
+
ctx.beginPath();
|
|
154
|
+
ctx.roundRect(0, 0, state.w, state.h, state.h / 2);
|
|
155
|
+
ctx.fill();
|
|
156
|
+
// ...
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Accessibility Checklist
|
|
163
|
+
|
|
164
|
+
The controller makes the native element invisible but fully accessible. Your recipe should add visual feedback:
|
|
165
|
+
|
|
166
|
+
1. **Focus ring** -- draw a dashed outline when `state.focused` is true (keyboard users)
|
|
167
|
+
2. **State label** -- show "ON"/"OFF" or a value percentage so the user sees the state
|
|
168
|
+
3. **Press feedback** -- scale down on `state.active`, spring back on release
|
|
169
|
+
4. **Hover feedback** -- change color/glow when `state.hover` is true
|
|
170
|
+
|
|
171
|
+
```javascript
|
|
172
|
+
// Focus ring helper
|
|
173
|
+
if (state.focused) {
|
|
174
|
+
ctx.strokeStyle = 'rgba(110,231,182,.6)';
|
|
175
|
+
ctx.lineWidth = 2;
|
|
176
|
+
ctx.setLineDash([4, 3]);
|
|
177
|
+
ctx.strokeRect(-2, -2, state.w + 4, state.h + 4);
|
|
178
|
+
ctx.setLineDash([]);
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Performance Rules
|
|
183
|
+
|
|
184
|
+
1. **Never allocate in `tick()`** -- pre-allocate TypedArrays in the factory closure or `init()`
|
|
185
|
+
2. **Use `splice()` sparingly** -- for small particle arrays (< 100) it's fine. For larger pools, use a dead-flag pattern
|
|
186
|
+
3. **Reset composite operation** -- if you set `ctx.globalCompositeOperation = 'screen'`, reset to `'source-over'` before returning
|
|
187
|
+
4. **Reset shadow** -- `ctx.shadowBlur = 0` after drawing glowing elements
|
|
188
|
+
5. **Use the `dt` parameter** -- all motion must be `value * dt`, not `value` per frame. This ensures consistent speed regardless of frame rate.
|