@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 +90 -1
- package/README.md +28 -32
- package/UIFXController.d.ts +7 -0
- package/UIFXController.js +260 -35
- package/UIFXRecipes.d.ts +253 -0
- package/UIFXRecipes.js +2478 -0
- package/llms.txt +20 -9
- package/package.json +12 -5
- /package/{recipes/UIFX-RECIPE-GUIDE.md → UIFX-RECIPE-GUIDE.md} +0 -0
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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
73
|
-
>
|
|
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 '
|
|
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
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
//
|
|
114
|
-
import {
|
|
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](
|
|
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
|
-
|
|
221
|
+
Recipe types ship too, on the `./recipes` subpath (`UIFXRecipes.d.ts`).
|
|
226
222
|
|
|
227
223
|
|
|
228
224
|
## LLM-Friendly Documentation
|
package/UIFXController.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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';
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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:
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
+
ac = new AbortController();
|
|
345
|
+
acCreated = true;
|
|
175
346
|
const signal = ac.signal;
|
|
176
347
|
|
|
177
348
|
function updatePointer(e) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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;
|