@zakkster/lite-ui-fx 1.5.0 → 1.6.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 +43 -0
- package/README.md +45 -7
- package/UIFX-RECIPE-GUIDE.md +59 -3
- package/UIFXController.d.ts +58 -0
- package/UIFXController.js +330 -1
- package/UIFXRecipes.d.ts +27 -4
- package/UIFXRecipes.js +201 -33
- package/llms.txt +36 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +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.6.0] -- 2026-09-07
|
|
9
|
+
|
|
10
|
+
Decorate mode (U4b, the second half of roadmap U4). A second public mount mode
|
|
11
|
+
alongside the hijack `mountUIFX`: `decorateUIFX` positions a canvas AROUND an
|
|
12
|
+
existing visible element instead of hijacking it. Additive: a bare `mountUIFX`
|
|
13
|
+
mount is byte-identical to 1.5.0; 53 -> 56 recipes.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `decorateUIFX(el, recipeFactory, options)`: a canvas overlay AROUND a live
|
|
18
|
+
element -- no `opacity:0`, no reparent. The overlay is a sibling placed from the
|
|
19
|
+
host's offset box and removed on `destroy()`, so the host is byte-identical
|
|
20
|
+
before and after (additive-only). Recipe `state` is wired from the host's own
|
|
21
|
+
events; for a form-control host `state.text` and `state.valid` mirror `el.value`
|
|
22
|
+
and `el.validity`, read at event time (never per frame). `setValue`/`setChecked`
|
|
23
|
+
are hijack-only and throw. Options are a subset (`padding`, `seed`, `colors`,
|
|
24
|
+
`theme`, `text`, `font`); the hijack-only keys throw in decorate mode.
|
|
25
|
+
- Three decorate recipes, born themed and t3-gated: `FocusHalo`, `ErrorShake`,
|
|
26
|
+
`SuccessBloom` (generic form feedback, reading `state.focused` / `state.valid`).
|
|
27
|
+
Registered in `RECIPES` / `RECIPE_META` (type `'decorate'`) / the default export
|
|
28
|
+
/ a new `UIFXRecipes5` barrel.
|
|
29
|
+
- `'decorate'` as a `RECIPE_META.type` routing tag: `mountRecipe(el, id)` routes a
|
|
30
|
+
decorate recipe to `decorateUIFX`. `VALID_META_TYPES` gains exactly this one
|
|
31
|
+
non-`UIType` tag; `mountUIFX` still rejects it (the two paths cannot cross).
|
|
32
|
+
- Optional `state.text` / `state.valid` fields (decorate mode only). TypeScript
|
|
33
|
+
`DecorateOptions`, `DecorateInstance`, and `decorateUIFX` in the d.ts.
|
|
34
|
+
- `decisions/0004-decorate-mode.md`; decorate coverage across the suite: t0 host
|
|
35
|
+
byte-identical DOM diff + a t9 `decorate-host-mutation` control, t1 fail-closed +
|
|
36
|
+
degenerate sweep, t2 A14-A16 (state wiring, host untouched, non-input host), t3
|
|
37
|
+
zero-alloc churn, t5 decorate on the shared ticker. 164 -> 177 node:test tests.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- `PasswordStrength` and `TypewriterField` re-homed from their U4a-era fake types
|
|
42
|
+
(slider / toggle) onto decorate mode (`RECIPE_META.type` `'decorate'`). Unlike
|
|
43
|
+
the U4a re-homes these are behaviour ports: they now read the live host input --
|
|
44
|
+
PasswordStrength derives strength from `state.text` (zero-alloc `charCodeAt`
|
|
45
|
+
scan, recomputed only on change); TypewriterField animates an underline that
|
|
46
|
+
grows with the typed text (no `measureText`). Both stay `themeable`.
|
|
47
|
+
- RECIPE_META covers 56 recipes; `themeable` true for all 56, `motionSafe` false
|
|
48
|
+
for all 56. All 56 stay zero-GC under the t3 frame-alloc gate (default AND
|
|
49
|
+
themed). `mountUIFX` and every hijack mount are unchanged.
|
|
50
|
+
|
|
8
51
|
## [1.5.0] -- 2026-09-07
|
|
9
52
|
|
|
10
53
|
New native element types (U4a, the first half of roadmap U4). Vol.3 faked
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ https://cdpn.io/pen/debug/yyaPKpB
|
|
|
21
21
|
## Live Demo (UI-FX vol3.)
|
|
22
22
|
https://cdpn.io/pen/debug/YPGEaYY
|
|
23
23
|
|
|
24
|
-
**
|
|
24
|
+
**56 recipes** across UI element categories:
|
|
25
25
|
|
|
26
26
|
- **Toggles** -- Swarm, Liquid, Neon Pulse, Pendulum, Circuit, Lightning, DNA
|
|
27
27
|
- **Buttons** -- Magnetic, Shatter, Confetti, Glitch, Heartbeat, Breathing, Ink Splash, Pixel Dissolve, Firework
|
|
@@ -29,18 +29,19 @@ https://cdpn.io/pen/debug/YPGEaYY
|
|
|
29
29
|
- **Knobs** -- Volume dial, Compass needle
|
|
30
30
|
- **Progress** -- Ring, Battery, Signal meter, Liquid Fill
|
|
31
31
|
- **Controls** -- Pill tabs, Stepper, Radio orbit
|
|
32
|
-
- **Indicators** --
|
|
32
|
+
- **Indicators** -- Water level, Heat map
|
|
33
33
|
- **Mood** -- Day/night, Reaction picker, Notification bell
|
|
34
|
-
- **Feedback** --
|
|
34
|
+
- **Feedback** -- Sound wave, Upload progress
|
|
35
35
|
- **Fun** -- Scratch reveal, Timer countdown, Pull refresh
|
|
36
36
|
- **Checkboxes** -- Ripple, Morph (X to check), Tick Draw, Indeterminate Scan
|
|
37
37
|
- **Loaders** -- Orbit planets, DNA helix
|
|
38
38
|
- **Counters** -- Flame heat, Glitch signal
|
|
39
39
|
- **Rating** -- Bubble inflate
|
|
40
|
+
- **Form (decorate)** -- Focus halo, Error shake, Success bloom, Password strength, Typewriter field (mounted via `decorateUIFX`, around a live element)
|
|
40
41
|
|
|
41
42
|
Every recipe is zero-GC, uses `dt`-based animation, and includes accessibility indicators (focus rings, state labels).
|
|
42
43
|
|
|
43
|
-
All
|
|
44
|
+
All 56 recipes ship in the package on the `./recipes` subpath -- versioned,
|
|
44
45
|
typed, and tree-shakeable. With `sideEffects: false`, importing one recipe pulls
|
|
45
46
|
in only that recipe, so a controller-only install stays tiny.
|
|
46
47
|
|
|
@@ -65,7 +66,7 @@ Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
|
|
|
65
66
|
npm i @zakkster/lite-ui-fx
|
|
66
67
|
```
|
|
67
68
|
|
|
68
|
-
> The
|
|
69
|
+
> The 56 recipes ship in the same package on the `./recipes` subpath and
|
|
69
70
|
> tree-shake, so importing one adds only that one.
|
|
70
71
|
|
|
71
72
|
|
|
@@ -97,7 +98,7 @@ instance.destroy();
|
|
|
97
98
|
// Controller (always needed)
|
|
98
99
|
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
99
100
|
|
|
100
|
-
// All
|
|
101
|
+
// All 56 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
|
|
101
102
|
import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
102
103
|
import { PendulumToggle, HeartbeatButton, RippleCheck } from '@zakkster/lite-ui-fx/recipes';
|
|
103
104
|
import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
|
|
@@ -158,6 +159,36 @@ Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), destroy() }`.
|
|
|
158
159
|
- `setChecked(b)` -- TOGGLE/CHECKBOX: set checked (updates the element +
|
|
159
160
|
`state.toggled`, fires `onToggle` once).
|
|
160
161
|
|
|
162
|
+
### `decorateUIFX(el, recipeFactory, options?)` -- the second mount mode
|
|
163
|
+
|
|
164
|
+
Where `mountUIFX` **hijacks** (creates a hidden native element under a canvas),
|
|
165
|
+
`decorateUIFX` **decorates**: it positions a canvas *around* an existing, visible
|
|
166
|
+
element without hijacking it -- no `opacity:0`, no reparenting. The overlay is a
|
|
167
|
+
sibling placed from the host's offset box and removed on `destroy()`, so the host
|
|
168
|
+
is byte-identical before and after. Recipe `state` is wired from the host's own
|
|
169
|
+
events; for a form-control host, `state.text` and `state.valid` mirror `el.value`
|
|
170
|
+
and `el.validity` (read at event time, never per frame). This is the honest home
|
|
171
|
+
for a decoration over a real input.
|
|
172
|
+
|
|
173
|
+
```javascript
|
|
174
|
+
import { decorateUIFX } from '@zakkster/lite-ui-fx';
|
|
175
|
+
import { PasswordStrength } from '@zakkster/lite-ui-fx/recipes';
|
|
176
|
+
|
|
177
|
+
const input = document.querySelector('#password');
|
|
178
|
+
const deco = decorateUIFX(input, PasswordStrength, { theme });
|
|
179
|
+
// ... input stays fully usable; the meter tracks what the user types ...
|
|
180
|
+
deco.destroy(); // removes ONLY the overlay; the input is untouched
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`options` is a subset: `padding`, `seed`, `colors`, `theme`, `text`, `font`. The
|
|
184
|
+
hijack-only keys (`width`/`height`/`value`/`checked`/`disabled`/`knobMode`/
|
|
185
|
+
`announce`/`label`) throw in decorate mode. Returns
|
|
186
|
+
`{ el, canvas, state, setValue, setChecked, destroy() }`, where `setValue` and
|
|
187
|
+
`setChecked` are hijack-only and throw (a decoration reflects the host; it does
|
|
188
|
+
not drive it). Built-in decorate recipes: `FocusHalo`, `ErrorShake`,
|
|
189
|
+
`SuccessBloom`, `PasswordStrength`, `TypewriterField` (`RECIPE_META.type` =
|
|
190
|
+
`'decorate'`, so `mountRecipe(el, id)` routes them here automatically).
|
|
191
|
+
|
|
161
192
|
### Element Types
|
|
162
193
|
|
|
163
194
|
| Type | Native Element | Recipe Hooks | Key State |
|
|
@@ -168,6 +199,10 @@ Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), destroy() }`.
|
|
|
168
199
|
| `UIType.CHECKBOX` | `<input type="checkbox">` (no `role=switch`) | `onToggle(checked)` | `state.toggled`, `state.indeterminate` |
|
|
169
200
|
| `UIType.PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` (0-1) |
|
|
170
201
|
| `UIType.KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
|
|
202
|
+
| *(decorate)* | none -- a canvas AROUND a live host (`decorateUIFX`) | host events -> state | `state.focused`, `state.text`, `state.valid` |
|
|
203
|
+
|
|
204
|
+
*(decorate)* is a mount mode, not a `UIType`: it creates no native element. A recipe
|
|
205
|
+
with `RECIPE_META.type === 'decorate'` is mounted via `decorateUIFX`.
|
|
171
206
|
|
|
172
207
|
### State Object (provided to `tick()` every frame)
|
|
173
208
|
|
|
@@ -184,6 +219,8 @@ Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), destroy() }`.
|
|
|
184
219
|
h: number; // Element height
|
|
185
220
|
padding: number; // Canvas padding
|
|
186
221
|
dpr: number; // Device pixel ratio
|
|
222
|
+
text?: string; // decorate mode only: the host value string
|
|
223
|
+
valid?: boolean; // decorate mode only: the host validity
|
|
187
224
|
}
|
|
188
225
|
```
|
|
189
226
|
|
|
@@ -194,7 +231,7 @@ Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), destroy() }`.
|
|
|
194
231
|
| Framer Motion | ~45 KB | React HOC | 0 | Via React | `npm i framer-motion` |
|
|
195
232
|
| GSAP | ~25 KB | Timeline | 0 | Manual | `npm i gsap` |
|
|
196
233
|
| Lottie | ~55 KB | JSON animation | After Effects | Manual | `npm i lottie-web` |
|
|
197
|
-
| **lite-ui-fx** | **< 5 KB** | **Canvas hijack** | **
|
|
234
|
+
| **lite-ui-fx** | **< 5 KB** | **Canvas hijack + decorate** | **56 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
|
|
198
235
|
|
|
199
236
|
## Writing Custom Recipes
|
|
200
237
|
|
|
@@ -227,6 +264,7 @@ export function MyButton() {
|
|
|
227
264
|
Full TypeScript declarations are included for:
|
|
228
265
|
|
|
229
266
|
- `mountUIFX`
|
|
267
|
+
- `decorateUIFX` (+ `DecorateOptions`, `DecorateInstance`)
|
|
230
268
|
- `UIType`
|
|
231
269
|
- `UIFXState`
|
|
232
270
|
- `UIFXPointer`
|
package/UIFX-RECIPE-GUIDE.md
CHANGED
|
@@ -63,12 +63,16 @@ The controller provides this every frame:
|
|
|
63
63
|
hover: boolean, // Pointer is inside the element
|
|
64
64
|
active: boolean, // Pointer is pressed down
|
|
65
65
|
focused: boolean, // Element has keyboard focus
|
|
66
|
-
toggled: boolean, // Checkbox checked state (toggles)
|
|
67
|
-
|
|
66
|
+
toggled: boolean, // Checkbox checked state (toggles/checkboxes)
|
|
67
|
+
indeterminate: boolean, // CHECKBOX only: native indeterminate (setValue(null))
|
|
68
|
+
val: number, // 0--1 value (sliders/knobs/progress)
|
|
68
69
|
w: number, // Element width in CSS pixels
|
|
69
70
|
h: number, // Element height
|
|
70
71
|
padding: number, // Canvas overflow padding
|
|
71
72
|
dpr: number, // Device pixel ratio
|
|
73
|
+
// Decorate mode only (decorateUIFX): the live host's value + validity.
|
|
74
|
+
text: string, // the host form-control's value string ('' if none)
|
|
75
|
+
valid: boolean, // the host's validity (el.validity.valid, else true)
|
|
72
76
|
}
|
|
73
77
|
```
|
|
74
78
|
|
|
@@ -121,13 +125,65 @@ const instance = mountUIFX(
|
|
|
121
125
|
instance.destroy();
|
|
122
126
|
```
|
|
123
127
|
|
|
124
|
-
##
|
|
128
|
+
## Element Types & Mount Modes
|
|
129
|
+
|
|
130
|
+
`mountUIFX` HIJACKS -- it creates one of six native elements (opacity:0) under the
|
|
131
|
+
canvas:
|
|
125
132
|
|
|
126
133
|
| Type | Native Element | Recipe Gets | Key State |
|
|
127
134
|
|------|----------------|-------------|-----------|
|
|
128
135
|
| `UIType.TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
|
|
129
136
|
| `UIType.BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
|
|
130
137
|
| `UIType.SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0--1) |
|
|
138
|
+
| `UIType.CHECKBOX` | `<input type="checkbox">` (no `role=switch`) | `onToggle(checked)` | `state.toggled`, `state.indeterminate` |
|
|
139
|
+
| `UIType.PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` |
|
|
140
|
+
| `UIType.KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val`, `knobMode` |
|
|
141
|
+
|
|
142
|
+
## Decorate-Mode Recipes (a canvas AROUND a live element)
|
|
143
|
+
|
|
144
|
+
`decorateUIFX(el, factory, options)` is the SECOND mount mode: instead of creating
|
|
145
|
+
a hidden element, it positions a canvas around an EXISTING, visible element (a real
|
|
146
|
+
`<input>`, a button, any element). Same recipe interface, same coordinate system --
|
|
147
|
+
`(0,0)` is the host's top-left, `state.w/h` are the host's size -- but three rules
|
|
148
|
+
differ, and breaking them is caught by the torture t0 decorate DOM-diff:
|
|
149
|
+
|
|
150
|
+
1. **Never touch the host.** A decoration paints ONLY its overlay canvas. Do not
|
|
151
|
+
write `el.style`, set an attribute, or read/move the host in `init`/`tick`. The
|
|
152
|
+
host must be byte-identical after `destroy()` (additive-only). The controller
|
|
153
|
+
never sets `opacity:0` and never reparents the host -- neither may your recipe.
|
|
154
|
+
2. **Read host content from `state`, at frame time, allocation-free.** For a
|
|
155
|
+
form-control host, `state.text` (the value string) and `state.valid` (validity)
|
|
156
|
+
are updated by the controller at EVENT time (input/change/invalid) -- never per
|
|
157
|
+
frame. Your `tick` reads them; scan `state.text` with `charCodeAt` (no allocating
|
|
158
|
+
string ops), and edge-detect `state.valid` against a closure-cached previous.
|
|
159
|
+
There is NO new hook: a decoration reacts by polling `state` in `tick`.
|
|
160
|
+
3. **`setValue`/`setChecked` are hijack-only.** A decoration reflects the host; it
|
|
161
|
+
does not drive it. Both throw in decorate mode.
|
|
162
|
+
|
|
163
|
+
```javascript
|
|
164
|
+
import { decorateUIFX } from './UIFXController.js';
|
|
165
|
+
// A decoration reads state.focused / state.valid / state.text; it never draws the
|
|
166
|
+
// host's own text (the real element already shows it).
|
|
167
|
+
export function Underline() {
|
|
168
|
+
let fill = 0;
|
|
169
|
+
return {
|
|
170
|
+
tick(ctx, dt, now, state) {
|
|
171
|
+
const len = (state.text || '').length; // number, no alloc
|
|
172
|
+
fill += ((len ? Math.min(len / 24, 1) : 0) - fill) * dt * 8;
|
|
173
|
+
ctx.strokeStyle = state.focused ? '#6ee7b6' : '#556';
|
|
174
|
+
ctx.lineWidth = 2;
|
|
175
|
+
ctx.beginPath();
|
|
176
|
+
ctx.moveTo(2, state.h - 3);
|
|
177
|
+
ctx.lineTo(2 + (state.w - 4) * fill, state.h - 3);
|
|
178
|
+
ctx.stroke();
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const deco = decorateUIFX(document.querySelector('#field'), Underline);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Register a decorate recipe with `RECIPE_META.type: 'decorate'` -- then
|
|
186
|
+
`mountRecipe(el, id)` routes it to `decorateUIFX` automatically.
|
|
131
187
|
|
|
132
188
|
## Using @zakkster Libraries in Recipes
|
|
133
189
|
|
package/UIFXController.d.ts
CHANGED
|
@@ -27,6 +27,11 @@ export interface UIFXState {
|
|
|
27
27
|
h: number;
|
|
28
28
|
padding: number;
|
|
29
29
|
dpr: number;
|
|
30
|
+
/** Decorate mode only (decorateUIFX): the host form-control's current value.
|
|
31
|
+
* Absent for hijack mounts and for a non-form host (then ''). */
|
|
32
|
+
text?: string;
|
|
33
|
+
/** Decorate mode only: the host's validity (el.validity.valid, else true). */
|
|
34
|
+
valid?: boolean;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export interface UIFXPointer {
|
|
@@ -86,6 +91,22 @@ export interface MountOptions {
|
|
|
86
91
|
announce?: boolean;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Options accepted by decorateUIFX. A subset of MountOptions: a decoration
|
|
96
|
+
* inherits the host's geometry (offset box) and value (read from the host), so
|
|
97
|
+
* the hijack-only options (width/height/value/checked/disabled/knobMode/announce/
|
|
98
|
+
* label) are rejected -- passing one throws (fail closed).
|
|
99
|
+
*/
|
|
100
|
+
export interface DecorateOptions {
|
|
101
|
+
/** Overlay padding around the host, in px (default 40). */
|
|
102
|
+
padding?: number;
|
|
103
|
+
seed?: number;
|
|
104
|
+
colors?: string[];
|
|
105
|
+
theme?: { light: string; mid: string; dark: string };
|
|
106
|
+
text?: string;
|
|
107
|
+
font?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
export interface UIFXInstance {
|
|
90
111
|
el: HTMLElement;
|
|
91
112
|
canvas: HTMLCanvasElement;
|
|
@@ -117,4 +138,41 @@ export declare function mountUIFX(
|
|
|
117
138
|
options?: MountOptions
|
|
118
139
|
): UIFXInstance;
|
|
119
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The instance returned by decorateUIFX. Like UIFXInstance but WITHOUT `wrapper`
|
|
143
|
+
* (there is none -- the overlay is a sibling of the host, not a wrapper around
|
|
144
|
+
* it), and setValue/setChecked are hijack-only: a decoration reflects the host,
|
|
145
|
+
* it does not drive it, so both throw.
|
|
146
|
+
*/
|
|
147
|
+
export interface DecorateInstance {
|
|
148
|
+
/** The decorated host element (unchanged -- decorate never mutates it). */
|
|
149
|
+
el: HTMLElement;
|
|
150
|
+
/** The overlay canvas (the only DOM node decorate adds). */
|
|
151
|
+
canvas: HTMLCanvasElement;
|
|
152
|
+
state: UIFXState;
|
|
153
|
+
/** Hijack-only. Throws in decorate mode. */
|
|
154
|
+
setValue(v?: number | null): void;
|
|
155
|
+
/** Hijack-only. Throws in decorate mode. */
|
|
156
|
+
setChecked(b?: boolean): void;
|
|
157
|
+
/** Remove the overlay + every listener decorate added; the host is left
|
|
158
|
+
* byte-identical to before decorate. Idempotent. */
|
|
159
|
+
destroy(): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decorate an EXISTING visible element with a canvas recipe WITHOUT hijacking it:
|
|
164
|
+
* no native element is created, opacity is never set, and the host is never
|
|
165
|
+
* reparented. An overlay canvas is added as a sibling and removed on destroy, so
|
|
166
|
+
* the host is byte-identical before and after. Recipe state is wired from the
|
|
167
|
+
* host's own events; for a form-control host, state.text/state.valid mirror
|
|
168
|
+
* el.value/el.validity (read at event time, never per frame). This is the honest
|
|
169
|
+
* home for a decoration over a real input (PasswordStrength, TypewriterField) and
|
|
170
|
+
* for generic form feedback (FocusHalo, ErrorShake, SuccessBloom). See 0004.
|
|
171
|
+
*/
|
|
172
|
+
export declare function decorateUIFX(
|
|
173
|
+
el: HTMLElement,
|
|
174
|
+
recipeFactory: RecipeFactory,
|
|
175
|
+
options?: DecorateOptions
|
|
176
|
+
): DecorateInstance;
|
|
177
|
+
|
|
120
178
|
export default mountUIFX;
|
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.
|
|
25
|
+
export const VERSION = '1.6.0';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------
|
|
28
28
|
// SHARED TICKER (ref-counted, one RAF for all UI components)
|
|
@@ -87,6 +87,12 @@ const KNOWN_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle'
|
|
|
87
87
|
const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font', 'knobMode', 'announce'];
|
|
88
88
|
const KNOB_MODES = ['rotate', 'vertical'];
|
|
89
89
|
|
|
90
|
+
// Options valid in decorate mode (decorateUIFX). A canvas AROUND a live element
|
|
91
|
+
// inherits the host's geometry (offset box) and value (read from el), so the
|
|
92
|
+
// hijack-only options (width/height/value/checked/disabled/knobMode/announce/
|
|
93
|
+
// label) are rejected here -- fail closed. Cold: read only at mount.
|
|
94
|
+
const DECORATE_OPTIONS = ['padding', 'seed', 'colors', 'theme', 'text', 'font'];
|
|
95
|
+
|
|
90
96
|
// Levenshtein edit distance. Cold: only reached on the error path.
|
|
91
97
|
function _editDistance(a, b) {
|
|
92
98
|
const al = a.length;
|
|
@@ -695,4 +701,327 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
695
701
|
}
|
|
696
702
|
}
|
|
697
703
|
|
|
704
|
+
// =========================================================
|
|
705
|
+
// decorateUIFX -- The second mount mode (canvas AROUND a live element)
|
|
706
|
+
// =========================================================
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Decorate an EXISTING visible element with a canvas recipe, WITHOUT hijacking
|
|
710
|
+
* it. Unlike mountUIFX this creates no native element, never sets opacity:0, and
|
|
711
|
+
* never reparents `el`: it adds ONE absolutely-positioned overlay canvas as a
|
|
712
|
+
* sibling in el.parentNode (placed from el's offset box) plus the listeners it
|
|
713
|
+
* owns, and on destroy removes exactly those -- the host is byte-identical to
|
|
714
|
+
* before. Recipe state is wired from el's own events; for a form-control host,
|
|
715
|
+
* state.text and state.valid mirror el.value / el.validity (read at event time,
|
|
716
|
+
* never per frame). This is the honest home for a decoration over a real input
|
|
717
|
+
* (PasswordStrength, TypewriterField) and for generic form feedback (FocusHalo,
|
|
718
|
+
* ErrorShake, SuccessBloom). See decisions/0004.
|
|
719
|
+
*
|
|
720
|
+
* @param {HTMLElement} el The live element to decorate (stays visible).
|
|
721
|
+
* @param {Function} recipeFactory (options) => Recipe object
|
|
722
|
+
* @param {Object} [options] padding, seed, colors, theme, text, font
|
|
723
|
+
* @returns {{ el, canvas, state, setValue, setChecked, destroy }}
|
|
724
|
+
*/
|
|
725
|
+
export function decorateUIFX(el, recipeFactory, options = {}) {
|
|
726
|
+
// =====================================================================
|
|
727
|
+
// PHASE 1 -- VALIDATION ONLY. No side effect until every check passes
|
|
728
|
+
// (fail closed, mirrors mountUIFX): no createElement, no insertBefore,
|
|
729
|
+
// no ticker acquire, no recipe.init.
|
|
730
|
+
// =====================================================================
|
|
731
|
+
|
|
732
|
+
// 1. el must be a live, attached DOM element -- we read its offset box and
|
|
733
|
+
// hang the overlay off its parent. A detached el has no parentNode to host
|
|
734
|
+
// the canvas: an Error, never a silent no-op.
|
|
735
|
+
if (!el || typeof el.addEventListener !== 'function' ||
|
|
736
|
+
typeof el.getBoundingClientRect !== 'function') {
|
|
737
|
+
throw new Error('decorateUIFX: el must be a DOM element');
|
|
738
|
+
}
|
|
739
|
+
if (!el.parentNode || typeof el.parentNode.insertBefore !== 'function') {
|
|
740
|
+
throw new Error('decorateUIFX: el must be attached to the DOM (no parentNode to host the overlay)');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 2. options: decorate accepts a subset. A hijack-only key is a mistake, not a
|
|
744
|
+
// silent ignore; a truly unknown key gets a did-you-mean over the decorate
|
|
745
|
+
// set. Both fail closed, before any element exists.
|
|
746
|
+
for (const k in options) {
|
|
747
|
+
if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
|
|
748
|
+
if (DECORATE_OPTIONS.indexOf(k) === -1) {
|
|
749
|
+
if (KNOWN_OPTIONS.indexOf(k) !== -1) {
|
|
750
|
+
throw new Error('decorateUIFX: option "' + k + '" is not valid in decorate mode (hijack-only)');
|
|
751
|
+
}
|
|
752
|
+
throw new Error(_didYouMean('decorateUIFX: unknown option', k, DECORATE_OPTIONS));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const padding = options.padding === undefined ? 40 : options.padding;
|
|
756
|
+
|
|
757
|
+
// Theming options (decisions/0002): validated fail closed here, forwarded to
|
|
758
|
+
// the recipe factory which resolves them in init. Cold mount code.
|
|
759
|
+
const _theme = options.theme;
|
|
760
|
+
if (_theme !== undefined) {
|
|
761
|
+
if (_theme === null || typeof _theme !== 'object' ||
|
|
762
|
+
typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
|
|
763
|
+
typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
|
|
764
|
+
throw new Error('decorateUIFX: option "theme" must be { light, mid, dark } of color strings');
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const _colors = options.colors;
|
|
768
|
+
if (_colors !== undefined &&
|
|
769
|
+
(!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
|
|
770
|
+
throw new Error('decorateUIFX: option "colors" must be an array of color strings');
|
|
771
|
+
}
|
|
772
|
+
if (options.text !== undefined && typeof options.text !== 'string') {
|
|
773
|
+
throw new Error('decorateUIFX: option "text" must be a string');
|
|
774
|
+
}
|
|
775
|
+
if (options.font !== undefined && typeof options.font !== 'string') {
|
|
776
|
+
throw new Error('decorateUIFX: option "font" must be a string');
|
|
777
|
+
}
|
|
778
|
+
if (options.seed !== undefined &&
|
|
779
|
+
(typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
|
|
780
|
+
throw new Error('decorateUIFX: option "seed" must be a finite number');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// 3. recipeFactory + recipe object + hooks (same contract as mountUIFX).
|
|
784
|
+
if (typeof recipeFactory !== 'function') {
|
|
785
|
+
throw new Error('decorateUIFX: recipeFactory must be a function');
|
|
786
|
+
}
|
|
787
|
+
const recipe = recipeFactory(options);
|
|
788
|
+
if (!recipe || typeof recipe !== 'object') {
|
|
789
|
+
throw new Error('decorateUIFX: recipe must be an object');
|
|
790
|
+
}
|
|
791
|
+
if (typeof recipe.tick !== 'function') {
|
|
792
|
+
throw new Error('decorateUIFX: recipe.tick must be a function');
|
|
793
|
+
}
|
|
794
|
+
for (const k in recipe) {
|
|
795
|
+
if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
|
|
796
|
+
if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
|
|
797
|
+
throw new Error(_didYouMean('decorateUIFX: unknown recipe hook', k, KNOWN_HOOKS));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// =====================================================================
|
|
802
|
+
// PHASE 2 -- SIDE EFFECTS (fail-closed unwind, mirrors mountUIFX). The
|
|
803
|
+
// only acquisitions are the overlay canvas, the AbortController, and the
|
|
804
|
+
// shared ticker -- unwound in reverse order on any throw.
|
|
805
|
+
// =====================================================================
|
|
806
|
+
let canvasAppended = false;
|
|
807
|
+
let acCreated = false;
|
|
808
|
+
let tickerAcquired = false;
|
|
809
|
+
let canvas = null;
|
|
810
|
+
let ac = null;
|
|
811
|
+
let removeTick = null;
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
// -- Placement from el's OFFSET box. Because the overlay is a SIBLING of el,
|
|
815
|
+
// they share an offsetParent, so offset-box coords land the canvas over el
|
|
816
|
+
// WITHOUT writing any style onto the parent (decision 2). Read once here,
|
|
817
|
+
// refreshed on resize only. --
|
|
818
|
+
let ow = el.offsetWidth;
|
|
819
|
+
let oh = el.offsetHeight;
|
|
820
|
+
let dpr = window.devicePixelRatio || 1;
|
|
821
|
+
let cw = ow + padding * 2;
|
|
822
|
+
let ch = oh + padding * 2;
|
|
823
|
+
|
|
824
|
+
canvas = document.createElement('canvas');
|
|
825
|
+
canvas.width = cw * dpr;
|
|
826
|
+
canvas.height = ch * dpr;
|
|
827
|
+
Object.assign(canvas.style, {
|
|
828
|
+
position: 'absolute',
|
|
829
|
+
left: (el.offsetLeft - padding) + 'px',
|
|
830
|
+
top: (el.offsetTop - padding) + 'px',
|
|
831
|
+
width: cw + 'px', height: ch + 'px',
|
|
832
|
+
pointerEvents: 'none',
|
|
833
|
+
});
|
|
834
|
+
const ctx = canvas.getContext('2d');
|
|
835
|
+
ctx.scale(dpr, dpr);
|
|
836
|
+
|
|
837
|
+
// Insert the overlay right AFTER el: among auto-z siblings it paints on top,
|
|
838
|
+
// and pointerEvents:none keeps el receiving every event. el is NOT touched --
|
|
839
|
+
// no style write, no reparent (the whole point of decorate mode).
|
|
840
|
+
el.parentNode.insertBefore(canvas, el.nextSibling);
|
|
841
|
+
canvasAppended = true;
|
|
842
|
+
|
|
843
|
+
// -- State. Generic fields wire like hijack mode; text/valid mirror the host,
|
|
844
|
+
// read now at init (law: hook initial values from the element) and refreshed
|
|
845
|
+
// at event time only. --
|
|
846
|
+
const state = {
|
|
847
|
+
hover: false,
|
|
848
|
+
active: false,
|
|
849
|
+
focused: (typeof document !== 'undefined' && document.activeElement === el),
|
|
850
|
+
text: (typeof el.value === 'string' ? el.value : ''),
|
|
851
|
+
valid: (el.validity ? !!el.validity.valid : true),
|
|
852
|
+
w: ow, h: oh, padding, dpr,
|
|
853
|
+
};
|
|
854
|
+
const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
|
|
855
|
+
// Cached rect for pointer math (U-11): filled lazily, refreshed on enter/
|
|
856
|
+
// scroll/resize; pointermove does ZERO layout reads at steady state.
|
|
857
|
+
let rect = null;
|
|
858
|
+
|
|
859
|
+
// -- Initialize recipe (validated in phase 1). ctx exists now. --
|
|
860
|
+
if (recipe.init) recipe.init(ctx, ow, oh, padding);
|
|
861
|
+
|
|
862
|
+
// -- Events (all via AbortController: destroy()'s abort removes exactly what
|
|
863
|
+
// decorate added and nothing the host owned). --
|
|
864
|
+
ac = new AbortController();
|
|
865
|
+
acCreated = true;
|
|
866
|
+
const signal = ac.signal;
|
|
867
|
+
|
|
868
|
+
function updatePointer(e) {
|
|
869
|
+
if (!rect) rect = el.getBoundingClientRect();
|
|
870
|
+
const nx = e.clientX - rect.left;
|
|
871
|
+
const ny = e.clientY - rect.top;
|
|
872
|
+
pointer.vx = nx - pointer.x;
|
|
873
|
+
pointer.vy = ny - pointer.y;
|
|
874
|
+
pointer.x = nx;
|
|
875
|
+
pointer.y = ny;
|
|
876
|
+
}
|
|
877
|
+
function refreshRect() { rect = el.getBoundingClientRect(); }
|
|
878
|
+
// Reposition the overlay from the offset box after a layout change (cold path).
|
|
879
|
+
// All layout READS are hoisted above the style WRITES: writing canvas.style
|
|
880
|
+
// dirties layout, so reading el.offset* afterwards would force a synchronous
|
|
881
|
+
// reflow. A decoration over live DOM is the one place this package can force
|
|
882
|
+
// layout (see the U4b brief HOT PATH note), so keep read-before-write even here.
|
|
883
|
+
function reposition() {
|
|
884
|
+
const nw = el.offsetWidth;
|
|
885
|
+
const nh = el.offsetHeight;
|
|
886
|
+
const ol = el.offsetLeft;
|
|
887
|
+
const ot = el.offsetTop;
|
|
888
|
+
canvas.style.left = (ol - padding) + 'px';
|
|
889
|
+
canvas.style.top = (ot - padding) + 'px';
|
|
890
|
+
if (nw !== ow || nh !== oh) {
|
|
891
|
+
ow = nw; oh = nh;
|
|
892
|
+
cw = ow + padding * 2;
|
|
893
|
+
ch = oh + padding * 2;
|
|
894
|
+
canvas.width = cw * dpr;
|
|
895
|
+
canvas.height = ch * dpr;
|
|
896
|
+
canvas.style.width = cw + 'px';
|
|
897
|
+
canvas.style.height = ch + 'px';
|
|
898
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
899
|
+
state.w = ow; state.h = oh;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
window.addEventListener('scroll', refreshRect, { passive: true, signal });
|
|
904
|
+
window.addEventListener('resize', () => { reposition(); refreshRect(); }, { passive: true, signal });
|
|
905
|
+
|
|
906
|
+
el.addEventListener('pointermove', updatePointer, { signal });
|
|
907
|
+
el.addEventListener('pointerenter', (e) => {
|
|
908
|
+
state.hover = true;
|
|
909
|
+
refreshRect();
|
|
910
|
+
updatePointer(e);
|
|
911
|
+
if (recipe.onHover) recipe.onHover(state, pointer);
|
|
912
|
+
}, { signal });
|
|
913
|
+
el.addEventListener('pointerleave', () => {
|
|
914
|
+
state.hover = false;
|
|
915
|
+
if (recipe.onLeave) recipe.onLeave(state, pointer);
|
|
916
|
+
}, { signal });
|
|
917
|
+
el.addEventListener('pointerdown', (e) => {
|
|
918
|
+
state.active = true;
|
|
919
|
+
updatePointer(e);
|
|
920
|
+
if (recipe.onClick) recipe.onClick(pointer.x, pointer.y, state);
|
|
921
|
+
}, { signal });
|
|
922
|
+
el.addEventListener('pointerup', () => { state.active = false; }, { signal });
|
|
923
|
+
|
|
924
|
+
el.addEventListener('focus', () => { state.focused = true; }, { signal });
|
|
925
|
+
el.addEventListener('blur', () => { state.focused = false; }, { signal });
|
|
926
|
+
|
|
927
|
+
// Host content -> state, at EVENT time only (el.value getter allocates a
|
|
928
|
+
// string; keep it off the frame path). A non-form host never fires these.
|
|
929
|
+
function syncHostValue() {
|
|
930
|
+
state.text = (typeof el.value === 'string' ? el.value : '');
|
|
931
|
+
state.valid = (el.validity ? !!el.validity.valid : true);
|
|
932
|
+
}
|
|
933
|
+
el.addEventListener('input', syncHostValue, { signal });
|
|
934
|
+
el.addEventListener('change', syncHostValue, { signal });
|
|
935
|
+
el.addEventListener('invalid', () => { state.valid = false; }, { signal });
|
|
936
|
+
|
|
937
|
+
// -- DPR re-read on display change (cold, feature-detected; absent matchMedia
|
|
938
|
+
// is a silent no-op -- fail closed, never throw). --
|
|
939
|
+
if (typeof window.matchMedia === 'function') {
|
|
940
|
+
const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
|
|
941
|
+
mq.addEventListener('change', () => {
|
|
942
|
+
const nd = window.devicePixelRatio || 1;
|
|
943
|
+
dpr = nd;
|
|
944
|
+
canvas.width = cw * nd;
|
|
945
|
+
canvas.height = ch * nd;
|
|
946
|
+
ctx.setTransform(nd, 0, 0, nd, 0, 0);
|
|
947
|
+
state.dpr = nd;
|
|
948
|
+
}, { signal });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// -- Render loop (shared ticker; same quarantine-on-throw as mountUIFX). --
|
|
952
|
+
const ticker = acquireTicker();
|
|
953
|
+
tickerAcquired = true;
|
|
954
|
+
let destroyed = false;
|
|
955
|
+
let quarantined = false;
|
|
956
|
+
|
|
957
|
+
removeTick = ticker.add((dtMs) => {
|
|
958
|
+
if (destroyed || quarantined) return;
|
|
959
|
+
const dt = dtMs / 1000;
|
|
960
|
+
const now = performance.now();
|
|
961
|
+
|
|
962
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
963
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
964
|
+
ctx.save();
|
|
965
|
+
ctx.translate(padding, padding); // Origin = the host element's top-left
|
|
966
|
+
try {
|
|
967
|
+
recipe.tick(ctx, dt, now, state, pointer);
|
|
968
|
+
} catch (err) {
|
|
969
|
+
quarantined = true;
|
|
970
|
+
console.error('decorateUIFX: recipe.tick threw; decoration quarantined', err);
|
|
971
|
+
ctx.restore();
|
|
972
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
ctx.restore();
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// -- Public API --
|
|
979
|
+
return {
|
|
980
|
+
/** The decorated host element (unchanged; provided for external reads). */
|
|
981
|
+
el,
|
|
982
|
+
|
|
983
|
+
/** The overlay canvas (for external styling). */
|
|
984
|
+
canvas,
|
|
985
|
+
|
|
986
|
+
/** Current state (read-only reference). */
|
|
987
|
+
state,
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Hijack-only. A decoration reflects the host; it does not own or push
|
|
991
|
+
* into the host's value, so setValue/setChecked fail closed here (use the
|
|
992
|
+
* host's own API to change it -- the decoration follows via its events).
|
|
993
|
+
*/
|
|
994
|
+
setValue() {
|
|
995
|
+
throw new Error('decorateUIFX: setValue is hijack-only; a decoration reflects the host, it does not drive it');
|
|
996
|
+
},
|
|
997
|
+
setChecked() {
|
|
998
|
+
throw new Error('decorateUIFX: setChecked is hijack-only; a decoration reflects the host, it does not drive it');
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
/** Destroy: remove the overlay + every listener decorate added. Idempotent.
|
|
1002
|
+
* The host element is byte-identical to before decorate (never touched). */
|
|
1003
|
+
destroy() {
|
|
1004
|
+
if (destroyed) return;
|
|
1005
|
+
destroyed = true;
|
|
1006
|
+
ac.abort();
|
|
1007
|
+
removeTick();
|
|
1008
|
+
if (recipe.destroy) recipe.destroy();
|
|
1009
|
+
releaseTicker();
|
|
1010
|
+
canvas.remove(); // the ONLY DOM node decorate added
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
// A phase-2 step threw (realistically recipe.init). Unwind ONLY what was
|
|
1015
|
+
// acquired, reverse order, each flag-guarded. recipe.destroy is NOT called
|
|
1016
|
+
// (init did not succeed). Re-throw the ORIGINAL error, unwrapped.
|
|
1017
|
+
if (tickerAcquired) {
|
|
1018
|
+
if (removeTick) removeTick();
|
|
1019
|
+
releaseTicker();
|
|
1020
|
+
}
|
|
1021
|
+
if (acCreated) ac.abort();
|
|
1022
|
+
if (canvasAppended) canvas.remove();
|
|
1023
|
+
throw err;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
698
1027
|
export default mountUIFX;
|
package/UIFXRecipes.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { UIFXRecipe, UIFXInstance, MountOptions } from './UIFXController';
|
|
2
2
|
|
|
3
3
|
// ===========================================================
|
|
4
|
-
// RECIPE OPTIONS + FACTORIES (all
|
|
4
|
+
// RECIPE OPTIONS + FACTORIES (all 56)
|
|
5
5
|
// ===========================================================
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -119,6 +119,13 @@ export declare function ScratchReveal(options?: RecipeOptions): UIFXRecipe;
|
|
|
119
119
|
export declare function TimerCountdown(options?: RecipeOptions): UIFXRecipe;
|
|
120
120
|
export declare function PullRefresh(options?: RecipeOptions): UIFXRecipe;
|
|
121
121
|
|
|
122
|
+
// -- U4b: DECORATE recipes (mounted AROUND a live element via decorateUIFX).
|
|
123
|
+
// Generic form feedback; PasswordStrength + TypewriterField (above) re-home
|
|
124
|
+
// onto decorate mode too. See decisions/0004. --
|
|
125
|
+
export declare function FocusHalo(options?: RecipeOptions): UIFXRecipe;
|
|
126
|
+
export declare function ErrorShake(options?: RecipeOptions): UIFXRecipe;
|
|
127
|
+
export declare function SuccessBloom(options?: RecipeOptions): UIFXRecipe;
|
|
128
|
+
|
|
122
129
|
// ===========================================================
|
|
123
130
|
// BARREL OBJECTS (back-compat)
|
|
124
131
|
// ===========================================================
|
|
@@ -189,11 +196,21 @@ export declare const UIFXRecipes4: {
|
|
|
189
196
|
LiquidFill: typeof LiquidFill;
|
|
190
197
|
};
|
|
191
198
|
|
|
199
|
+
/** U4b additions -- decorate-mode recipes (kept out of the Vol.1-3 + Vol.4 snapshots). */
|
|
200
|
+
export declare const UIFXRecipes5: {
|
|
201
|
+
FocusHalo: typeof FocusHalo;
|
|
202
|
+
ErrorShake: typeof ErrorShake;
|
|
203
|
+
SuccessBloom: typeof SuccessBloom;
|
|
204
|
+
};
|
|
205
|
+
|
|
192
206
|
// ===========================================================
|
|
193
207
|
// RECIPE REGISTRY
|
|
194
208
|
// ===========================================================
|
|
195
209
|
|
|
196
|
-
|
|
210
|
+
// 'decorate' is not a UIType (it creates no native element); it is the registry
|
|
211
|
+
// routing tag for a recipe mounted AROUND a live element via decorateUIFX. See
|
|
212
|
+
// decisions/0004.
|
|
213
|
+
export type RecipeType = 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob' | 'decorate';
|
|
197
214
|
|
|
198
215
|
export type RecipeFactory = (options?: Record<string, unknown>) => UIFXRecipe;
|
|
199
216
|
|
|
@@ -223,7 +240,10 @@ export declare function registerRecipe(
|
|
|
223
240
|
): RecipeFactory;
|
|
224
241
|
|
|
225
242
|
/**
|
|
226
|
-
* Resolve a recipe id to its factory + declared type and mount it
|
|
243
|
+
* Resolve a recipe id to its factory + declared type and mount it. A hijack
|
|
244
|
+
* recipe mounts via mountUIFX (the native element is created inside `container`);
|
|
245
|
+
* a recipe whose meta.type is 'decorate' mounts via decorateUIFX, treating
|
|
246
|
+
* `container` as the LIVE element to decorate (a canvas is placed AROUND it).
|
|
227
247
|
* Fail closed: unknown id or a conflicting options.type throws.
|
|
228
248
|
*/
|
|
229
249
|
export declare function mountRecipe(
|
|
@@ -233,7 +253,7 @@ export declare function mountRecipe(
|
|
|
233
253
|
): UIFXInstance;
|
|
234
254
|
|
|
235
255
|
// ===========================================================
|
|
236
|
-
// DEFAULT EXPORT -- combined all-
|
|
256
|
+
// DEFAULT EXPORT -- combined all-56 namespace
|
|
237
257
|
// ===========================================================
|
|
238
258
|
|
|
239
259
|
declare const UIFXAllRecipes: {
|
|
@@ -290,5 +310,8 @@ declare const UIFXAllRecipes: {
|
|
|
290
310
|
ScratchReveal: typeof ScratchReveal;
|
|
291
311
|
TimerCountdown: typeof TimerCountdown;
|
|
292
312
|
PullRefresh: typeof PullRefresh;
|
|
313
|
+
FocusHalo: typeof FocusHalo;
|
|
314
|
+
ErrorShake: typeof ErrorShake;
|
|
315
|
+
SuccessBloom: typeof SuccessBloom;
|
|
293
316
|
};
|
|
294
317
|
export default UIFXAllRecipes;
|
package/UIFXRecipes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-ui-fx -- Recipe Collection (all
|
|
2
|
+
* @zakkster/lite-ui-fx -- Recipe Collection (all 56)
|
|
3
3
|
*
|
|
4
4
|
* The three recipe volumes consolidated into one shipped, typed, versioned
|
|
5
5
|
* module, exposed as the ./recipes subpath export, plus the U4a additions for
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* TypewriterField, SoundWaveBtn, UploadProgress, ScratchReveal,
|
|
20
20
|
* TimerCountdown, PullRefresh
|
|
21
21
|
* U4a (3): TickDraw, IndeterminateScan (CHECKBOX), LiquidFill (PROGRESS)
|
|
22
|
+
* U4b (3): FocusHalo, ErrorShake, SuccessBloom (DECORATE) -- generic form
|
|
23
|
+
* feedback; PasswordStrength + TypewriterField re-homed to DECORATE
|
|
24
|
+
* (canvas AROUND a live input, driven by state.text; see 0004)
|
|
22
25
|
*
|
|
23
26
|
* Registry: RECIPES (null-prototype), RECIPE_META (live), RECIPE_NAMES,
|
|
24
27
|
* registerRecipe(id, factory, meta?), mountRecipe(container, id, options?).
|
|
@@ -32,7 +35,7 @@
|
|
|
32
35
|
|
|
33
36
|
import { lerp, clamp, easeOut, easeIn, easeInOut } from '@zakkster/lite-lerp';
|
|
34
37
|
import { Random } from '@zakkster/lite-random';
|
|
35
|
-
import { mountUIFX, UIType } from './UIFXController.js';
|
|
38
|
+
import { mountUIFX, decorateUIFX, UIType } from './UIFXController.js';
|
|
36
39
|
|
|
37
40
|
|
|
38
41
|
// ---------------------------------------------------------
|
|
@@ -2294,6 +2297,28 @@ export function RadioOrbit(o = {}) {
|
|
|
2294
2297
|
// ===========================================================
|
|
2295
2298
|
|
|
2296
2299
|
/** 9. Password Strength -- Segmented bar with color progression and label. */
|
|
2300
|
+
/** Password strength 0..1 from a string, zero-alloc (charCodeAt scan, no
|
|
2301
|
+
* allocating string ops). Length (up to ~12 chars) is 60%, character-class
|
|
2302
|
+
* diversity (lower/upper/digit/symbol) 40%. COLD -- called only when the
|
|
2303
|
+
* decorated field's text changes. */
|
|
2304
|
+
function pwStrength(s) {
|
|
2305
|
+
const n = s.length;
|
|
2306
|
+
if (n === 0) return 0;
|
|
2307
|
+
let lo = 0, up = 0, di = 0, sy = 0;
|
|
2308
|
+
for (let i = 0; i < n; i++) {
|
|
2309
|
+
const c = s.charCodeAt(i);
|
|
2310
|
+
if (c >= 97 && c <= 122) lo = 1;
|
|
2311
|
+
else if (c >= 65 && c <= 90) up = 1;
|
|
2312
|
+
else if (c >= 48 && c <= 57) di = 1;
|
|
2313
|
+
else sy = 1;
|
|
2314
|
+
}
|
|
2315
|
+
const v = Math.min(n / 12, 1) * 0.6 + ((lo + up + di + sy) / 4) * 0.4;
|
|
2316
|
+
return v > 1 ? 1 : v;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/** Password Strength (U4b DECORATE, re-home) -- four strength segments driven by
|
|
2320
|
+
* the LIVE host input's value (state.text), not a faked slider. Strength is
|
|
2321
|
+
* recomputed only when the text changes (cold); the tick is zero-alloc. */
|
|
2297
2322
|
export function PasswordStrength(o = {}) {
|
|
2298
2323
|
let segs=[0,0,0,0];
|
|
2299
2324
|
const labels=['WEAK','FAIR','GOOD','STRONG'];
|
|
@@ -2302,9 +2327,14 @@ export function PasswordStrength(o = {}) {
|
|
|
2302
2327
|
: (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#ff6b6b','#fbbf24','#38bdf8','#6ee7b6']);
|
|
2303
2328
|
const colors = base.length >= 4 ? base : [base[0], base[1 % base.length], base[2 % base.length], base[3 % base.length]];
|
|
2304
2329
|
const noneColor = (o.theme && o.theme.mid) || '#666';
|
|
2330
|
+
let lastText = null, strength = 0; // strength recomputed only on text change
|
|
2305
2331
|
return {
|
|
2306
2332
|
tick(c,dt,now,st) {
|
|
2307
|
-
|
|
2333
|
+
// state.text is the live host value; rescan only on change (cold). A
|
|
2334
|
+
// bare decoration over an empty field reads '' -> strength 0.
|
|
2335
|
+
const t = st.text || '';
|
|
2336
|
+
if (t !== lastText) { lastText = t; strength = pwStrength(t); }
|
|
2337
|
+
const level=Math.ceil(strength*4);
|
|
2308
2338
|
for(let i=0;i<4;i++) segs[i]=lerp(segs[i],i<level?1:0,dt*10);
|
|
2309
2339
|
|
|
2310
2340
|
const segW=(st.w-12)/4,segH=8;
|
|
@@ -2562,33 +2592,43 @@ export function NotificationBell(o = {}) {
|
|
|
2562
2592
|
// ===========================================================
|
|
2563
2593
|
|
|
2564
2594
|
/** 15. Typewriter Field -- Characters appear one by one with cursor blink. */
|
|
2595
|
+
/** Typewriter Field (U4b DECORATE, re-home) -- an animated underline that grows
|
|
2596
|
+
* with the LIVE host input's text and a caret that flares on each new character.
|
|
2597
|
+
* Draws NO text (the real input shows its own; a decoration never re-renders the
|
|
2598
|
+
* host content) and reads only state.text's length -- zero-alloc, no measureText. */
|
|
2565
2599
|
export function TypewriterField(o = {}) {
|
|
2566
2600
|
const P = resolveTheme(o, { accent: '#6ee7b6', dim2: '#8888aa' });
|
|
2567
|
-
const
|
|
2568
|
-
const
|
|
2569
|
-
let
|
|
2570
|
-
let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
|
|
2601
|
+
const themed = !!(o.theme || o.colors);
|
|
2602
|
+
const glow = themed ? rgbaOf(P.accent, .5) : 'rgba(110,231,182,.5)';
|
|
2603
|
+
let lastLen=0, fill=0, spark=0, blink=0;
|
|
2571
2604
|
return {
|
|
2572
|
-
onToggle(checked){typing=checked;if(checked){charIdx=0;timer=0}},
|
|
2573
2605
|
tick(c,dt,now,st) {
|
|
2574
|
-
|
|
2575
|
-
if(
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
//
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2606
|
+
const len=(st.text || '').length;
|
|
2607
|
+
if(len>lastLen) spark=1; // a new char landed -> caret pulse
|
|
2608
|
+
lastLen=len;
|
|
2609
|
+
blink=(blink+dt*3)%2;
|
|
2610
|
+
spark=spark>0?spark-dt*3:0;
|
|
2611
|
+
|
|
2612
|
+
// Underline grows toward a fraction of the width set by text length
|
|
2613
|
+
// (capped at ~24 chars = full width). No measureText -> zero-alloc.
|
|
2614
|
+
const target=len===0?0:Math.min(len/24,1);
|
|
2615
|
+
fill=lerp(fill,target,dt*8);
|
|
2616
|
+
const y=st.h-3, x0=2, x1=2+(st.w-4)*fill;
|
|
2617
|
+
|
|
2618
|
+
c.strokeStyle='rgba(255,255,255,.08)';c.lineWidth=2;
|
|
2619
|
+
c.beginPath();c.moveTo(x0,y);c.lineTo(st.w-2,y);c.stroke();
|
|
2620
|
+
c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2621
|
+
c.beginPath();c.moveTo(x0,y);c.lineTo(x1,y);c.stroke();
|
|
2622
|
+
|
|
2623
|
+
// Caret: a glow that flares on each keystroke, blinks when idle+focused.
|
|
2624
|
+
if(spark>0.01){
|
|
2625
|
+
c.globalAlpha=spark;c.fillStyle=glow;
|
|
2626
|
+
c.beginPath();c.arc(x1,y,4+spark*3,0,PI2);c.fill();
|
|
2627
|
+
c.globalAlpha=1;
|
|
2628
|
+
}
|
|
2629
|
+
if(st.focused&&blink<1){
|
|
2630
|
+
c.fillStyle=P.accent;c.fillRect(x1,y-9,1.5,12);
|
|
2588
2631
|
}
|
|
2589
|
-
|
|
2590
|
-
lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?P.accent:P.dim2);
|
|
2591
|
-
if(st.focused)fr(c,st.w,st.h,6);
|
|
2592
2632
|
},
|
|
2593
2633
|
};
|
|
2594
2634
|
}
|
|
@@ -2854,6 +2894,100 @@ export const UIFXRecipes3 = {
|
|
|
2854
2894
|
ScratchReveal, TimerCountdown, PullRefresh,
|
|
2855
2895
|
};
|
|
2856
2896
|
|
|
2897
|
+
// ===========================================================
|
|
2898
|
+
// DECORATIONS (U4b) -- canvas AROUND a live element (decorateUIFX). Generic form
|
|
2899
|
+
// feedback reading state.focused / state.valid / state.text; NONE draw the host's
|
|
2900
|
+
// own content. Born themed + zero-alloc + t3-gated. See decisions/0004.
|
|
2901
|
+
// ===========================================================
|
|
2902
|
+
|
|
2903
|
+
/** Focus Halo (U4b DECORATE) -- a soft glow around the host that breathes while
|
|
2904
|
+
* focused and fades on blur. Generic form feedback; reads only state.focused. */
|
|
2905
|
+
export function FocusHalo(o = {}) {
|
|
2906
|
+
const P = resolveTheme(o, { accent: '#6ee7b6' });
|
|
2907
|
+
let halo=0; // 0..1 presence
|
|
2908
|
+
return {
|
|
2909
|
+
tick(c,dt,now,st) {
|
|
2910
|
+
halo=lerp(halo, st.focused?1:0, dt*8);
|
|
2911
|
+
if(halo<0.01) return;
|
|
2912
|
+
const breathe=0.75+Math.sin(now/380)*0.25, r=8;
|
|
2913
|
+
c.strokeStyle=P.accent;
|
|
2914
|
+
for(let i=3;i>=1;i--){
|
|
2915
|
+
c.globalAlpha=halo*breathe*(0.10*i);
|
|
2916
|
+
c.lineWidth=i*2;
|
|
2917
|
+
rr(c,-i*2,-i*2,st.w+i*4,st.h+i*4,r+i*2);c.stroke();
|
|
2918
|
+
}
|
|
2919
|
+
c.globalAlpha=halo;c.strokeStyle=P.accent;c.lineWidth=1.5;
|
|
2920
|
+
rr(c,-1,-1,st.w+2,st.h+2,r);c.stroke();
|
|
2921
|
+
c.globalAlpha=1;
|
|
2922
|
+
},
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** Error Shake (U4b DECORATE) -- a red border that shakes on the state.valid
|
|
2927
|
+
* true->false edge and settles as the shake decays; a steady red border holds
|
|
2928
|
+
* while invalid. Draws only its OWN jitter (never moves the host). */
|
|
2929
|
+
export function ErrorShake(o = {}) {
|
|
2930
|
+
const P = resolveTheme(o, { accent: '#ff6b6b' });
|
|
2931
|
+
let wasValid=true, shake=0;
|
|
2932
|
+
return {
|
|
2933
|
+
tick(c,dt,now,st) {
|
|
2934
|
+
if(wasValid && st.valid===false) shake=1; // valid -> invalid edge
|
|
2935
|
+
wasValid = st.valid !== false;
|
|
2936
|
+
shake = shake>0 ? shake-dt*1.6 : 0;
|
|
2937
|
+
if(shake<0.01 && st.valid!==false) return; // nothing to show
|
|
2938
|
+
|
|
2939
|
+
const dx = shake>0 ? Math.sin(now/22)*shake*6 : 0;
|
|
2940
|
+
const a = st.valid===false ? 0.9 : shake, r=8;
|
|
2941
|
+
c.globalAlpha=a;c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2942
|
+
rr(c,dx,0,st.w,st.h,r);c.stroke();
|
|
2943
|
+
c.globalAlpha=1;
|
|
2944
|
+
},
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
/** Success Bloom (U4b DECORATE) -- a green ring + fixed-pool particle bloom on the
|
|
2949
|
+
* state.valid false->true edge (a fixed error resolved). Zero-alloc: typed-array
|
|
2950
|
+
* pool preallocated in the factory. */
|
|
2951
|
+
export function SuccessBloom(o = {}) {
|
|
2952
|
+
const P = resolveTheme(o, { accent: '#6ee7b6' });
|
|
2953
|
+
const N = 20;
|
|
2954
|
+
const px=new Float32Array(N), py=new Float32Array(N), pvx=new Float32Array(N), pvy=new Float32Array(N), pa=new Float32Array(N);
|
|
2955
|
+
let wasValid=true, ring=0;
|
|
2956
|
+
function bloom(st){
|
|
2957
|
+
ring=1;
|
|
2958
|
+
const cx=st.w/2, cy=st.h/2;
|
|
2959
|
+
for(let i=0;i<N;i++){
|
|
2960
|
+
const ang=(i/N)*PI2, sp=40+(i%5)*8;
|
|
2961
|
+
px[i]=cx; py[i]=cy; pvx[i]=Math.cos(ang)*sp; pvy[i]=Math.sin(ang)*sp; pa[i]=1;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
return {
|
|
2965
|
+
tick(c,dt,now,st) {
|
|
2966
|
+
// false -> true edge = success. (undefined stays !== false: no edge.)
|
|
2967
|
+
if(wasValid===false && st.valid!==false) bloom(st);
|
|
2968
|
+
wasValid = st.valid !== false;
|
|
2969
|
+
|
|
2970
|
+
if(ring>0){
|
|
2971
|
+
ring-=dt*1.4; if(ring<0) ring=0;
|
|
2972
|
+
const cx=st.w/2, cy=st.h/2, rad=(1-ring)*st.w*0.6;
|
|
2973
|
+
c.globalAlpha=ring;c.strokeStyle=P.accent;c.lineWidth=2;
|
|
2974
|
+
c.beginPath();c.arc(cx,cy,rad,0,PI2);c.stroke();
|
|
2975
|
+
c.globalAlpha=1;
|
|
2976
|
+
}
|
|
2977
|
+
c.fillStyle=P.accent;
|
|
2978
|
+
for(let i=0;i<N;i++){
|
|
2979
|
+
if(pa[i]<=0) continue;
|
|
2980
|
+
px[i]+=pvx[i]*dt; py[i]+=pvy[i]*dt; pvx[i]*=0.92; pvy[i]*=0.92; pa[i]-=dt*1.4;
|
|
2981
|
+
if(pa[i]<=0) continue;
|
|
2982
|
+
c.globalAlpha=pa[i];
|
|
2983
|
+
c.beginPath();c.arc(px[i],py[i],2.5,0,PI2);c.fill();
|
|
2984
|
+
}
|
|
2985
|
+
c.globalAlpha=1;
|
|
2986
|
+
},
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
|
|
2857
2991
|
// U4a additions -- new native element types (CHECKBOX, PROGRESS). Kept out of the
|
|
2858
2992
|
// Vol.1-3 historical snapshots above so those stay accurate; all recipes remain
|
|
2859
2993
|
// reachable via RECIPES / RECIPE_META and their named exports regardless.
|
|
@@ -2862,6 +2996,13 @@ export const UIFXRecipes4 = {
|
|
|
2862
2996
|
LiquidFill,
|
|
2863
2997
|
};
|
|
2864
2998
|
|
|
2999
|
+
// U4b additions -- decorate-mode recipes (a canvas AROUND a live element). Kept out
|
|
3000
|
+
// of the Vol.1-3 + Vol.4 snapshots above; reachable via RECIPES / RECIPE_META and
|
|
3001
|
+
// their named exports regardless.
|
|
3002
|
+
export const UIFXRecipes5 = {
|
|
3003
|
+
FocusHalo, ErrorShake, SuccessBloom,
|
|
3004
|
+
};
|
|
3005
|
+
|
|
2865
3006
|
|
|
2866
3007
|
// ===========================================================
|
|
2867
3008
|
// DEFAULT EXPORT -- combined all-53 namespace
|
|
@@ -2921,6 +3062,9 @@ export default {
|
|
|
2921
3062
|
ScratchReveal,
|
|
2922
3063
|
TimerCountdown,
|
|
2923
3064
|
PullRefresh,
|
|
3065
|
+
FocusHalo,
|
|
3066
|
+
ErrorShake,
|
|
3067
|
+
SuccessBloom,
|
|
2924
3068
|
};
|
|
2925
3069
|
|
|
2926
3070
|
|
|
@@ -2987,6 +3131,9 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2987
3131
|
scratchReveal: ScratchReveal,
|
|
2988
3132
|
timerCountdown: TimerCountdown,
|
|
2989
3133
|
pullRefresh: PullRefresh,
|
|
3134
|
+
focusHalo: FocusHalo,
|
|
3135
|
+
errorShake: ErrorShake,
|
|
3136
|
+
successBloom: SuccessBloom,
|
|
2990
3137
|
});
|
|
2991
3138
|
|
|
2992
3139
|
/**
|
|
@@ -2994,8 +3141,10 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
2994
3141
|
* a picker without hardcoding the list. A live array: registerRecipe() updates
|
|
2995
3142
|
* it, so existing pickers keep working.
|
|
2996
3143
|
*
|
|
2997
|
-
* type 'toggle'
|
|
2998
|
-
*
|
|
3144
|
+
* type 'toggle'|'button'|'slider'|'checkbox'|'progress'|'knob' -- the
|
|
3145
|
+
* native element it mounts on (mountUIFX); or 'decorate' -- mounted
|
|
3146
|
+
* AROUND a live element via decorateUIFX (no native element created)
|
|
3147
|
+
* family display grouping (Toggles, Buttons, Sliders, Knobs, Form, ...)
|
|
2999
3148
|
* themeable accepts { colors, theme } (true for all as of U3b/1.4.0)
|
|
3000
3149
|
* motionSafe inherently-calm under prefers-reduced-motion (false for all -- U5)
|
|
3001
3150
|
*/
|
|
@@ -3041,18 +3190,21 @@ export const RECIPE_META = [
|
|
|
3041
3190
|
{ id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
|
|
3042
3191
|
{ id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
|
|
3043
3192
|
{ id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
|
|
3044
|
-
{ id: 'passwordStrength', name: 'Password Strength', type: '
|
|
3193
|
+
{ id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: false },
|
|
3045
3194
|
{ id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
3046
3195
|
{ id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
3047
3196
|
{ id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: true, motionSafe: false },
|
|
3048
3197
|
{ id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
|
|
3049
3198
|
{ id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
|
|
3050
|
-
{ id: 'typewriterField', name: 'Typewriter Field', type: '
|
|
3199
|
+
{ id: 'typewriterField', name: 'Typewriter Field', type: 'decorate', family: 'Feedback', themeable: true, motionSafe: false },
|
|
3051
3200
|
{ id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: true, motionSafe: false },
|
|
3052
3201
|
{ id: 'uploadProgress', name: 'Upload Progress', type: 'progress', family: 'Feedback', themeable: true, motionSafe: false },
|
|
3053
3202
|
{ id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
|
|
3054
3203
|
{ id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: true, motionSafe: false },
|
|
3055
3204
|
{ id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
|
|
3205
|
+
{ id: 'focusHalo', name: 'Focus Halo', type: 'decorate', family: 'Form', themeable: true, motionSafe: false },
|
|
3206
|
+
{ id: 'errorShake', name: 'Error Shake', type: 'decorate', family: 'Form', themeable: true, motionSafe: false },
|
|
3207
|
+
{ id: 'successBloom', name: 'Success Bloom', type: 'decorate', family: 'Form', themeable: true, motionSafe: false },
|
|
3056
3208
|
];
|
|
3057
3209
|
|
|
3058
3210
|
/** Names of every built-in recipe (the keys of RECIPES at load time). */
|
|
@@ -3061,7 +3213,11 @@ export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
|
|
|
3061
3213
|
// The valid recipe/mount types, taken from the controller's UIType so the
|
|
3062
3214
|
// registry's fail-closed check and the controller's mount guard are one source
|
|
3063
3215
|
// of truth (they cannot drift as U4 adds types). Built once at load (cold).
|
|
3064
|
-
|
|
3216
|
+
// Plus the ONE non-UIType routing tag: 'decorate' (U4b) creates no native
|
|
3217
|
+
// element -- it is mounted AROUND a live element by decorateUIFX, not by
|
|
3218
|
+
// mountUIFX -- so it is not a UIType, but it is a valid RECIPE_META.type that
|
|
3219
|
+
// mountRecipe routes on (see below). It is the only member not from UIType.
|
|
3220
|
+
const VALID_META_TYPES = new Set([...Object.values(UIType), 'decorate']);
|
|
3065
3221
|
|
|
3066
3222
|
/**
|
|
3067
3223
|
* Register a custom recipe, or override a built-in. Instantly usable via
|
|
@@ -3144,13 +3300,18 @@ function nearestRecipe(id) {
|
|
|
3144
3300
|
}
|
|
3145
3301
|
|
|
3146
3302
|
/**
|
|
3147
|
-
* Resolve a recipe id to its factory + declared type and mount it
|
|
3148
|
-
*
|
|
3303
|
+
* Resolve a recipe id to its factory + declared type and mount it. A hijack
|
|
3304
|
+
* recipe (type toggle/button/slider/checkbox/progress/knob) mounts via mountUIFX,
|
|
3305
|
+
* creating the native element inside `container`. A DECORATE recipe (type
|
|
3306
|
+
* 'decorate', U4b) mounts via decorateUIFX, treating the first argument as the
|
|
3307
|
+
* LIVE element to decorate (a canvas is placed AROUND it -- nothing is created
|
|
3308
|
+
* inside it). Fail closed:
|
|
3149
3309
|
* - unknown id -> throw naming the nearest known id (did-you-mean).
|
|
3150
3310
|
* - options.type present and != the recipe's declared type -> throw.
|
|
3151
3311
|
* options.type is consumed here, never forwarded as a mount option.
|
|
3152
3312
|
*
|
|
3153
|
-
* @param {HTMLElement} container
|
|
3313
|
+
* @param {HTMLElement} container hijack: parent to mount into; decorate: the
|
|
3314
|
+
* live element to decorate.
|
|
3154
3315
|
* @param {string} id
|
|
3155
3316
|
* @param {Object} [options]
|
|
3156
3317
|
* @returns {{ el: HTMLElement, destroy: Function }}
|
|
@@ -3179,6 +3340,13 @@ export function mountRecipe(container, id, options) {
|
|
|
3179
3340
|
mountOptions = {};
|
|
3180
3341
|
for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
|
|
3181
3342
|
}
|
|
3343
|
+
// A decoration is mounted AROUND a live element (no native element created),
|
|
3344
|
+
// so it routes to decorateUIFX with `container` as the host element. Every
|
|
3345
|
+
// other type is a hijack mount. mountUIFX keeps rejecting 'decorate' via its
|
|
3346
|
+
// own _KNOWN_TYPES guard, so the two paths cannot cross.
|
|
3347
|
+
if (type === 'decorate') {
|
|
3348
|
+
return decorateUIFX(container, factory, mountOptions);
|
|
3349
|
+
}
|
|
3182
3350
|
return mountUIFX(container, type, factory, mountOptions);
|
|
3183
3351
|
}
|
|
3184
3352
|
|
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @zakkster/lite-ui-fx
|
|
2
|
-
> Canvas-hijacked UI components with pluggable recipe system.
|
|
2
|
+
> Canvas-hijacked UI components with pluggable recipe system. 56 built-in recipes.
|
|
3
3
|
|
|
4
|
-
VERSION 1.
|
|
4
|
+
VERSION 1.6.0
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
npm i @zakkster/lite-ui-fx
|
|
@@ -12,9 +12,9 @@ Canvas overlay (z-index:1) renders visuals via a recipe factory function.
|
|
|
12
12
|
Recipe = { tick(), init?(), onHover?(), onClick?(), onToggle?(), onDrag?(), destroy?() }
|
|
13
13
|
|
|
14
14
|
## Import -- Controller
|
|
15
|
-
import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
15
|
+
import { mountUIFX, decorateUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
16
16
|
|
|
17
|
-
## Import -- Recipes (one ./recipes subpath,
|
|
17
|
+
## Import -- Recipes (one ./recipes subpath, 56 total, tree-shakeable)
|
|
18
18
|
import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
19
19
|
import { PendulumToggle, HeartbeatButton, AuroraSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
20
20
|
import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
|
|
@@ -24,11 +24,23 @@ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from
|
|
|
24
24
|
// RECIPES id->factory (null-proto); RECIPE_META { id, name, type, family, themeable, motionSafe }; RECIPE_NAMES frozen.
|
|
25
25
|
// mountRecipe(container, id, options?): resolves id fail-closed (did-you-mean), asserts META.type, mounts.
|
|
26
26
|
|
|
27
|
-
## Mount
|
|
27
|
+
## Mount -- two modes
|
|
28
|
+
// HIJACK (mountUIFX): creates a native element (opacity:0) inside `container` and
|
|
29
|
+
// paints a canvas over it. The native element owns events + a11y.
|
|
28
30
|
const instance = mountUIFX(container, UIType.TOGGLE, SwarmToggle, { label: 'Sound' });
|
|
29
31
|
instance.setValue(v); // SLIDER/KNOB/PROGRESS: v in 0..1 (fires onDrag once); CHECKBOX: setValue(null) = indeterminate
|
|
30
32
|
instance.setChecked(b); // TOGGLE/CHECKBOX: set checked (fires onToggle once)
|
|
31
33
|
instance.destroy(); // cleanup
|
|
34
|
+
// DECORATE (decorateUIFX): a canvas AROUND an EXISTING visible element -- no
|
|
35
|
+
// native element created, no opacity:0, host never reparented; the overlay is a
|
|
36
|
+
// sibling placed from the host's offset box, removed on destroy (host byte-
|
|
37
|
+
// identical). State is wired from the host's own events; for a form-control host
|
|
38
|
+
// state.text/state.valid mirror el.value/el.validity (read at event time). This
|
|
39
|
+
// is the home for a decoration over a real input.
|
|
40
|
+
const deco = decorateUIFX(inputEl, PasswordStrength, { theme });
|
|
41
|
+
deco.destroy(); // removes ONLY the overlay + its listeners; host untouched
|
|
42
|
+
// setValue/setChecked are HIJACK-ONLY: they throw in decorate mode (a decoration
|
|
43
|
+
// reflects the host; it does not drive it).
|
|
32
44
|
|
|
33
45
|
## Options (4th arg; unknown option or recipe-hook keys throw a did-you-mean -- fail closed)
|
|
34
46
|
width, height, padding=40, label // geometry + accessible label
|
|
@@ -42,6 +54,9 @@ text // visible canvas label; falls back to label, then the recipe default
|
|
|
42
54
|
font // canvas font string; falls back to the recipe's historical font
|
|
43
55
|
knobMode // KNOB only: 'rotate' | 'vertical' pointer mapping (default 'rotate'); wrong type throws
|
|
44
56
|
announce // PROGRESS only: opt-in aria-live announcements at 10% steps; wrong type throws
|
|
57
|
+
// decorateUIFX accepts a SUBSET: padding, seed, colors, theme, text, font. The
|
|
58
|
+
// hijack-only keys (width/height/value/checked/disabled/knobMode/announce/label)
|
|
59
|
+
// throw in decorate mode -- geometry comes from the host, value is read from it.
|
|
45
60
|
|
|
46
61
|
## Element Types
|
|
47
62
|
UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
|
|
@@ -50,11 +65,14 @@ UIType.SLIDER -> <input type="range"> -> state.val (0-1), onDrag(val, velocity
|
|
|
50
65
|
UIType.CHECKBOX -> <input type="checkbox"> (no role=switch) -> state.toggled + state.indeterminate, onToggle(checked)
|
|
51
66
|
UIType.PROGRESS -> <progress> (non-interactive) -> state.val, driven by instance.setValue
|
|
52
67
|
UIType.KNOB -> <input type="range"> -> state.val, arrows native + knobMode pointer map, onDrag(val, velocity)
|
|
68
|
+
(decorate) -> NO native element created; a canvas AROUND a live host (decorateUIFX). Not a UIType --
|
|
69
|
+
RECIPE_META.type 'decorate' routes mountRecipe to decorateUIFX. State: focused + text + valid.
|
|
53
70
|
|
|
54
71
|
## State Object (provided to tick every frame)
|
|
55
72
|
{ hover, active, focused, toggled, indeterminate, disabled, val, w, h, padding, dpr }
|
|
73
|
+
// decorate mode adds: text (host value string), valid (host validity boolean).
|
|
56
74
|
|
|
57
|
-
##
|
|
75
|
+
## 56 Built-in Recipes
|
|
58
76
|
|
|
59
77
|
### Vol. 1 -- 10 recipes
|
|
60
78
|
Toggles: SwarmToggle, LiquidToggle, NeonPulseToggle
|
|
@@ -83,6 +101,11 @@ Fun: ScratchReveal, TimerCountdown, PullRefresh
|
|
|
83
101
|
Checkboxes (UIType.CHECKBOX): TickDraw, IndeterminateScan
|
|
84
102
|
Progress (UIType.PROGRESS): LiquidFill
|
|
85
103
|
|
|
104
|
+
### U4b -- 3 recipes (decorate mode: a canvas AROUND a live element)
|
|
105
|
+
Form feedback (decorateUIFX): FocusHalo, ErrorShake, SuccessBloom
|
|
106
|
+
// Re-homed to decorate mode (type 'decorate'): PasswordStrength (reads the live
|
|
107
|
+
// input's text), TypewriterField (an underline that grows with the typed text).
|
|
108
|
+
|
|
86
109
|
## Writing Custom Recipes
|
|
87
110
|
See UIFX-RECIPE-GUIDE.md (included in package).
|
|
88
111
|
|
|
@@ -98,10 +121,15 @@ See UIFX-RECIPE-GUIDE.md (included in package).
|
|
|
98
121
|
- Zero-GC in all built-in recipes: const colors + globalAlpha, precomputed
|
|
99
122
|
color/label LUTs, fixed preallocated particle pools, gradients built in init.
|
|
100
123
|
Gated per recipe by the t3-frame-alloc torture tier (default AND themed mount).
|
|
101
|
-
- Themeable: all
|
|
124
|
+
- Themeable: all 56 recipes honour { colors, theme:{light,mid,dark}, text, font },
|
|
102
125
|
resolved once in init (zero per-frame alloc). RECIPE_META.themeable is true for
|
|
103
|
-
all
|
|
126
|
+
all 56; motionSafe stays false (reduced motion is a later pass). A bare mount is
|
|
104
127
|
byte-identical to pre-theming. Shipped palettes + APCA contrast are authored with
|
|
105
128
|
@zakkster/lite-hueforge (a dev-only tool, never a runtime dependency).
|
|
106
129
|
- U4a element types: CHECKBOX (indeterminate), PROGRESS (setValue-driven, aria-live
|
|
107
130
|
opt-in), KNOB (knobMode pointer map); setValue/setChecked sync native+state+hook once.
|
|
131
|
+
- U4b decorate mode (decorateUIFX): a canvas AROUND a live element -- no hijack, no
|
|
132
|
+
opacity:0, host never reparented; the overlay is a sibling placed from the host's
|
|
133
|
+
offset box and removed on destroy (host byte-identical, additive-only). State is
|
|
134
|
+
wired from the host's own events (state.text/state.valid at event time). It is the
|
|
135
|
+
second mount mode + the surface the enrichment decorations build on.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-ui-fx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|