@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 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
  ![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
10
10
 
11
- ## ✨ What is lite-ui-fx?
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 — handling focus, keyboard, and pointer events. The canvas handles all visuals.
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
- ## 🎬 Live Demo (UI-FX)
15
+ ## Live Demo (UI-FX)
16
16
  https://cdpn.io/pen/debug/RNGjMjQ
17
17
 
18
- ## 🎬 Live Demo (UI-FX vol.2)
18
+ ## Live Demo (UI-FX vol.2)
19
19
  https://cdpn.io/pen/debug/yyaPKpB
20
20
 
21
- ## 🎬 Live Demo (UI-FX vol3.)
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
- - 🔘 **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 → ✓)
37
- - 🔄 **Loaders** — Orbit planets, DNA helix
38
- - 🔢 **Counters** — Flame heat, Glitch signal
39
- - ⭐ **Rating** — Bubble inflate
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
- 📁 **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
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
- 📘 **How to write your own:**
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
- 📦 Download all recipes as a ZIP
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
- ## 🚀 Install
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 — not in the npm package — to keep the library tiny.
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
- ## 🕹️ Quick Start
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
- ## 📦 Import Map
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 — 10 recipes (toggles, buttons, sliders)
107
+ // Vol. 1 -- 10 recipes (toggles, buttons, sliders)
108
108
  import { SwarmToggle, MagneticButton, SparkSlider } from './recipes/UIFXRecipes.js';
109
109
 
110
- // Vol. 2 — 20 recipes (+ loaders, checkboxes, counters, rating)
110
+ // Vol. 2 -- 20 recipes (+ loaders, checkboxes, counters, rating)
111
111
  import { PendulumToggle, HeartbeatButton, RippleCheck } from './recipes/UIFXRecipes2.js';
112
112
 
113
- // Vol. 3 — 20 recipes (knobs, progress, controls, indicators, mood, feedback, fun)
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
- ## 🧠 How It Works
117
+ ## How It Works
118
118
 
119
119
  ```
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
- └──────────────────────────────────────────────────┘
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
- ## ⚙️ API
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–1) |
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
- val: number; // Slider value (0–1)
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
- ## 📊 Comparison
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** | *50 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
191
+ | **lite-ui-fx** | **< 5 KB** | **Canvas hijack** | **50 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
188
192
 
189
- ## 🎨 Writing Custom Recipes
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
- ## 📦 TypeScript
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
- ## 📚 LLM-Friendly Documentation
232
+ ## LLM-Friendly Documentation
229
233
 
230
234
  See `llms.txt` for AI-optimized metadata and the complete recipe catalog.
231
235
 
@@ -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 — Canvas-Hijacked UI Components
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) — 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? }
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 — interpolation in recipes
15
- * @zakkster/lite-random — deterministic particle effects
16
- * @zakkster/lite-color — OKLCH color math (optional per recipe)
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 — The Canvas Hijacker
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
- width,
80
- height,
81
- padding = 40,
82
- label = '',
83
- } = {}) {
84
- // ── Resolve dimensions ──
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
- const dpr = window.devicePixelRatio || 1;
247
+ let dpr = window.devicePixelRatio || 1;
88
248
 
89
- // ── Create native element (invisible, accessible, receives events) ──
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'; el.value = '50';
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
- const thumbCSS = document.createElement('style');
119
- thumbCSS.textContent = `
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
- // ── Create canvas overlay (DPR-aware) ──
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
- // ── Wrapper ──
144
- const wrapper = document.createElement('div');
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
- // ── State ──
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: false, // checkbox state
159
- val: type === UIType.SLIDER ? 0.5 : 0, // slider value 0–1
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
- // ── Initialize recipe ──
166
- const recipe = recipeFactory();
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
- // ── Events (all via AbortController) ──
170
- const ac = new AbortController();
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
- const r = el.getBoundingClientRect();
175
- const nx = e.clientX - r.left;
176
- const ny = e.clientY - r.top;
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
- // Keyboard: Space/Enter toggles checkbox
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 === 'Space' || e.code === 'Enter') {
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
- // ── Render loop (shared ticker) ──
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
- const removeTick = ticker.add((dtMs) => {
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
- recipe.tick(ctx, dt, now, state, pointer);
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
- // ── Public API ──
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. 30 built-in recipes.
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 — Controller
14
+ ## Import -- Controller
13
15
  import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
14
16
 
15
- ## Import — Recipes (3 volumes, 30 total)
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 → <input type="checkbox" role="switch"> → state.toggled, onToggle(checked)
26
- UIType.BUTTON → <button> → state.active, onClick(x, y, state)
27
- UIType.SLIDER → <input type="range"> → state.val (0–1), onDrag(val, velocity)
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
- ## 30 Built-in Recipes
40
+ ## 50 Built-in Recipes
33
41
 
34
- ### Vol. 1 (UIFXRecipes.js) — 10 recipes
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) — 20 recipes
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) — 20 recipes
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",
4
- "description": "Canvas-hijacked UI components with a pluggable recipe system. 30 built-in recipes across toggles, buttons, sliders, knobs, loaders, checkboxes, counters, and ratings.",
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
- "vitest": "^3.0.0"
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": "vitest run"
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.