@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 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
- **53 recipes** across UI element categories:
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** -- Password strength, Water level, Heat map
32
+ - **Indicators** -- Water level, Heat map
33
33
  - **Mood** -- Day/night, Reaction picker, Notification bell
34
- - **Feedback** -- Typewriter, Sound wave, Upload progress
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 53 recipes ship in the package on the `./recipes` subpath -- versioned,
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 53 recipes ship in the same package on the `./recipes` subpath and
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 53 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
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** | **53 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
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`
@@ -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
- val: number, // 0--1 slider value (sliders)
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
- ## Three Element Types
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
 
@@ -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.5.0';
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 53)
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
- export type RecipeType = 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob';
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 via mountUIFX.
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-53 namespace
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 53)
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
- const level=Math.ceil(st.val*4);
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 FONT = pickFont(o, "500 13px 'JetBrains Mono',monospace");
2568
- const text=pickText(o, 'Hello World');
2569
- let charIdx=0, timer=0, cursorBlink=0, typing=false;
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
- cursorBlink=(cursorBlink+dt*3)%2;
2575
- if(typing&&charIdx<text.length){timer+=dt;if(timer>.08){timer=0;charIdx++}}
2576
-
2577
- c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
2578
- c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
2579
-
2580
- c.fillStyle=P.accent;c.font=FONT;c.textAlign='left';c.textBaseline='middle';
2581
- // Rebuild the visible substring + its width only when a char is added.
2582
- if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
2583
- c.fillText(display,8,st.h/2);
2584
-
2585
- // Cursor
2586
- if(cursorBlink<1){
2587
- c.fillStyle=P.accent;c.fillRect(9+dispW,st.h/2-8,1.5,16);
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' | 'button' | 'slider' -- the native element it mounts on
2998
- * family display grouping (Toggles, Buttons, Sliders, Knobs, ...)
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: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
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: 'toggle', family: 'Feedback', themeable: true, motionSafe: false },
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
- const VALID_META_TYPES = new Set(Object.values(UIType));
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 via
3148
- * mountUIFX. Fail closed:
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. 53 built-in recipes.
2
+ > Canvas-hijacked UI components with pluggable recipe system. 56 built-in recipes.
3
3
 
4
- VERSION 1.5.0
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, 53 total, tree-shakeable)
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
- ## 53 Built-in Recipes
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 53 recipes honour { colors, theme:{light,mid,dark}, text, font },
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 53; motionSafe stays false (reduced motion is a later pass). A bare mount is
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.5.0",
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",