@zakkster/lite-ui-fx 1.0.5 → 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 CHANGED
@@ -5,7 +5,49 @@ All notable changes to `@zakkster/lite-ui-fx` are documented here.
5
5
  The format follows Keep a Changelog; this project adheres to Semantic
6
6
  Versioning.
7
7
 
8
- ## [1.0.5] -- unreleased
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
9
51
 
10
52
  Truth pass, law pass, and the torture skeleton. No runtime behaviour
11
53
  changes: this release makes the package honest, lawful, and provable.
package/README.md CHANGED
@@ -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
 
@@ -169,6 +172,7 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
169
172
  active: boolean; // Pointer pressed
170
173
  focused: boolean; // Keyboard focus
171
174
  toggled: boolean; // Checkbox state
175
+ disabled: boolean; // Disabled via options.disabled
172
176
  val: number; // Slider value (0-1)
173
177
  w: number; // Element width
174
178
  h: number; // Element height
@@ -13,6 +13,7 @@ export interface UIFXState {
13
13
  active: boolean;
14
14
  focused: boolean;
15
15
  toggled: boolean;
16
+ disabled: boolean;
16
17
  val: number;
17
18
  w: number;
18
19
  h: number;
@@ -52,6 +53,12 @@ export interface MountOptions {
52
53
  height?: number;
53
54
  padding?: number;
54
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;
55
62
  }
56
63
 
57
64
  export interface UIFXInstance {
package/UIFXController.js CHANGED
@@ -22,7 +22,7 @@ import { Ticker } from '@zakkster/lite-ticker';
22
22
 
23
23
  // Three-place version sync: this constant, package.json "version", and the
24
24
  // VERSION line in llms.txt must always match. /release keeps them locked.
25
- export const VERSION = '1.0.5';
25
+ export const VERSION = '1.1.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -50,6 +50,90 @@ function releaseTicker() {
50
50
  }
51
51
 
52
52
 
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
+
53
137
  // ---------------------------------------------------------
54
138
  // ELEMENT TYPES
55
139
  // ---------------------------------------------------------
@@ -79,16 +163,88 @@ export const UIType = Object.freeze({
79
163
  * @param {string} [options.label] Accessible label for the element
80
164
  * @returns {{ el: HTMLElement, destroy: Function }}
81
165
  */
82
- export function mountUIFX(container, type, recipeFactory, {
83
- width,
84
- height,
85
- padding = 40,
86
- label = '',
87
- } = {}) {
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 {
88
244
  // -- Resolve dimensions --
89
245
  const w = width || (type === UIType.BUTTON ? 160 : type === UIType.SLIDER ? 200 : 64);
90
246
  const h = height || (type === UIType.BUTTON ? 48 : type === UIType.SLIDER ? 28 : 36);
91
- const dpr = window.devicePixelRatio || 1;
247
+ let dpr = window.devicePixelRatio || 1;
92
248
 
93
249
  // -- Create native element (invisible, accessible, receives events) --
94
250
  let el;
@@ -96,17 +252,20 @@ export function mountUIFX(container, type, recipeFactory, {
96
252
  el = document.createElement('input');
97
253
  el.type = 'checkbox';
98
254
  el.setAttribute('role', 'switch');
255
+ el.checked = checked; // coerced boolean; lands before frame 1
99
256
  if (label) el.setAttribute('aria-label', label);
100
257
  } else if (type === UIType.SLIDER) {
101
258
  el = document.createElement('input');
102
259
  el.type = 'range';
103
- 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
104
262
  if (label) el.setAttribute('aria-label', label);
105
263
  } else {
106
264
  el = document.createElement('button');
107
265
  el.textContent = label || 'Action';
108
266
  el.type = 'button';
109
267
  }
268
+ if (disabled) el.disabled = true; // lands before frame 1
110
269
 
111
270
  Object.assign(el.style, {
112
271
  position: 'relative', zIndex: '2',
@@ -117,14 +276,12 @@ export function mountUIFX(container, type, recipeFactory, {
117
276
  WebkitAppearance: 'none', appearance: 'none',
118
277
  });
119
278
 
120
- // 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.
121
282
  if (type === UIType.SLIDER) {
122
- const thumbCSS = document.createElement('style');
123
- thumbCSS.textContent = `
124
- .uifx-slider::-webkit-slider-thumb { -webkit-appearance:none; width:24px; height:24px; cursor:grab; }
125
- .uifx-slider::-moz-range-thumb { width:24px; height:24px; cursor:grab; border:none; background:transparent; }
126
- `;
127
- document.head.appendChild(thumbCSS);
283
+ acquireSliderStyle();
284
+ styleAcquired = true;
128
285
  el.classList.add('uifx-slider');
129
286
  }
130
287
 
@@ -145,7 +302,7 @@ export function mountUIFX(container, type, recipeFactory, {
145
302
  ctx.scale(dpr, dpr);
146
303
 
147
304
  // -- Wrapper --
148
- const wrapper = document.createElement('div');
305
+ wrapper = document.createElement('div');
149
306
  Object.assign(wrapper.style, {
150
307
  position: 'relative', display: 'inline-block',
151
308
  width: `${w}px`, height: `${h}px`,
@@ -153,40 +310,58 @@ export function mountUIFX(container, type, recipeFactory, {
153
310
  wrapper.appendChild(el);
154
311
  wrapper.appendChild(canvas);
155
312
  container.appendChild(wrapper);
313
+ wrapperAppended = true;
156
314
 
157
- // -- State --
315
+ // -- State (value/checked/disabled land here BEFORE frame 1) --
158
316
  const state = {
159
317
  hover: false,
160
318
  active: false, // pointer is down
161
319
  focused: false, // keyboard focus
162
- toggled: false, // checkbox state
163
- 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
164
323
  w, h, padding, dpr,
165
324
  };
166
325
 
167
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;
168
331
 
169
- // -- Initialize recipe --
170
- 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. --
171
334
  if (recipe.init) recipe.init(ctx, w, h, padding);
172
335
 
173
336
  // -- Events (all via AbortController) --
174
- const ac = new AbortController();
337
+ ac = new AbortController();
338
+ acCreated = true;
175
339
  const signal = ac.signal;
176
340
 
177
341
  function updatePointer(e) {
178
- const r = el.getBoundingClientRect();
179
- const nx = e.clientX - r.left;
180
- 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;
181
349
  pointer.vx = nx - pointer.x;
182
350
  pointer.vy = ny - pointer.y;
183
351
  pointer.x = nx;
184
352
  pointer.y = ny;
185
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 });
186
360
 
187
361
  el.addEventListener('pointermove', updatePointer, { signal });
188
362
  el.addEventListener('pointerenter', (e) => {
189
363
  state.hover = true;
364
+ refreshRect(); // one layout read per enter
190
365
  updatePointer(e);
191
366
  if (recipe.onHover) recipe.onHover(state, pointer);
192
367
  }, { signal });
@@ -211,13 +386,11 @@ export function mountUIFX(container, type, recipeFactory, {
211
386
  state.toggled = el.checked;
212
387
  if (recipe.onToggle) recipe.onToggle(state.toggled, state);
213
388
  }, { signal });
214
- // 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.
215
392
  el.addEventListener('keydown', (e) => {
216
- if (e.code === 'Space' || e.code === 'Enter') {
217
- el.checked = !el.checked;
218
- state.toggled = el.checked;
219
- if (recipe.onToggle) recipe.onToggle(state.toggled, state);
220
- }
393
+ if (e.code === 'Enter') el.click();
221
394
  }, { signal });
222
395
  }
223
396
 
@@ -229,12 +402,29 @@ export function mountUIFX(container, type, recipeFactory, {
229
402
  }, { signal });
230
403
  }
231
404
 
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
+
232
420
  // -- Render loop (shared ticker) --
233
421
  const ticker = acquireTicker();
422
+ tickerAcquired = true;
234
423
  let destroyed = false;
424
+ let quarantined = false; // a recipe.tick throw quarantines only this one
235
425
 
236
- const removeTick = ticker.add((dtMs) => {
237
- if (destroyed) return;
426
+ removeTick = ticker.add((dtMs) => {
427
+ if (destroyed || quarantined) return;
238
428
  const dt = dtMs / 1000;
239
429
  const now = performance.now();
240
430
 
@@ -242,7 +432,17 @@ export function mountUIFX(container, type, recipeFactory, {
242
432
  ctx.clearRect(0, 0, cw, ch);
243
433
  ctx.save();
244
434
  ctx.translate(padding, padding); // Origin = native element's top-left
245
- 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
+ }
246
446
  ctx.restore();
247
447
  });
248
448
 
@@ -268,9 +468,27 @@ export function mountUIFX(container, type, recipeFactory, {
268
468
  removeTick();
269
469
  if (recipe.destroy) recipe.destroy();
270
470
  releaseTicker();
471
+ if (type === UIType.SLIDER) releaseSliderStyle();
271
472
  wrapper.remove();
272
473
  },
273
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
+ }
274
492
  }
275
493
 
276
494
  export default mountUIFX;
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zakkster/lite-ui-fx
2
2
  > Canvas-hijacked UI components with pluggable recipe system. 50 built-in recipes.
3
3
 
4
- VERSION 1.0.5
4
+ VERSION 1.1.0
5
5
 
6
6
  ## Install
7
7
  npm i @zakkster/lite-ui-fx
@@ -23,13 +23,19 @@ import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.j
23
23
  const instance = mountUIFX(container, UIType.TOGGLE, SwarmToggle, { label: 'Sound' });
24
24
  instance.destroy(); // cleanup
25
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
+
26
32
  ## Element Types
27
33
  UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
28
34
  UIType.BUTTON -> <button> -> state.active, onClick(x, y, state)
29
35
  UIType.SLIDER -> <input type="range"> -> state.val (0-1), onDrag(val, velocity)
30
36
 
31
37
  ## State Object (provided to tick every frame)
32
- { hover, active, focused, toggled, val, w, h, padding, dpr }
38
+ { hover, active, focused, toggled, disabled, val, w, h, padding, dpr }
33
39
 
34
40
  ## 50 Built-in Recipes
35
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-ui-fx",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Canvas-hijacked UI components with a pluggable recipe system. 50 built-in recipes across toggles, buttons, sliders, knobs, loaders, checkboxes, counters, and ratings.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",