@zakkster/lite-ui-fx 1.0.5 → 1.2.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,96 @@ 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.2.0] -- unreleased
9
+
10
+ Recipes ship as code (U-13). The three GitHub-only recipe volumes are
11
+ consolidated into one `UIFXRecipes.js` at the package root, exposed as the
12
+ `./recipes` subpath export behind a registry. No recipe body changes -- the
13
+ zero-GC / size-true / theming sweep is U3.
14
+
15
+ ### Added
16
+
17
+ - `./recipes` subpath export: all 50 recipes ship in one `UIFXRecipes.js`
18
+ (with `UIFXRecipes.d.ts`), versioned, typed, and tree-shakeable
19
+ (`sideEffects: false`). `UIFX-RECIPE-GUIDE.md` moves to the package root and
20
+ ships as well.
21
+ - Recipe registry (ported from `@zakkster/lite-scratch-fx`): `RECIPES`
22
+ (null-prototype, id -> factory), `RECIPE_META` (`{ id, name, type, family,
23
+ themeable, motionSafe }`; `themeable` and `motionSafe` are `false` for all
24
+ until U3), `RECIPE_NAMES` (frozen), and `registerRecipe(id, factory, meta)`
25
+ with an in-place meta-merge.
26
+ - `mountRecipe(container, id, options?)`: resolves the id fail closed (an
27
+ unknown id throws with a did-you-mean; a non-string id gets the same clean
28
+ message), asserts any `options.type` matches the recipe's declared type, then
29
+ mounts via `mountUIFX`.
30
+ - Torture: `t0-lifecycle` and `t1-degenerate` iterate `RECIPE_META`, so all 50
31
+ recipes are mounted, exercised, and destroyed by construction. New
32
+ `test/registry.test.mjs` (registry + `mountRecipe` contract + a boundary
33
+ matrix) and `test/treeshake.test.mjs` (an esbuild proof that importing one
34
+ recipe drops the others).
35
+
36
+ ### Changed
37
+
38
+ - Fail closed on the element type: `mountUIFX` now throws on any `type` other
39
+ than `UIType.BUTTON` / `TOGGLE` / `SLIDER` (an unknown type previously became
40
+ a button silently), and `registerRecipe` rejects a recipe with no valid type
41
+ before any mutation.
42
+ - The recipes are no longer a GitHub ZIP / copy-paste. `README.md` and
43
+ `llms.txt` document the `./recipes` import and drop the "not included in the
44
+ npm package" wording.
45
+ - `package.json`: `exports["./recipes"]` added; `files[]` ships
46
+ `UIFXRecipes.js`, `UIFXRecipes.d.ts`, and `UIFX-RECIPE-GUIDE.md`; `esbuild`
47
+ added as a devDependency (the tree-shake proof only -- not shipped).
48
+ - Decision recorded in `decisions/0001-recipes-position.md`.
49
+
50
+ ### Removed
51
+
52
+ - The `recipes/` directory (three volumes plus their `.d.ts`). Their exports
53
+ are unchanged and now come from the root `UIFXRecipes.js`.
54
+
55
+ ## [1.1.0] -- 2026-09-06
56
+
57
+ Controller correctness: the two S1 defects (U-01, U-02) and three
58
+ controller-level S3s (U-09, U-10, U-11). No visual change at default mounts.
59
+
60
+ ### Fixed
61
+
62
+ - U-01 (keyboard): a toggle activates on one Space press with exactly one
63
+ `onToggle`, and the native checkbox is the sole source of truth. The manual
64
+ keydown checked-flip -- which fired a second `onToggle` -- is removed; Enter
65
+ is bridged to the same native activation via `el.click()`.
66
+ - U-02 (loop survival): one malformed recipe can no longer freeze the page.
67
+ Invalid recipes are rejected at mount (fail closed -- every side effect is
68
+ unwound, so no orphan DOM and no leaked refcount); a `tick()` that throws
69
+ quarantines only that component (one `console.error`, its canvas cleared)
70
+ while the shared ticker and every other component keep running.
71
+ - U-09 (style leak): the slider-thumb `<style>` is one shared, ref-counted
72
+ node -- injected on first slider mount, removed when the last slider
73
+ unmounts; `document.head` child count nets to zero.
74
+ - U-10 (fail-open options): unknown option keys and unknown recipe-hook keys
75
+ are now errors with a did-you-mean hint; `value` must be a number in [0,1].
76
+ - U-11 (forced reflow): the bounding rect is cached on pointerenter and
77
+ refreshed on scroll/resize (passive listeners); `pointermove` does zero
78
+ layout reads.
79
+
80
+ ### Added
81
+
82
+ - Mount options `value` (slider initial, 0..1), `checked` (toggle initial),
83
+ and `disabled` -- each lands in the native element and `state` before the
84
+ first frame. New `state.disabled` for recipes to render a disabled look.
85
+ - DPR re-read: the canvas re-scales on a display-density change
86
+ (`matchMedia`, feature-detected; a silent no-op where unavailable).
87
+ - Torture tiers t2 (the accessibility contract) and t5 (100-component scale
88
+ plus the U-02 quarantine regression); two t9 controls (double-toggle,
89
+ validation-bypass).
90
+
91
+ ### Changed
92
+
93
+ - Mount validates every input before any side effect (fail closed):
94
+ container, options, factory, and recipe shape are checked before the DOM,
95
+ the shared style/ticker refcounts, or the render loop are touched.
96
+
97
+ ## [1.0.5] -- 2026-09-06
9
98
 
10
99
  Truth pass, law pass, and the torture skeleton. No runtime behaviour
11
100
  changes: this release makes the package honest, lawful, and provable.
package/README.md CHANGED
@@ -40,26 +40,22 @@ https://cdpn.io/pen/debug/YPGEaYY
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).
44
- All visual effects live in the GitHub repo as **recipes**.
43
+ All 50 recipes ship in the package on the `./recipes` subpath -- versioned,
44
+ typed, and tree-shakeable. With `sideEffects: false`, importing one recipe pulls
45
+ in only that recipe, so a controller-only install stays tiny.
45
46
 
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
-
54
- **How to write your own:**
55
- https://github.com/PeshoVurtoleta/lite-ui-fx/blob/main/recipes/UIFX-RECIPE-GUIDE.md
47
+ ```javascript
48
+ import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
49
+ import { SwarmToggle } from '@zakkster/lite-ui-fx/recipes';
50
+ ```
56
51
 
57
- Recipes are **optional**, **open-source**, and **not included in the npm package**
58
- to keep the install size tiny (<2 KB).
59
- You can copy/paste any recipe into your project or use them as inspiration.
52
+ The `./recipes` entry also exports a registry for data-driven pickers:
53
+ `RECIPES` (id -> factory), `RECIPE_META` (`{ id, name, type, family }`),
54
+ `RECIPE_NAMES`, `registerRecipe(id, factory, meta)`, and
55
+ `mountRecipe(container, id, options?)` -- which resolves the id fail-closed
56
+ (did-you-mean on a typo) and mounts it as its declared type.
60
57
 
61
- Download all recipes as a ZIP
62
- https://github.com/PeshoVurtoleta/lite-ui-fx/archive/refs/heads/main.zip
58
+ **How to write your own:** see [UIFX-RECIPE-GUIDE.md](UIFX-RECIPE-GUIDE.md), shipped in the package.
63
59
 
64
60
  Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
65
61
 
@@ -69,15 +65,15 @@ Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
69
65
  npm i @zakkster/lite-ui-fx
70
66
  ```
71
67
 
72
- > Looking for the visual effects?
73
- > Recipes live in the GitHub repo -- not in the npm package -- to keep the library tiny.
68
+ > The 50 recipes ship in the same package on the `./recipes` subpath and
69
+ > tree-shake, so importing one adds only that one.
74
70
 
75
71
 
76
72
  ## Quick Start
77
73
 
78
74
  ```javascript
79
75
  import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
80
- import { SwarmToggle } from './recipes/UIFXRecipes.js';
76
+ import { SwarmToggle } from '@zakkster/lite-ui-fx/recipes';
81
77
 
82
78
  // Mount a canvas-rendered toggle onto a container
83
79
  const instance = mountUIFX(
@@ -101,17 +97,13 @@ instance.destroy();
101
97
  // Controller (always needed)
102
98
  import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
103
99
 
104
- // Recipes are NOT included in the npm package.
105
- // Copy them from the GitHub repo into your own ./recipes folder:
106
-
107
- // Vol. 1 -- 10 recipes (toggles, buttons, sliders)
108
- import { SwarmToggle, MagneticButton, SparkSlider } from './recipes/UIFXRecipes.js';
109
-
110
- // Vol. 2 -- 20 recipes (+ loaders, checkboxes, counters, rating)
111
- import { PendulumToggle, HeartbeatButton, RippleCheck } from './recipes/UIFXRecipes2.js';
100
+ // All 50 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
101
+ import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
102
+ import { PendulumToggle, HeartbeatButton, RippleCheck } from '@zakkster/lite-ui-fx/recipes';
103
+ import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
112
104
 
113
- // Vol. 3 -- 20 recipes (knobs, progress, controls, indicators, mood, feedback, fun)
114
- import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.js';
105
+ // Registry surface for data-driven pickers:
106
+ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
115
107
  ```
116
108
 
117
109
  ## How It Works
@@ -150,6 +142,9 @@ import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.j
150
142
  | `options.height` | `number` | Element height |
151
143
  | `options.padding` | `number` | Canvas overflow (default: 40px) |
152
144
  | `options.label` | `string` | Accessible label (aria-label) |
145
+ | `options.value` | `number` | Slider initial value, 0..1 (default 0.5); out-of-range throws |
146
+ | `options.checked` | `boolean` | Toggle initial state (default false) |
147
+ | `options.disabled` | `boolean` | Disables the native element; sets `state.disabled` |
153
148
 
154
149
  Returns `{ el, canvas, wrapper, state, destroy() }`.
155
150
 
@@ -169,6 +164,7 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
169
164
  active: boolean; // Pointer pressed
170
165
  focused: boolean; // Keyboard focus
171
166
  toggled: boolean; // Checkbox state
167
+ disabled: boolean; // Disabled via options.disabled
172
168
  val: number; // Slider value (0-1)
173
169
  w: number; // Element width
174
170
  h: number; // Element height
@@ -188,7 +184,7 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
188
184
 
189
185
  ## Writing Custom Recipes
190
186
 
191
- See the full [UIFX-RECIPE-GUIDE.md](recipes/UIFX-RECIPE-GUIDE.md) (included in the package).
187
+ See the full [UIFX-RECIPE-GUIDE.md](UIFX-RECIPE-GUIDE.md) (included in the package).
192
188
 
193
189
  Minimal recipe:
194
190
 
@@ -222,7 +218,7 @@ Full TypeScript declarations are included for:
222
218
  - `UIFXPointer`
223
219
  - `UIFXRecipe`
224
220
 
225
- (Recipes are not part of the npm package, so their types are not included.)
221
+ Recipe types ship too, on the `./recipes` subpath (`UIFXRecipes.d.ts`).
226
222
 
227
223
 
228
224
  ## LLM-Friendly Documentation
@@ -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.2.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,95 @@ 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
+ // 1b. type: exactly one of the three known element types. An unknown or
181
+ // undefined type is an Error here, never a silent default to a button
182
+ // (fail closed -- the type selects the native element).
183
+ if (type !== UIType.BUTTON && type !== UIType.TOGGLE && type !== UIType.SLIDER) {
184
+ throw new Error('mountUIFX: type must be UIType.BUTTON, UIType.TOGGLE, or UIType.SLIDER');
185
+ }
186
+
187
+ // 2. options: unknown keys -> did-you-mean; value/checked/disabled
188
+ // validated and coerced HERE, before any element exists.
189
+ for (const k in options) {
190
+ if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
191
+ if (KNOWN_OPTIONS.indexOf(k) === -1) {
192
+ throw new Error(_didYouMean('mountUIFX: unknown option', k, KNOWN_OPTIONS));
193
+ }
194
+ }
195
+ const value = options.value;
196
+ if (value !== undefined &&
197
+ (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1)) {
198
+ // null is not zero: an out-of-range or non-numeric value is an error,
199
+ // never a silent coercion.
200
+ throw new Error('mountUIFX: option "value" must be a number in [0,1]');
201
+ }
202
+ const checked = options.checked === undefined ? false : !!options.checked;
203
+ const disabled = options.disabled === undefined ? false : !!options.disabled;
204
+ const width = options.width;
205
+ const height = options.height;
206
+ const padding = options.padding === undefined ? 40 : options.padding;
207
+ const label = options.label === undefined ? '' : options.label;
208
+
209
+ // 3. recipeFactory
210
+ if (typeof recipeFactory !== 'function') {
211
+ throw new Error('mountUIFX: recipeFactory must be a function');
212
+ }
213
+
214
+ // 4. recipe object + hooks. Created now so a bad recipe throws BEFORE any
215
+ // DOM/refcount side effect; .init is deferred to phase 2 (needs ctx).
216
+ const recipe = recipeFactory();
217
+ if (!recipe || typeof recipe !== 'object') {
218
+ throw new Error('mountUIFX: recipe must be an object');
219
+ }
220
+ if (typeof recipe.tick !== 'function') {
221
+ throw new Error('mountUIFX: recipe.tick must be a function');
222
+ }
223
+ for (const k in recipe) {
224
+ if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
225
+ if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
226
+ throw new Error(_didYouMean('mountUIFX: unknown recipe hook', k, KNOWN_HOOKS));
227
+ }
228
+ }
229
+
230
+ // =====================================================================
231
+ // PHASE 2 -- SIDE EFFECTS. Every check above has passed; only now do
232
+ // we allocate DOM, bump refcounts, and wire events.
233
+ //
234
+ // This region is ALSO fail-closed: if any step throws (realistically a
235
+ // user recipe.init, but anything here), we UNWIND every side effect that
236
+ // actually landed -- in reverse acquisition order, each guarded by its own
237
+ // flag so nothing underflows a refcount or double-frees -- then re-throw
238
+ // the ORIGINAL error. A try/catch is free on the success path; this is all
239
+ // cold mount code with zero hot-path impact.
240
+ // =====================================================================
241
+
242
+ let styleAcquired = false; // acquireSliderStyle() bumped _sliderRefs
243
+ let wrapperAppended = false; // wrapper is in container.children
244
+ let acCreated = false; // AbortController exists (listeners may be on it)
245
+ let tickerAcquired = false; // acquireTicker() bumped _sharedRefs
246
+ let wrapper = null;
247
+ let ac = null;
248
+ let removeTick = null;
249
+
250
+ try {
88
251
  // -- Resolve dimensions --
89
252
  const w = width || (type === UIType.BUTTON ? 160 : type === UIType.SLIDER ? 200 : 64);
90
253
  const h = height || (type === UIType.BUTTON ? 48 : type === UIType.SLIDER ? 28 : 36);
91
- const dpr = window.devicePixelRatio || 1;
254
+ let dpr = window.devicePixelRatio || 1;
92
255
 
93
256
  // -- Create native element (invisible, accessible, receives events) --
94
257
  let el;
@@ -96,17 +259,20 @@ export function mountUIFX(container, type, recipeFactory, {
96
259
  el = document.createElement('input');
97
260
  el.type = 'checkbox';
98
261
  el.setAttribute('role', 'switch');
262
+ el.checked = checked; // coerced boolean; lands before frame 1
99
263
  if (label) el.setAttribute('aria-label', label);
100
264
  } else if (type === UIType.SLIDER) {
101
265
  el = document.createElement('input');
102
266
  el.type = 'range';
103
- el.min = '0'; el.max = '100'; el.value = '50';
267
+ el.min = '0'; el.max = '100';
268
+ el.value = value !== undefined ? String(value * 100) : '50'; // 0..1 -> 0..100
104
269
  if (label) el.setAttribute('aria-label', label);
105
270
  } else {
106
271
  el = document.createElement('button');
107
272
  el.textContent = label || 'Action';
108
273
  el.type = 'button';
109
274
  }
275
+ if (disabled) el.disabled = true; // lands before frame 1
110
276
 
111
277
  Object.assign(el.style, {
112
278
  position: 'relative', zIndex: '2',
@@ -117,14 +283,12 @@ export function mountUIFX(container, type, recipeFactory, {
117
283
  WebkitAppearance: 'none', appearance: 'none',
118
284
  });
119
285
 
120
- // Slider thumb needs explicit sizing for hit area
286
+ // Slider thumb needs explicit sizing for hit area. One shared, ref-counted
287
+ // <style> for all sliders (U-09): released in destroy() when the last slider
288
+ // goes -- head child count nets to zero across mount/destroy.
121
289
  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);
290
+ acquireSliderStyle();
291
+ styleAcquired = true;
128
292
  el.classList.add('uifx-slider');
129
293
  }
130
294
 
@@ -145,7 +309,7 @@ export function mountUIFX(container, type, recipeFactory, {
145
309
  ctx.scale(dpr, dpr);
146
310
 
147
311
  // -- Wrapper --
148
- const wrapper = document.createElement('div');
312
+ wrapper = document.createElement('div');
149
313
  Object.assign(wrapper.style, {
150
314
  position: 'relative', display: 'inline-block',
151
315
  width: `${w}px`, height: `${h}px`,
@@ -153,40 +317,58 @@ export function mountUIFX(container, type, recipeFactory, {
153
317
  wrapper.appendChild(el);
154
318
  wrapper.appendChild(canvas);
155
319
  container.appendChild(wrapper);
320
+ wrapperAppended = true;
156
321
 
157
- // -- State --
322
+ // -- State (value/checked/disabled land here BEFORE frame 1) --
158
323
  const state = {
159
324
  hover: false,
160
325
  active: false, // pointer is down
161
326
  focused: false, // keyboard focus
162
- toggled: false, // checkbox state
163
- val: type === UIType.SLIDER ? 0.5 : 0, // slider value 0-1
327
+ toggled: checked, // coerced boolean; element + state AGREE
328
+ disabled, // recipes can render a disabled look
329
+ val: value !== undefined ? value : (type === UIType.SLIDER ? 0.5 : 0), // 0-1
164
330
  w, h, padding, dpr,
165
331
  };
166
332
 
167
333
  const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
334
+ // Cached bounding rect. Refreshed on pointerenter + scroll/resize (cold);
335
+ // pointermove reads it with ZERO layout reads (U-11). null until the first
336
+ // pointer event, then lazily filled once (see updatePointer).
337
+ let rect = null;
168
338
 
169
- // -- Initialize recipe --
170
- const recipe = recipeFactory();
339
+ // -- Initialize recipe (already validated in phase 1: object, tick fn,
340
+ // only known hooks). ctx exists now, so init can run. --
171
341
  if (recipe.init) recipe.init(ctx, w, h, padding);
172
342
 
173
343
  // -- Events (all via AbortController) --
174
- const ac = new AbortController();
344
+ ac = new AbortController();
345
+ acCreated = true;
175
346
  const signal = ac.signal;
176
347
 
177
348
  function updatePointer(e) {
178
- const r = el.getBoundingClientRect();
179
- const nx = e.clientX - r.left;
180
- const ny = e.clientY - r.top;
349
+ // Lazily fill the rect on the first pointer event (e.g. a pointerdown
350
+ // with no prior pointerenter). Fires getBoundingClientRect at most once
351
+ // until the next scroll/resize/enter nulls or refreshes it -- steady-
352
+ // state pointermove does ZERO layout reads (U-11 / NIT 1).
353
+ if (!rect) rect = el.getBoundingClientRect();
354
+ const nx = e.clientX - rect.left;
355
+ const ny = e.clientY - rect.top;
181
356
  pointer.vx = nx - pointer.x;
182
357
  pointer.vy = ny - pointer.y;
183
358
  pointer.x = nx;
184
359
  pointer.y = ny;
185
360
  }
361
+ function refreshRect() { rect = el.getBoundingClientRect(); }
362
+
363
+ // Rect invalidation on layout shift -- cold path, through ac.signal so
364
+ // destroy()'s abort removes them (no orphaned window listeners).
365
+ window.addEventListener('scroll', refreshRect, { passive: true, signal });
366
+ window.addEventListener('resize', refreshRect, { passive: true, signal });
186
367
 
187
368
  el.addEventListener('pointermove', updatePointer, { signal });
188
369
  el.addEventListener('pointerenter', (e) => {
189
370
  state.hover = true;
371
+ refreshRect(); // one layout read per enter
190
372
  updatePointer(e);
191
373
  if (recipe.onHover) recipe.onHover(state, pointer);
192
374
  }, { signal });
@@ -211,13 +393,11 @@ export function mountUIFX(container, type, recipeFactory, {
211
393
  state.toggled = el.checked;
212
394
  if (recipe.onToggle) recipe.onToggle(state.toggled, state);
213
395
  }, { signal });
214
- // Keyboard: Space/Enter toggles checkbox
396
+ // Space activates the checkbox natively (browser fires click -> change ->
397
+ // the listener above). Enter is not native for a checkbox; route it
398
+ // through the SAME activation path so there is one onToggle per press.
215
399
  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
- }
400
+ if (e.code === 'Enter') el.click();
221
401
  }, { signal });
222
402
  }
223
403
 
@@ -229,12 +409,29 @@ export function mountUIFX(container, type, recipeFactory, {
229
409
  }, { signal });
230
410
  }
231
411
 
412
+ // -- DPR re-read on display change (cold, feature-detected). Absent
413
+ // matchMedia is a silent no-op: the canvas stays at mount DPR (fail
414
+ // closed, never throw). Listener bound to signal for teardown. --
415
+ if (typeof window.matchMedia === 'function') {
416
+ const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
417
+ mq.addEventListener('change', () => {
418
+ const nd = window.devicePixelRatio || 1;
419
+ dpr = nd;
420
+ canvas.width = cw * nd;
421
+ canvas.height = ch * nd;
422
+ ctx.setTransform(nd, 0, 0, nd, 0, 0);
423
+ state.dpr = nd;
424
+ }, { signal });
425
+ }
426
+
232
427
  // -- Render loop (shared ticker) --
233
428
  const ticker = acquireTicker();
429
+ tickerAcquired = true;
234
430
  let destroyed = false;
431
+ let quarantined = false; // a recipe.tick throw quarantines only this one
235
432
 
236
- const removeTick = ticker.add((dtMs) => {
237
- if (destroyed) return;
433
+ removeTick = ticker.add((dtMs) => {
434
+ if (destroyed || quarantined) return;
238
435
  const dt = dtMs / 1000;
239
436
  const now = performance.now();
240
437
 
@@ -242,7 +439,17 @@ export function mountUIFX(container, type, recipeFactory, {
242
439
  ctx.clearRect(0, 0, cw, ch);
243
440
  ctx.save();
244
441
  ctx.translate(padding, padding); // Origin = native element's top-left
245
- recipe.tick(ctx, dt, now, state, pointer);
442
+ try {
443
+ recipe.tick(ctx, dt, now, state, pointer);
444
+ } catch (err) {
445
+ // U-02B: contain the throw. The shared Ticker never sees it, so its
446
+ // RAF reschedules and every other component keeps running.
447
+ quarantined = true;
448
+ console.error('mountUIFX: recipe.tick threw for type "' + type + '"; component quarantined', err);
449
+ ctx.restore();
450
+ ctx.clearRect(0, 0, cw, ch);
451
+ return;
452
+ }
246
453
  ctx.restore();
247
454
  });
248
455
 
@@ -268,9 +475,27 @@ export function mountUIFX(container, type, recipeFactory, {
268
475
  removeTick();
269
476
  if (recipe.destroy) recipe.destroy();
270
477
  releaseTicker();
478
+ if (type === UIType.SLIDER) releaseSliderStyle();
271
479
  wrapper.remove();
272
480
  },
273
481
  };
482
+ } catch (err) {
483
+ // A step in phase 2 threw (realistically recipe.init -- user code).
484
+ // Unwind ONLY what was actually acquired, in reverse acquisition order,
485
+ // each guarded by its flag so an early throw (e.g. at init, before ac /
486
+ // ticker exist) never releases a ticker or aborts an ac that was never
487
+ // created. recipe.destroy() is deliberately NOT called: init did not
488
+ // succeed, so there is no initialised recipe to tear down.
489
+ if (tickerAcquired) {
490
+ if (removeTick) removeTick();
491
+ releaseTicker();
492
+ }
493
+ if (acCreated) ac.abort();
494
+ if (wrapperAppended) wrapper.remove();
495
+ if (styleAcquired) releaseSliderStyle();
496
+ // Re-throw the ORIGINAL error, preserved verbatim (never wrapped).
497
+ throw err;
498
+ }
274
499
  }
275
500
 
276
501
  export default mountUIFX;