@zakkster/lite-ui-fx 1.4.0 → 1.5.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,58 @@ 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.5.0] -- 2026-09-07
9
+
10
+ New native element types (U4a, the first half of roadmap U4). Vol.3 faked
11
+ checkboxes as `role=switch` toggles and knobs/progress meters as sliders; U4a
12
+ promotes them to their true native elements (law 1). Additive: a bare mount of
13
+ any existing recipe is unchanged; 50 -> 53 recipes. Decorate mode is U4b.
14
+
15
+ ### Added
16
+
17
+ - Three `UIType`s, each wrapping the correct native element: `CHECKBOX`
18
+ (`<input type=checkbox>`, no `role=switch`; indeterminate via `setValue(null)`,
19
+ exposed as `state.indeterminate`), `PROGRESS` (native `<progress>`,
20
+ non-interactive, value written by `setValue`; opt-in `announce` adds a
21
+ visually-hidden `aria-live=polite` region updated at 10% steps), and `KNOB`
22
+ (`<input type=range>`, arrow keys native, canvas-side `knobMode`
23
+ `'rotate' | 'vertical'` pointer mapping).
24
+ - `instance.setValue(v)` / `instance.setChecked(b)`: one call syncs the native
25
+ element, `state`, any PROGRESS announcer, and fires the recipe hook
26
+ (`onDrag`/`onToggle`) exactly once (a programmatic write emits no native event).
27
+ - Options `knobMode` (KNOB-only) and `announce` (PROGRESS-only), both validated
28
+ fail-closed (presence on the wrong type throws).
29
+ - Three recipes, born themed and t3-gated: `TickDraw`, `IndeterminateScan`
30
+ (CHECKBOX, honouring `state.indeterminate`), `LiquidFill` (PROGRESS). Registered
31
+ in `RECIPES` / `RECIPE_META` / the default export / a new `UIFXRecipes4` barrel.
32
+ - `decisions/0003-element-types.md`; controller `npm test` coverage for the new
33
+ types; t2 gains the CHECKBOX-no-switch, PROGRESS-value, KNOB-arrows, and
34
+ `setValue`/`setChecked`-once contracts (A10-A13).
35
+
36
+ ### Changed
37
+
38
+ - Eight Vol.3 recipes re-homed onto their true types (rippleCheck/morphCheck ->
39
+ checkbox; volumeKnob/compassKnob -> knob; ringProgress/batteryGauge/signalMeter/
40
+ uploadProgress -> progress). Re-home is a `RECIPE_META.type` string change only
41
+ -- no recipe body touched -- so each renders byte-identical to 1.4.0 (proven by
42
+ `git diff`); new types keep the donor's default geometry (checkbox 64x36,
43
+ knob/progress 200x28).
44
+ - The mount type guard and `registerRecipe` both derive their valid-type set from
45
+ `UIType`, so the controller, the registry, and the d.ts cannot drift as types
46
+ are added; an unknown type still throws (fail closed).
47
+ - Torture: `makeChurn` drives the new types (checkbox like toggle + sweeps
48
+ indeterminate, knob like slider, progress sweeps value with no hook); t0/t5
49
+ synthetic batches iterate all six types; the t3 tier now gates 53 recipes
50
+ (default AND themed). `npm test` 164 pass; torture `gc major=0`, `alloc=0 B/op`.
51
+ - Docs (`llms.txt`, `README.md`, both `.d.ts`) updated to 53 recipes and the new
52
+ types / options / methods.
53
+
54
+ ### Fixed
55
+
56
+ - The Vol.3 semantic mis-mounts: a checkbox is no longer announced as a switch
57
+ (WCAG role match), and progress meters are non-interactive rather than
58
+ user-draggable sliders.
59
+
8
60
  ## [1.4.0] -- 2026-09-07
9
61
 
10
62
  The theming pass (U3b), completing U3's third finding (U-06). One option
package/README.md CHANGED
@@ -21,26 +21,26 @@ https://cdpn.io/pen/debug/yyaPKpB
21
21
  ## Live Demo (UI-FX vol3.)
22
22
  https://cdpn.io/pen/debug/YPGEaYY
23
23
 
24
- **50 recipes** across UI element categories:
24
+ **53 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
28
28
  - **Sliders** -- Spark, Cosmic Void, Laser, Aurora, Wave, Elastic Band, Gravity
29
29
  - **Knobs** -- Volume dial, Compass needle
30
- - **Progress** -- Ring, Battery, Signal meter
30
+ - **Progress** -- Ring, Battery, Signal meter, Liquid Fill
31
31
  - **Controls** -- Pill tabs, Stepper, Radio orbit
32
32
  - **Indicators** -- Password strength, Water level, Heat map
33
33
  - **Mood** -- Day/night, Reaction picker, Notification bell
34
34
  - **Feedback** -- Typewriter, Sound wave, Upload progress
35
35
  - **Fun** -- Scratch reveal, Timer countdown, Pull refresh
36
- - **Checkboxes** -- Ripple, Morph (X to check)
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
40
 
41
41
  Every recipe is zero-GC, uses `dt`-based animation, and includes accessibility indicators (focus rings, state labels).
42
42
 
43
- All 50 recipes ship in the package on the `./recipes` subpath -- versioned,
43
+ All 53 recipes ship in the package on the `./recipes` subpath -- versioned,
44
44
  typed, and tree-shakeable. With `sideEffects: false`, importing one recipe pulls
45
45
  in only that recipe, so a controller-only install stays tiny.
46
46
 
@@ -65,7 +65,7 @@ Part of the [@zakkster/lite-*](https://www.npmjs.com/org/zakkster) ecosystem.
65
65
  npm i @zakkster/lite-ui-fx
66
66
  ```
67
67
 
68
- > The 50 recipes ship in the same package on the `./recipes` subpath and
68
+ > The 53 recipes ship in the same package on the `./recipes` subpath and
69
69
  > tree-shake, so importing one adds only that one.
70
70
 
71
71
 
@@ -97,7 +97,7 @@ instance.destroy();
97
97
  // Controller (always needed)
98
98
  import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
99
99
 
100
- // All 50 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
100
+ // All 53 recipes ship on the ./recipes subpath (tree-shakeable) -- import by name:
101
101
  import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
102
102
  import { PendulumToggle, HeartbeatButton, RippleCheck } from '@zakkster/lite-ui-fx/recipes';
103
103
  import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
@@ -136,7 +136,7 @@ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from
136
136
  | Parameter | Type | Description |
137
137
  |-----------|------|-------------|
138
138
  | `container` | `HTMLElement` | Parent to mount into |
139
- | `type` | `'button' \| 'toggle' \| 'slider'` | Determines native element type |
139
+ | `type` | `'button' \| 'toggle' \| 'slider' \| 'checkbox' \| 'progress' \| 'knob'` | Determines native element type |
140
140
  | `recipeFactory` | `() => Recipe` | Factory function (controller calls it) |
141
141
  | `options.width` | `number` | Element width (auto from type if omitted) |
142
142
  | `options.height` | `number` | Element height |
@@ -145,8 +145,18 @@ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from
145
145
  | `options.value` | `number` | Slider initial value, 0..1 (default 0.5); out-of-range throws |
146
146
  | `options.checked` | `boolean` | Toggle initial state (default false) |
147
147
  | `options.disabled` | `boolean` | Disables the native element; sets `state.disabled` |
148
+ | `options.knobMode` | `'rotate' \| 'vertical'` | KNOB only: pointer-to-value mapping (default `'rotate'`); wrong type throws |
149
+ | `options.announce` | `boolean` | PROGRESS only: opt-in `aria-live` announcements at 10% steps; wrong type throws |
148
150
 
149
- Returns `{ el, canvas, wrapper, state, destroy() }`.
151
+ Recipe theming options (`seed`, `colors`, `theme`, `text`, `font`) are also
152
+ accepted and forwarded to the recipe -- see `llms.txt` for the full option surface.
153
+
154
+ Returns `{ el, canvas, wrapper, state, setValue(v), setChecked(b), destroy() }`.
155
+
156
+ - `setValue(v)` -- SLIDER/KNOB/PROGRESS: set `v` in `0..1` (updates the element +
157
+ `state.val`, fires `onDrag` once). CHECKBOX: `setValue(null)` sets indeterminate.
158
+ - `setChecked(b)` -- TOGGLE/CHECKBOX: set checked (updates the element +
159
+ `state.toggled`, fires `onToggle` once).
150
160
 
151
161
  ### Element Types
152
162
 
@@ -155,6 +165,9 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
155
165
  | `UIType.TOGGLE` | `<input type="checkbox" role="switch">` | `onToggle(checked)` | `state.toggled` |
156
166
  | `UIType.BUTTON` | `<button>` | `onClick(x, y, state)` | `state.active` |
157
167
  | `UIType.SLIDER` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
168
+ | `UIType.CHECKBOX` | `<input type="checkbox">` (no `role=switch`) | `onToggle(checked)` | `state.toggled`, `state.indeterminate` |
169
+ | `UIType.PROGRESS` | `<progress>` (non-interactive) | (driven by `setValue`) | `state.val` (0-1) |
170
+ | `UIType.KNOB` | `<input type="range">` | `onDrag(val, velocity)` | `state.val` (0-1) |
158
171
 
159
172
  ### State Object (provided to `tick()` every frame)
160
173
 
@@ -163,7 +176,8 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
163
176
  hover: boolean; // Pointer inside element
164
177
  active: boolean; // Pointer pressed
165
178
  focused: boolean; // Keyboard focus
166
- toggled: boolean; // Checkbox state
179
+ toggled: boolean; // Checkbox/toggle state
180
+ indeterminate: boolean; // CHECKBOX only: native indeterminate (setValue(null))
167
181
  disabled: boolean; // Disabled via options.disabled
168
182
  val: number; // Slider value (0-1)
169
183
  w: number; // Element width
@@ -180,7 +194,7 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
180
194
  | Framer Motion | ~45 KB | React HOC | 0 | Via React | `npm i framer-motion` |
181
195
  | GSAP | ~25 KB | Timeline | 0 | Manual | `npm i gsap` |
182
196
  | Lottie | ~55 KB | JSON animation | After Effects | Manual | `npm i lottie-web` |
183
- | **lite-ui-fx** | **< 5 KB** | **Canvas hijack** | **50 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
197
+ | **lite-ui-fx** | **< 5 KB** | **Canvas hijack** | **53 built-in** | **Native + visual** | **`npm i @zakkster/lite-ui-fx`** |
184
198
 
185
199
  ## Writing Custom Recipes
186
200
 
@@ -1,11 +1,17 @@
1
1
  export declare const VERSION: string;
2
2
 
3
- export type UITypeValue = 'button' | 'toggle' | 'slider';
3
+ export type UITypeValue = 'button' | 'toggle' | 'slider' | 'checkbox' | 'progress' | 'knob';
4
4
 
5
5
  export declare const UIType: Readonly<{
6
6
  BUTTON: 'button';
7
7
  TOGGLE: 'toggle';
8
8
  SLIDER: 'slider';
9
+ /** Plain checkbox (no role=switch); indeterminate via setValue(null). */
10
+ CHECKBOX: 'checkbox';
11
+ /** Native <progress>, non-interactive; value driven by setValue. */
12
+ PROGRESS: 'progress';
13
+ /** <input type=range>; arrows native, pointer mapped by knobMode. */
14
+ KNOB: 'knob';
9
15
  }>;
10
16
 
11
17
  export interface UIFXState {
@@ -13,6 +19,8 @@ export interface UIFXState {
13
19
  active: boolean;
14
20
  focused: boolean;
15
21
  toggled: boolean;
22
+ /** CHECKBOX only: the native indeterminate state (set via setValue(null)). */
23
+ indeterminate: boolean;
16
24
  disabled: boolean;
17
25
  val: number;
18
26
  w: number;
@@ -72,6 +80,10 @@ export interface MountOptions {
72
80
  text?: string;
73
81
  /** Canvas font string; falls back to the recipe's historical font. */
74
82
  font?: string;
83
+ /** KNOB only: pointer-to-value mapping (default 'rotate'). Throws on any other type. */
84
+ knobMode?: 'rotate' | 'vertical';
85
+ /** PROGRESS only: opt-in aria-live announcements at 10% steps. Throws on any other type. */
86
+ announce?: boolean;
75
87
  }
76
88
 
77
89
  export interface UIFXInstance {
@@ -80,6 +92,21 @@ export interface UIFXInstance {
80
92
  wrapper: HTMLDivElement;
81
93
  state: UIFXState;
82
94
 
95
+ /**
96
+ * Set a valued control (SLIDER/KNOB/PROGRESS) to v in [0,1]: updates the
97
+ * native element, state.val, any PROGRESS announcer, and fires onDrag once.
98
+ * For a CHECKBOX, setValue(null) sets the indeterminate state. Throws on the
99
+ * wrong element type or an out-of-range value.
100
+ */
101
+ setValue(v: number | null): void;
102
+
103
+ /**
104
+ * Set a TOGGLE/CHECKBOX checked state: updates the native element,
105
+ * state.toggled, clears indeterminate, and fires onToggle exactly once.
106
+ * Throws on any other element type.
107
+ */
108
+ setChecked(b: boolean): void;
109
+
83
110
  destroy(): void;
84
111
  }
85
112
 
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.4.0';
25
+ export const VERSION = '1.5.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -84,7 +84,8 @@ function releaseSliderStyle() {
84
84
  // ---------------------------------------------------------
85
85
 
86
86
  const KNOWN_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'destroy'];
87
- const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font'];
87
+ const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font', 'knobMode', 'announce'];
88
+ const KNOB_MODES = ['rotate', 'vertical'];
88
89
 
89
90
  // Levenshtein edit distance. Cold: only reached on the error path.
90
91
  function _editDistance(a, b) {
@@ -143,8 +144,15 @@ export const UIType = Object.freeze({
143
144
  BUTTON: 'button',
144
145
  TOGGLE: 'toggle',
145
146
  SLIDER: 'slider',
147
+ CHECKBOX: 'checkbox', // <input type=checkbox> WITHOUT role=switch (a check is not a switch)
148
+ PROGRESS: 'progress', // native <progress>, non-interactive, value driven programmatically
149
+ KNOB: 'knob', // <input type=range>, arrows native, canvas-side knobMode pointer map
146
150
  });
147
151
 
152
+ // The valid mount types, derived once from UIType so the controller guard, the
153
+ // recipe registry, and the d.ts never drift apart. Cold: read only at mount.
154
+ const _KNOWN_TYPES = new Set(Object.values(UIType));
155
+
148
156
 
149
157
  // =========================================================
150
158
  // UIFXController -- The Canvas Hijacker
@@ -177,11 +185,11 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
177
185
  throw new Error('mountUIFX: container must be a DOM element');
178
186
  }
179
187
 
180
- // 1b. type: exactly one of the three known element types. An unknown or
188
+ // 1b. type: exactly one of the known element types (UIType). An unknown or
181
189
  // undefined type is an Error here, never a silent default to a button
182
190
  // (fail closed -- the type selects the native element).
183
- if (type !== UIType.BUTTON && type !== UIType.TOGGLE && type !== UIType.SLIDER) {
184
- throw new Error('mountUIFX: type must be UIType.BUTTON, UIType.TOGGLE, or UIType.SLIDER');
191
+ if (!_KNOWN_TYPES.has(type)) {
192
+ throw new Error('mountUIFX: type must be one of UIType.BUTTON, TOGGLE, SLIDER, CHECKBOX, PROGRESS, KNOB');
185
193
  }
186
194
 
187
195
  // 2. options: unknown keys -> did-you-mean; value/checked/disabled
@@ -205,6 +213,8 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
205
213
  const height = options.height;
206
214
  const padding = options.padding === undefined ? 40 : options.padding;
207
215
  const label = options.label === undefined ? '' : options.label;
216
+ const knobMode = options.knobMode || 'rotate'; // KNOB pointer map; validated below
217
+ const announce = options.announce === true; // PROGRESS aria-live; validated below
208
218
 
209
219
  // Reserved theming options (decisions/0002): all optional, validated fail
210
220
  // closed here, then forwarded to the recipe factory which resolves them in
@@ -233,6 +243,26 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
233
243
  throw new Error('mountUIFX: option "seed" must be a finite number');
234
244
  }
235
245
 
246
+ // Type-scoped options (U4a). knobMode belongs only to a KNOB; announce only
247
+ // to a PROGRESS. Presence on the wrong type is a mistake, not a silent
248
+ // ignore (fail closed). Both validated here, before any element exists.
249
+ if (options.knobMode !== undefined) {
250
+ if (type !== UIType.KNOB) {
251
+ throw new Error('mountUIFX: option "knobMode" is only valid for UIType.KNOB');
252
+ }
253
+ if (KNOB_MODES.indexOf(options.knobMode) === -1) {
254
+ throw new Error('mountUIFX: option "knobMode" must be "rotate" or "vertical"');
255
+ }
256
+ }
257
+ if (options.announce !== undefined) {
258
+ if (type !== UIType.PROGRESS) {
259
+ throw new Error('mountUIFX: option "announce" is only valid for UIType.PROGRESS');
260
+ }
261
+ if (typeof options.announce !== 'boolean') {
262
+ throw new Error('mountUIFX: option "announce" must be a boolean');
263
+ }
264
+ }
265
+
236
266
  // 3. recipeFactory
237
267
  if (typeof recipeFactory !== 'function') {
238
268
  throw new Error('mountUIFX: recipeFactory must be a function');
@@ -279,24 +309,37 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
279
309
 
280
310
  try {
281
311
  // -- Resolve dimensions --
282
- const w = width || (type === UIType.BUTTON ? 160 : type === UIType.SLIDER ? 200 : 64);
283
- const h = height || (type === UIType.BUTTON ? 48 : type === UIType.SLIDER ? 28 : 36);
312
+ // SLIDER/PROGRESS/KNOB share the 200x28 range geometry (so every vol.3
313
+ // re-home renders byte-identical to its slider era); CHECKBOX shares the
314
+ // 64x36 toggle geometry; BUTTON keeps 160x48.
315
+ const _rangeLike = type === UIType.SLIDER || type === UIType.PROGRESS || type === UIType.KNOB;
316
+ const w = width || (type === UIType.BUTTON ? 160 : _rangeLike ? 200 : 64);
317
+ const h = height || (type === UIType.BUTTON ? 48 : _rangeLike ? 28 : 36);
284
318
  let dpr = window.devicePixelRatio || 1;
285
319
 
286
320
  // -- Create native element (invisible, accessible, receives events) --
287
321
  let el;
288
- if (type === UIType.TOGGLE) {
322
+ if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
289
323
  el = document.createElement('input');
290
324
  el.type = 'checkbox';
291
- el.setAttribute('role', 'switch');
325
+ // TOGGLE is a switch; CHECKBOX is a plain checkbox. A check is not a
326
+ // switch -- U4a drops the role for CHECKBOX (the vol.3 mis-mount fix).
327
+ if (type === UIType.TOGGLE) el.setAttribute('role', 'switch');
292
328
  el.checked = checked; // coerced boolean; lands before frame 1
293
329
  if (label) el.setAttribute('aria-label', label);
294
- } else if (type === UIType.SLIDER) {
330
+ } else if (type === UIType.SLIDER || type === UIType.KNOB) {
295
331
  el = document.createElement('input');
296
332
  el.type = 'range';
297
333
  el.min = '0'; el.max = '100';
298
334
  el.value = value !== undefined ? String(value * 100) : '50'; // 0..1 -> 0..100
299
335
  if (label) el.setAttribute('aria-label', label);
336
+ } else if (type === UIType.PROGRESS) {
337
+ // Non-interactive: value is written programmatically (setValue) only,
338
+ // and exposed to assistive tech by the native <progress> element.
339
+ el = document.createElement('progress');
340
+ el.max = 1;
341
+ el.value = value !== undefined ? value : 0; // 0..1
342
+ if (label) el.setAttribute('aria-label', label);
300
343
  } else {
301
344
  el = document.createElement('button');
302
345
  el.textContent = label || 'Action';
@@ -316,7 +359,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
316
359
  // Slider thumb needs explicit sizing for hit area. One shared, ref-counted
317
360
  // <style> for all sliders (U-09): released in destroy() when the last slider
318
361
  // goes -- head child count nets to zero across mount/destroy.
319
- if (type === UIType.SLIDER) {
362
+ if (type === UIType.SLIDER || type === UIType.KNOB) {
320
363
  acquireSliderStyle();
321
364
  styleAcquired = true;
322
365
  el.classList.add('uifx-slider');
@@ -349,14 +392,32 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
349
392
  container.appendChild(wrapper);
350
393
  wrapperAppended = true;
351
394
 
395
+ // -- Optional aria-live announcer for PROGRESS (U4a). Opt-in via
396
+ // { announce: true }: a visually-hidden polite region that setValue
397
+ // updates at 10% steps. It lives in the wrapper, so wrapper.remove() in
398
+ // destroy() takes it with everything else -- no separate teardown. --
399
+ let announceRegion = null;
400
+ if (type === UIType.PROGRESS && announce) {
401
+ announceRegion = document.createElement('span');
402
+ announceRegion.setAttribute('aria-live', 'polite');
403
+ Object.assign(announceRegion.style, {
404
+ position: 'absolute', width: '1px', height: '1px',
405
+ overflow: 'hidden', clipPath: 'inset(50%)',
406
+ whiteSpace: 'nowrap', border: '0', padding: '0', margin: '-1px',
407
+ });
408
+ wrapper.appendChild(announceRegion);
409
+ }
410
+ let _lastAnnouncePct = -1; // last announced 10% step (cold: only setValue writes)
411
+
352
412
  // -- State (value/checked/disabled land here BEFORE frame 1) --
353
413
  const state = {
354
414
  hover: false,
355
415
  active: false, // pointer is down
356
416
  focused: false, // keyboard focus
357
417
  toggled: checked, // coerced boolean; element + state AGREE
418
+ indeterminate: false, // CHECKBOX only; set via setValue(null)
358
419
  disabled, // recipes can render a disabled look
359
- val: value !== undefined ? value : (type === UIType.SLIDER ? 0.5 : 0), // 0-1
420
+ val: value !== undefined ? value : (type === UIType.SLIDER || type === UIType.KNOB ? 0.5 : 0), // 0-1
360
421
  w, h, padding, dpr,
361
422
  };
362
423
 
@@ -366,6 +427,28 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
366
427
  // pointer event, then lazily filled once (see updatePointer).
367
428
  let rect = null;
368
429
 
430
+ // Apply a numeric value to a valued control (SLIDER/KNOB/PROGRESS): reflect
431
+ // it to the native element, update state, announce (PROGRESS), and fire
432
+ // onDrag exactly once when asked. A programmatic el.value write fires NO
433
+ // native 'input', so this explicit hook call is the only one -- no double
434
+ // fire. Cold path (setValue + knob drag), never a per-frame body.
435
+ function _applyVal(v, fireHook) {
436
+ state.val = v;
437
+ if (type === UIType.PROGRESS) {
438
+ el.value = v; // 0..1, native max=1
439
+ if (announceRegion) {
440
+ const pct = Math.round(v * 10) * 10;
441
+ if (pct !== _lastAnnouncePct) {
442
+ _lastAnnouncePct = pct;
443
+ announceRegion.textContent = pct + '%';
444
+ }
445
+ }
446
+ } else {
447
+ el.value = String(v * 100); // range 0..100
448
+ }
449
+ if (fireHook && recipe.onDrag) recipe.onDrag(v, pointer.vx, state);
450
+ }
451
+
369
452
  // -- Initialize recipe (already validated in phase 1: object, tick fn,
370
453
  // only known hooks). ctx exists now, so init can run. --
371
454
  if (recipe.init) recipe.init(ctx, w, h, padding);
@@ -417,9 +500,12 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
417
500
  el.addEventListener('focus', () => { state.focused = true; }, { signal });
418
501
  el.addEventListener('blur', () => { state.focused = false; }, { signal });
419
502
 
420
- // Toggle events
421
- if (type === UIType.TOGGLE) {
503
+ // Toggle + checkbox events (both are a native <input type=checkbox>)
504
+ if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
422
505
  el.addEventListener('change', () => {
506
+ // A user interaction resolves any indeterminate state (native does
507
+ // this too); keep state.indeterminate in agreement.
508
+ state.indeterminate = false;
423
509
  state.toggled = el.checked;
424
510
  if (recipe.onToggle) recipe.onToggle(state.toggled, state);
425
511
  }, { signal });
@@ -431,14 +517,51 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
431
517
  }, { signal });
432
518
  }
433
519
 
434
- // Slider events
435
- if (type === UIType.SLIDER) {
520
+ // Slider + knob value events. A range input fires 'input' on native drag
521
+ // (slider) AND on arrow keys (both) -- one path for keyboard on either type.
522
+ if (type === UIType.SLIDER || type === UIType.KNOB) {
436
523
  el.addEventListener('input', () => {
437
524
  state.val = el.value / 100;
438
525
  if (recipe.onDrag) recipe.onDrag(state.val, pointer.vx, state);
439
526
  }, { signal });
440
527
  }
441
528
 
529
+ // KNOB pointer remap (U4a). A range input maps value to horizontal thumb
530
+ // position; a knob maps a rotational or vertical drag instead. Arrow keys
531
+ // stay native (the 'input' handler above); for pointer we drive the value
532
+ // ourselves and preventDefault the native jump-to-pointer, restoring focus
533
+ // by hand. All cold: pointer handlers, no per-frame work.
534
+ if (type === UIType.KNOB) {
535
+ let knobActive = false;
536
+ let knobStartVal = 0;
537
+ let knobStartY = 0;
538
+ el.addEventListener('pointerdown', (e) => {
539
+ knobActive = true;
540
+ knobStartVal = state.val;
541
+ knobStartY = e.clientY;
542
+ refreshRect();
543
+ el.focus();
544
+ e.preventDefault(); // suppress the range's native jump-to-pointer
545
+ if (el.setPointerCapture) el.setPointerCapture(e.pointerId);
546
+ }, { signal });
547
+ el.addEventListener('pointermove', (e) => {
548
+ if (!knobActive) return;
549
+ let v;
550
+ if (knobMode === 'vertical') {
551
+ v = knobStartVal + (knobStartY - e.clientY) / 150; // 150px = full sweep
552
+ } else {
553
+ const cx = rect ? rect.left + rect.width / 2 : e.clientX;
554
+ const cy = rect ? rect.top + rect.height / 2 : e.clientY;
555
+ let a = Math.atan2(e.clientY - cy, e.clientX - cx); // -PI..PI
556
+ a = (a + Math.PI * 2.5) % (Math.PI * 2); // 0 at bottom, clockwise
557
+ v = a / (Math.PI * 2);
558
+ }
559
+ if (v < 0) v = 0; else if (v > 1) v = 1;
560
+ _applyVal(v, true); // reflect + fire onDrag once
561
+ }, { signal });
562
+ el.addEventListener('pointerup', () => { knobActive = false; }, { signal });
563
+ }
564
+
442
565
  // -- DPR re-read on display change (cold, feature-detected). Absent
443
566
  // matchMedia is a silent no-op: the canvas stays at mount DPR (fail
444
567
  // closed, never throw). Listener bound to signal for teardown. --
@@ -497,6 +620,50 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
497
620
  /** Current state (read-only reference). */
498
621
  state,
499
622
 
623
+ /**
624
+ * Programmatically set a valued control (SLIDER/KNOB/PROGRESS) to v in
625
+ * [0,1]: updates the native element, state.val, any PROGRESS announcer,
626
+ * and fires onDrag exactly once (a programmatic write emits no native
627
+ * event, so there is no second fire). For a CHECKBOX, setValue(null)
628
+ * sets the indeterminate state. Fail closed on wrong type / bad value.
629
+ */
630
+ setValue(v) {
631
+ if (destroyed) return;
632
+ if (type === UIType.CHECKBOX) {
633
+ if (v === null) {
634
+ el.indeterminate = true;
635
+ state.indeterminate = true;
636
+ return;
637
+ }
638
+ throw new Error('setValue: a checkbox takes setChecked(bool), or setValue(null) for indeterminate');
639
+ }
640
+ if (type !== UIType.SLIDER && type !== UIType.KNOB && type !== UIType.PROGRESS) {
641
+ throw new Error('setValue: only SLIDER, KNOB, and PROGRESS carry a numeric value');
642
+ }
643
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) {
644
+ throw new Error('setValue: v must be a number in [0,1]');
645
+ }
646
+ _applyVal(v, true);
647
+ },
648
+
649
+ /**
650
+ * Programmatically set a TOGGLE/CHECKBOX checked state: updates the
651
+ * native element, state.toggled, clears indeterminate, and fires
652
+ * onToggle exactly once. Fail closed on the wrong type.
653
+ */
654
+ setChecked(b) {
655
+ if (destroyed) return;
656
+ if (type !== UIType.TOGGLE && type !== UIType.CHECKBOX) {
657
+ throw new Error('setChecked: only TOGGLE and CHECKBOX carry a checked state');
658
+ }
659
+ const nb = !!b;
660
+ el.indeterminate = false;
661
+ el.checked = nb;
662
+ state.indeterminate = false;
663
+ state.toggled = nb;
664
+ if (recipe.onToggle) recipe.onToggle(nb, state);
665
+ },
666
+
500
667
  /** Destroy everything. Idempotent. */
501
668
  destroy() {
502
669
  if (destroyed) return;
@@ -505,7 +672,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
505
672
  removeTick();
506
673
  if (recipe.destroy) recipe.destroy();
507
674
  releaseTicker();
508
- if (type === UIType.SLIDER) releaseSliderStyle();
675
+ if (type === UIType.SLIDER || type === UIType.KNOB) releaseSliderStyle();
509
676
  wrapper.remove();
510
677
  },
511
678
  };
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 50)
4
+ // RECIPE OPTIONS + FACTORIES (all 53)
5
5
  // ===========================================================
6
6
 
7
7
  /**
@@ -72,6 +72,9 @@ export declare function HelixLoader(options?: RecipeOptions): UIFXRecipe;
72
72
  // -- Vol.2: Checkboxes --
73
73
  export declare function RippleCheck(options?: RecipeOptions): UIFXRecipe;
74
74
  export declare function MorphCheck(options?: RecipeOptions): UIFXRecipe;
75
+ // -- U4a: CHECKBOX recipes (honour st.indeterminate) --
76
+ export declare function TickDraw(options?: RecipeOptions): UIFXRecipe;
77
+ export declare function IndeterminateScan(options?: RecipeOptions): UIFXRecipe;
75
78
 
76
79
  // -- Vol.2: Counters --
77
80
  export declare function FlameCounter(options?: RecipeOptions): UIFXRecipe;
@@ -88,6 +91,8 @@ export declare function CompassKnob(options?: RecipeOptions): UIFXRecipe;
88
91
  export declare function RingProgress(options?: RecipeOptions): UIFXRecipe;
89
92
  export declare function BatteryGauge(options?: RecipeOptions): UIFXRecipe;
90
93
  export declare function SignalMeter(options?: RecipeOptions): UIFXRecipe;
94
+ // -- U4a: PROGRESS recipe --
95
+ export declare function LiquidFill(options?: RecipeOptions): UIFXRecipe;
91
96
 
92
97
  // -- Vol.3: Controls --
93
98
  export declare function PillTabs(options?: RecipeOptions): UIFXRecipe;
@@ -177,11 +182,18 @@ export declare const UIFXRecipes3: {
177
182
  PullRefresh: typeof PullRefresh;
178
183
  };
179
184
 
185
+ /** U4a additions -- new native element types (kept out of the Vol.1-3 snapshots). */
186
+ export declare const UIFXRecipes4: {
187
+ TickDraw: typeof TickDraw;
188
+ IndeterminateScan: typeof IndeterminateScan;
189
+ LiquidFill: typeof LiquidFill;
190
+ };
191
+
180
192
  // ===========================================================
181
193
  // RECIPE REGISTRY
182
194
  // ===========================================================
183
195
 
184
- export type RecipeType = 'toggle' | 'button' | 'slider';
196
+ export type RecipeType = 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob';
185
197
 
186
198
  export type RecipeFactory = (options?: Record<string, unknown>) => UIFXRecipe;
187
199
 
@@ -221,7 +233,7 @@ export declare function mountRecipe(
221
233
  ): UIFXInstance;
222
234
 
223
235
  // ===========================================================
224
- // DEFAULT EXPORT -- combined all-50 namespace
236
+ // DEFAULT EXPORT -- combined all-53 namespace
225
237
  // ===========================================================
226
238
 
227
239
  declare const UIFXAllRecipes: {
@@ -252,6 +264,8 @@ declare const UIFXAllRecipes: {
252
264
  HelixLoader: typeof HelixLoader;
253
265
  RippleCheck: typeof RippleCheck;
254
266
  MorphCheck: typeof MorphCheck;
267
+ TickDraw: typeof TickDraw;
268
+ IndeterminateScan: typeof IndeterminateScan;
255
269
  FlameCounter: typeof FlameCounter;
256
270
  GlitchCounter: typeof GlitchCounter;
257
271
  BubbleRating: typeof BubbleRating;
@@ -260,6 +274,7 @@ declare const UIFXAllRecipes: {
260
274
  RingProgress: typeof RingProgress;
261
275
  BatteryGauge: typeof BatteryGauge;
262
276
  SignalMeter: typeof SignalMeter;
277
+ LiquidFill: typeof LiquidFill;
263
278
  PillTabs: typeof PillTabs;
264
279
  Stepper: typeof Stepper;
265
280
  RadioOrbit: typeof RadioOrbit;
package/UIFXRecipes.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @zakkster/lite-ui-fx -- Recipe Collection (all 50)
2
+ * @zakkster/lite-ui-fx -- Recipe Collection (all 53)
3
3
  *
4
4
  * The three recipe volumes consolidated into one shipped, typed, versioned
5
- * module, exposed as the ./recipes subpath export. See
6
- * decisions/0001-recipes-position.md.
5
+ * module, exposed as the ./recipes subpath export, plus the U4a additions for
6
+ * the new native element types. See decisions/0001-recipes-position.md.
7
7
  *
8
8
  * Vol.1 (10): SwarmToggle, LiquidToggle, NeonPulseToggle, MagneticButton,
9
9
  * ShatterButton, ConfettiButton, GlitchButton, SparkSlider,
@@ -18,6 +18,7 @@
18
18
  * HeatMap, DayNightToggle, ReactionPicker, NotificationBell,
19
19
  * TypewriterField, SoundWaveBtn, UploadProgress, ScratchReveal,
20
20
  * TimerCountdown, PullRefresh
21
+ * U4a (3): TickDraw, IndeterminateScan (CHECKBOX), LiquidFill (PROGRESS)
21
22
  *
22
23
  * Registry: RECIPES (null-prototype), RECIPE_META (live), RECIPE_NAMES,
23
24
  * registerRecipe(id, factory, meta?), mountRecipe(container, id, options?).
@@ -1588,6 +1589,84 @@ export function MorphCheck(o = {}) {
1588
1589
  };
1589
1590
  }
1590
1591
 
1592
+ /** Tick Draw (U4a CHECKBOX) -- the checkmark strokes itself on; a centred dash
1593
+ * when indeterminate; empty when off. Reads st.toggled + st.indeterminate. */
1594
+ export function TickDraw(o = {}) {
1595
+ const P = resolveTheme(o, { accent: '#34d399', surface: '#0a0a12', dim2: '#8888aa' });
1596
+ const boxOff = 'rgba(255,255,255,.06)';
1597
+ const FONT = pickFont(o, "500 9px 'JetBrains Mono',monospace");
1598
+ let drawT = 0; // 0..1 stroke progress of the check
1599
+ return {
1600
+ tick(ctx, dt, now, st) {
1601
+ // Indeterminate suppresses the check; a full check draws only when on.
1602
+ drawT = lerp(drawT, st.indeterminate ? 0 : (st.toggled ? 1 : 0), dt * 12);
1603
+ const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2, r = 6;
1604
+
1605
+ ctx.fillStyle = (st.toggled || st.indeterminate) ? P.accent : boxOff;
1606
+ roundRect(ctx, 0, 0, sz, sz, r); ctx.fill();
1607
+
1608
+ ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
1609
+ if (st.indeterminate) {
1610
+ // Standard indeterminate glyph: one centred dash.
1611
+ ctx.beginPath(); ctx.moveTo(sz * 0.28, cy); ctx.lineTo(sz * 0.72, cy); ctx.stroke();
1612
+ } else if (drawT > 0.01) {
1613
+ const p1 = clamp(drawT * 2, 0, 1), p2 = clamp(drawT * 2 - 1, 0, 1);
1614
+ ctx.beginPath();
1615
+ ctx.moveTo(cx - 7, cy);
1616
+ ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
1617
+ if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
1618
+ ctx.stroke();
1619
+ }
1620
+ ctx.lineCap = 'butt';
1621
+
1622
+ label(ctx, st.indeterminate ? '\u2212' : (st.toggled ? '\u2713' : '\u25cb'),
1623
+ cx, sz + 12, (st.toggled || st.indeterminate) ? P.accent : P.dim2, FONT);
1624
+ if (st.focused) focusRing(ctx, sz, sz, r);
1625
+ },
1626
+ };
1627
+ }
1628
+
1629
+ /** Indeterminate Scan (U4a CHECKBOX) -- sweeps a scan line while indeterminate,
1630
+ * settles to a stroked check when toggled, empty when off. */
1631
+ export function IndeterminateScan(o = {}) {
1632
+ const P = resolveTheme(o, { accent: '#60a5fa', surface: '#0a0a12', dim2: '#8888aa' });
1633
+ const FONT = pickFont(o, "500 9px 'JetBrains Mono',monospace");
1634
+ let scan = 0; // 0..1 sweep phase
1635
+ let checkT = 0; // 0..1 check reveal
1636
+ return {
1637
+ tick(ctx, dt, now, st) {
1638
+ checkT = lerp(checkT, (!st.indeterminate && st.toggled) ? 1 : 0, dt * 12);
1639
+ const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2, r = 6;
1640
+
1641
+ ctx.fillStyle = (st.toggled && !st.indeterminate) ? P.accent : 'rgba(255,255,255,.06)';
1642
+ roundRect(ctx, 0, 0, sz, sz, r); ctx.fill();
1643
+
1644
+ if (st.indeterminate) {
1645
+ // Scan line sweeping down, fading at the sweep ends (globalAlpha,
1646
+ // not a per-frame colour string).
1647
+ scan += dt * 1.6; if (scan > 1) scan -= 1;
1648
+ const y = 4 + scan * (sz - 8);
1649
+ ctx.strokeStyle = P.accent; ctx.lineWidth = 2; ctx.lineCap = 'round';
1650
+ ctx.globalAlpha = Math.sin(scan * Math.PI);
1651
+ ctx.beginPath(); ctx.moveTo(4, y); ctx.lineTo(sz - 4, y); ctx.stroke();
1652
+ ctx.globalAlpha = 1; ctx.lineCap = 'butt';
1653
+ } else if (checkT > 0.01) {
1654
+ const p1 = clamp(checkT * 2, 0, 1), p2 = clamp(checkT * 2 - 1, 0, 1);
1655
+ ctx.strokeStyle = P.surface; ctx.lineWidth = 3; ctx.lineCap = 'round';
1656
+ ctx.beginPath();
1657
+ ctx.moveTo(cx - 7, cy);
1658
+ ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
1659
+ if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
1660
+ ctx.stroke(); ctx.lineCap = 'butt';
1661
+ }
1662
+
1663
+ label(ctx, st.indeterminate ? '\u2212' : (st.toggled ? '\u2713' : '\u25cb'),
1664
+ cx, sz + 12, (st.toggled || st.indeterminate) ? P.accent : P.dim2, FONT);
1665
+ if (st.focused) focusRing(ctx, sz, sz, r);
1666
+ },
1667
+ };
1668
+ }
1669
+
1591
1670
 
1592
1671
  // ===========================================================
1593
1672
  // COUNTERS (use slider val as input)
@@ -2037,6 +2116,55 @@ export function SignalMeter(o = {}) {
2037
2116
  // CONTROLS
2038
2117
  // ===========================================================
2039
2118
 
2119
+ /** Liquid Fill (U4a PROGRESS) -- a container that fills with a waving liquid to
2120
+ * the progress value, with a percentage readout. Non-interactive. */
2121
+ export function LiquidFill(o = {}) {
2122
+ const P = resolveTheme(o, { accent: '#22d3ee', accent2: '#0ea5e9', dim: '#e2e2f0' });
2123
+ const FONT = pickFont(o, "700 14px 'JetBrains Mono',monospace");
2124
+ let displayVal = 0, phase = 0, liquid = null;
2125
+ return {
2126
+ init(c, w, h) {
2127
+ // Vertical liquid gradient, built ONCE (cold): crest -> base.
2128
+ const g = c.createLinearGradient(0, 0, 0, h);
2129
+ g.addColorStop(0, P.accent);
2130
+ g.addColorStop(1, P.accent2);
2131
+ liquid = g;
2132
+ },
2133
+ tick(c, dt, now, st) {
2134
+ displayVal = lerp(displayVal, st.val, dt * 5);
2135
+ phase += dt * 3;
2136
+ const w = st.w, h = st.h, r = Math.min(12, h / 2);
2137
+
2138
+ // Container outline
2139
+ c.strokeStyle = 'rgba(255,255,255,.14)'; c.lineWidth = 2;
2140
+ roundRect(c, 1, 1, w - 2, h - 2, r); c.stroke();
2141
+
2142
+ // Liquid body, clipped to the container, with a sine surface. Const
2143
+ // gradient fill + fixed-step sampling -> zero per-frame allocation.
2144
+ c.save();
2145
+ roundRect(c, 1, 1, w - 2, h - 2, r); c.clip();
2146
+ const surface = h - displayVal * h;
2147
+ const amp = displayVal > 0.01 && displayVal < 0.99 ? 3 : 0;
2148
+ c.fillStyle = liquid || P.accent;
2149
+ c.beginPath();
2150
+ c.moveTo(0, h);
2151
+ c.lineTo(0, surface);
2152
+ for (let x = 0; x <= w; x += 6) {
2153
+ c.lineTo(x, surface + Math.sin(x * 0.15 + phase) * amp);
2154
+ }
2155
+ c.lineTo(w, h);
2156
+ c.closePath();
2157
+ c.fill();
2158
+ c.restore();
2159
+
2160
+ // Percentage readout (PCT LUT -- no per-frame string build)
2161
+ c.fillStyle = P.dim; c.font = FONT; c.textAlign = 'center'; c.textBaseline = 'middle';
2162
+ c.fillText(PCT[Math.round(displayVal * 100)], w / 2, h / 2);
2163
+ if (st.focused) fr(c, w, h, r);
2164
+ },
2165
+ };
2166
+ }
2167
+
2040
2168
  /** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
2041
2169
  export function PillTabs(o = {}) {
2042
2170
  const P = resolveTheme(o, { accent: '#c4b5fd', accent2: '#a78bfa', dim: '#9999b8' });
@@ -2726,9 +2854,17 @@ export const UIFXRecipes3 = {
2726
2854
  ScratchReveal, TimerCountdown, PullRefresh,
2727
2855
  };
2728
2856
 
2857
+ // U4a additions -- new native element types (CHECKBOX, PROGRESS). Kept out of the
2858
+ // Vol.1-3 historical snapshots above so those stay accurate; all recipes remain
2859
+ // reachable via RECIPES / RECIPE_META and their named exports regardless.
2860
+ export const UIFXRecipes4 = {
2861
+ TickDraw, IndeterminateScan,
2862
+ LiquidFill,
2863
+ };
2864
+
2729
2865
 
2730
2866
  // ===========================================================
2731
- // DEFAULT EXPORT -- combined all-50 namespace
2867
+ // DEFAULT EXPORT -- combined all-53 namespace
2732
2868
  // ===========================================================
2733
2869
 
2734
2870
  export default {
@@ -2759,6 +2895,8 @@ export default {
2759
2895
  HelixLoader,
2760
2896
  RippleCheck,
2761
2897
  MorphCheck,
2898
+ TickDraw,
2899
+ IndeterminateScan,
2762
2900
  FlameCounter,
2763
2901
  GlitchCounter,
2764
2902
  BubbleRating,
@@ -2767,6 +2905,7 @@ export default {
2767
2905
  RingProgress,
2768
2906
  BatteryGauge,
2769
2907
  SignalMeter,
2908
+ LiquidFill,
2770
2909
  PillTabs,
2771
2910
  Stepper,
2772
2911
  RadioOrbit,
@@ -2822,6 +2961,8 @@ export const RECIPES = Object.assign(Object.create(null), {
2822
2961
  helixLoader: HelixLoader,
2823
2962
  rippleCheck: RippleCheck,
2824
2963
  morphCheck: MorphCheck,
2964
+ tickDraw: TickDraw,
2965
+ indeterminateScan: IndeterminateScan,
2825
2966
  flameCounter: FlameCounter,
2826
2967
  glitchCounter: GlitchCounter,
2827
2968
  bubbleRating: BubbleRating,
@@ -2830,6 +2971,7 @@ export const RECIPES = Object.assign(Object.create(null), {
2830
2971
  ringProgress: RingProgress,
2831
2972
  batteryGauge: BatteryGauge,
2832
2973
  signalMeter: SignalMeter,
2974
+ liquidFill: LiquidFill,
2833
2975
  pillTabs: PillTabs,
2834
2976
  stepper: Stepper,
2835
2977
  radioOrbit: RadioOrbit,
@@ -2883,16 +3025,19 @@ export const RECIPE_META = [
2883
3025
  { id: 'gravitySlider', name: 'Gravity Slider', type: 'slider', family: 'Sliders', themeable: true, motionSafe: false },
2884
3026
  { id: 'orbitLoader', name: 'Orbit Loader', type: 'toggle', family: 'Loaders', themeable: true, motionSafe: false },
2885
3027
  { id: 'helixLoader', name: 'Helix Loader', type: 'toggle', family: 'Loaders', themeable: true, motionSafe: false },
2886
- { id: 'rippleCheck', name: 'Ripple Check', type: 'toggle', family: 'Checkboxes', themeable: true, motionSafe: false },
2887
- { id: 'morphCheck', name: 'Morph Check', type: 'toggle', family: 'Checkboxes', themeable: true, motionSafe: false },
3028
+ { id: 'rippleCheck', name: 'Ripple Check', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3029
+ { id: 'morphCheck', name: 'Morph Check', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3030
+ { id: 'tickDraw', name: 'Tick Draw', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3031
+ { id: 'indeterminateScan', name: 'Indeterminate Scan', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
2888
3032
  { id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
2889
3033
  { id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
2890
3034
  { id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: true, motionSafe: false },
2891
- { id: 'volumeKnob', name: 'Volume Knob', type: 'slider', family: 'Knobs', themeable: true, motionSafe: false },
2892
- { id: 'compassKnob', name: 'Compass Knob', type: 'slider', family: 'Knobs', themeable: true, motionSafe: false },
2893
- { id: 'ringProgress', name: 'Ring Progress', type: 'slider', family: 'Progress', themeable: true, motionSafe: false },
2894
- { id: 'batteryGauge', name: 'Battery Gauge', type: 'slider', family: 'Progress', themeable: true, motionSafe: false },
2895
- { id: 'signalMeter', name: 'Signal Meter', type: 'slider', family: 'Progress', themeable: true, motionSafe: false },
3035
+ { id: 'volumeKnob', name: 'Volume Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3036
+ { id: 'compassKnob', name: 'Compass Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3037
+ { id: 'ringProgress', name: 'Ring Progress', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3038
+ { id: 'batteryGauge', name: 'Battery Gauge', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3039
+ { id: 'signalMeter', name: 'Signal Meter', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3040
+ { id: 'liquidFill', name: 'Liquid Fill', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
2896
3041
  { id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
2897
3042
  { id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
2898
3043
  { id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
@@ -2904,7 +3049,7 @@ export const RECIPE_META = [
2904
3049
  { id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
2905
3050
  { id: 'typewriterField', name: 'Typewriter Field', type: 'toggle', family: 'Feedback', themeable: true, motionSafe: false },
2906
3051
  { id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: true, motionSafe: false },
2907
- { id: 'uploadProgress', name: 'Upload Progress', type: 'slider', family: 'Feedback', themeable: true, motionSafe: false },
3052
+ { id: 'uploadProgress', name: 'Upload Progress', type: 'progress', family: 'Feedback', themeable: true, motionSafe: false },
2908
3053
  { id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
2909
3054
  { id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: true, motionSafe: false },
2910
3055
  { id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
@@ -2913,6 +3058,11 @@ export const RECIPE_META = [
2913
3058
  /** Names of every built-in recipe (the keys of RECIPES at load time). */
2914
3059
  export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
2915
3060
 
3061
+ // The valid recipe/mount types, taken from the controller's UIType so the
3062
+ // registry's fail-closed check and the controller's mount guard are one source
3063
+ // of truth (they cannot drift as U4 adds types). Built once at load (cold).
3064
+ const VALID_META_TYPES = new Set(Object.values(UIType));
3065
+
2916
3066
  /**
2917
3067
  * Register a custom recipe, or override a built-in. Instantly usable via
2918
3068
  * RECIPES[id] and reflected in RECIPE_META so existing pickers keep working.
@@ -2937,9 +3087,11 @@ export function registerRecipe(id, factory, meta) {
2937
3087
  const type = (meta && meta.type) || (prev && prev.type) || undefined;
2938
3088
  // A recipe's type selects its native element; a typeless recipe cannot be
2939
3089
  // mounted. Reject it at registration (fail closed) -- checked BEFORE any
2940
- // mutation, so a rejected call leaves RECIPES/RECIPE_META untouched.
2941
- if (type !== 'toggle' && type !== 'button' && type !== 'slider') {
2942
- throw new TypeError('registerRecipe: type must be "toggle", "button", or "slider"');
3090
+ // mutation, so a rejected call leaves RECIPES/RECIPE_META untouched. The
3091
+ // valid set is UIType (VALID_META_TYPES), so registry + controller never
3092
+ // disagree about what a type is.
3093
+ if (!VALID_META_TYPES.has(type)) {
3094
+ throw new TypeError('registerRecipe: type must be one of "button", "toggle", "slider", "checkbox", "progress", "knob"');
2943
3095
  }
2944
3096
 
2945
3097
  RECIPES[id] = factory;
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zakkster/lite-ui-fx
2
- > Canvas-hijacked UI components with pluggable recipe system. 50 built-in recipes.
2
+ > Canvas-hijacked UI components with pluggable recipe system. 53 built-in recipes.
3
3
 
4
- VERSION 1.4.0
4
+ VERSION 1.5.0
5
5
 
6
6
  ## Install
7
7
  npm i @zakkster/lite-ui-fx
@@ -14,7 +14,7 @@ Recipe = { tick(), init?(), onHover?(), onClick?(), onToggle?(), onDrag?(), dest
14
14
  ## Import -- Controller
15
15
  import { mountUIFX, UIType } from '@zakkster/lite-ui-fx';
16
16
 
17
- ## Import -- Recipes (one ./recipes subpath, 50 total, tree-shakeable)
17
+ ## Import -- Recipes (one ./recipes subpath, 53 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';
@@ -26,7 +26,9 @@ import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from
26
26
 
27
27
  ## Mount
28
28
  const instance = mountUIFX(container, UIType.TOGGLE, SwarmToggle, { label: 'Sound' });
29
- instance.destroy(); // cleanup
29
+ instance.setValue(v); // SLIDER/KNOB/PROGRESS: v in 0..1 (fires onDrag once); CHECKBOX: setValue(null) = indeterminate
30
+ instance.setChecked(b); // TOGGLE/CHECKBOX: set checked (fires onToggle once)
31
+ instance.destroy(); // cleanup
30
32
 
31
33
  ## Options (4th arg; unknown option or recipe-hook keys throw a did-you-mean -- fail closed)
32
34
  width, height, padding=40, label // geometry + accessible label
@@ -38,16 +40,21 @@ colors // string[] palette override; wins over theme (array-palette recipes:
38
40
  theme // { light, mid, dark } -> accent/dim/surface; partial or wrong-shape throws
39
41
  text // visible canvas label; falls back to label, then the recipe default (label-in-name)
40
42
  font // canvas font string; falls back to the recipe's historical font
43
+ knobMode // KNOB only: 'rotate' | 'vertical' pointer mapping (default 'rotate'); wrong type throws
44
+ announce // PROGRESS only: opt-in aria-live announcements at 10% steps; wrong type throws
41
45
 
42
46
  ## Element Types
43
- UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
44
- UIType.BUTTON -> <button> -> state.active, onClick(x, y, state)
45
- UIType.SLIDER -> <input type="range"> -> state.val (0-1), onDrag(val, velocity)
47
+ UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
48
+ UIType.BUTTON -> <button> -> state.active, onClick(x, y, state)
49
+ UIType.SLIDER -> <input type="range"> -> state.val (0-1), onDrag(val, velocity)
50
+ UIType.CHECKBOX -> <input type="checkbox"> (no role=switch) -> state.toggled + state.indeterminate, onToggle(checked)
51
+ UIType.PROGRESS -> <progress> (non-interactive) -> state.val, driven by instance.setValue
52
+ UIType.KNOB -> <input type="range"> -> state.val, arrows native + knobMode pointer map, onDrag(val, velocity)
46
53
 
47
54
  ## State Object (provided to tick every frame)
48
- { hover, active, focused, toggled, disabled, val, w, h, padding, dpr }
55
+ { hover, active, focused, toggled, indeterminate, disabled, val, w, h, padding, dpr }
49
56
 
50
- ## 50 Built-in Recipes
57
+ ## 53 Built-in Recipes
51
58
 
52
59
  ### Vol. 1 -- 10 recipes
53
60
  Toggles: SwarmToggle, LiquidToggle, NeonPulseToggle
@@ -72,6 +79,10 @@ Mood: DayNightToggle, ReactionPicker, NotificationBell
72
79
  Feedback: TypewriterField, SoundWaveBtn, UploadProgress
73
80
  Fun: ScratchReveal, TimerCountdown, PullRefresh
74
81
 
82
+ ### U4a -- 3 recipes (new element types)
83
+ Checkboxes (UIType.CHECKBOX): TickDraw, IndeterminateScan
84
+ Progress (UIType.PROGRESS): LiquidFill
85
+
75
86
  ## Writing Custom Recipes
76
87
  See UIFX-RECIPE-GUIDE.md (included in package).
77
88
 
@@ -87,8 +98,10 @@ See UIFX-RECIPE-GUIDE.md (included in package).
87
98
  - Zero-GC in all built-in recipes: const colors + globalAlpha, precomputed
88
99
  color/label LUTs, fixed preallocated particle pools, gradients built in init.
89
100
  Gated per recipe by the t3-frame-alloc torture tier (default AND themed mount).
90
- - Themeable: all 50 recipes honour { colors, theme:{light,mid,dark}, text, font },
101
+ - Themeable: all 53 recipes honour { colors, theme:{light,mid,dark}, text, font },
91
102
  resolved once in init (zero per-frame alloc). RECIPE_META.themeable is true for
92
- all 50; motionSafe stays false (reduced motion is a later pass). A bare mount is
103
+ all 53; motionSafe stays false (reduced motion is a later pass). A bare mount is
93
104
  byte-identical to pre-theming. Shipped palettes + APCA contrast are authored with
94
105
  @zakkster/lite-hueforge (a dev-only tool, never a runtime dependency).
106
+ - U4a element types: CHECKBOX (indeterminate), PROGRESS (setValue-driven, aria-live
107
+ opt-in), KNOB (knobMode pointer map); setValue/setChecked sync native+state+hook once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-ui-fx",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",