@zakkster/lite-ui-fx 1.6.0 → 1.7.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.7.0] -- 2026-09-07
9
+
10
+ Host integration (roadmap U5). Two host-clock modes plus reduced-motion and a
11
+ frame-budget signal, applied to BOTH mount modes (`mountUIFX` and `decorateUIFX`).
12
+ Additive: a default mount is byte-identical to 1.6.0.
13
+
14
+ ### Added
15
+
16
+ - `{ ticker }` mount option (both modes): a caller-supplied ticker
17
+ (`{ add(fn) -> removeFn }`, e.g. `@zakkster/lite-ticker`) drives the component
18
+ instead of the shared ref-counted ticker. `destroy()` unregisters the
19
+ component's frame but never destroys the caller's ticker (ownership stays with
20
+ the caller).
21
+ - `{ driven: true }` mount option (both modes): no ticker and no RAF; the host
22
+ drives each frame via `instance.tick(dtMs)`. `instance.tick` is the internal
23
+ frame body in driven mode and throws otherwise. `{ ticker }` and `{ driven }`
24
+ are mutually exclusive; a non-boolean `driven` or a ticker without `.add()`
25
+ throws at mount.
26
+ - `state.reducedMotion` (both modes): read from
27
+ `matchMedia('(prefers-reduced-motion: reduce)')` before `recipe.init` and
28
+ watched via the AbortController; absent `matchMedia` is a no-op (stays `false`).
29
+ Calm paths for six recipes -- `SwarmToggle`, `PasswordStrength`,
30
+ `TypewriterField`, `FocusHalo`, `ErrorShake`, `SuccessBloom`: under reduced
31
+ motion `ErrorShake` stops displacing, `SuccessBloom` spawns no particles, and
32
+ `SwarmToggle` rests its particles at formation.
33
+ - `state.budget` (0..1, both modes): a per-frame frame-budget number (1 at
34
+ ~60fps, lower as frames lengthen), computed in place with no allocation, for
35
+ budget-aware recipes to shed work.
36
+ - `mountRecipe` emits a `console.warn` (not a throw) when mounting a
37
+ `motionSafe:false` recipe while the user prefers reduced motion.
38
+ - TypeScript: `HostTicker`, `HostClockOptions`, `state.reducedMotion` /
39
+ `state.budget`, and `instance.tick(dtMs)` on both instance types.
40
+ - `decisions/0005-host-clock.md`. U5 coverage: t5 caller-ticker ownership +
41
+ driven determinism, t3 reduced-motion churn, and two t9 controls (`fake-calm`,
42
+ `ticker-ownership`). 177 -> 196 node:test tests; 5 -> 7 torture controls.
43
+
44
+ ### Changed
45
+
46
+ - The per-mount frame loop is one named function shared by all three clock modes;
47
+ the default (shared-ticker) path is byte-identical to 1.6.0.
48
+ - `RECIPE_META.motionSafe` is now `true` for six recipes (previously `false` for
49
+ all 56): it marks exactly the recipes that ship a reduced-motion calm path.
50
+
8
51
  ## [1.6.0] -- 2026-09-07
9
52
 
10
53
  Decorate mode (U4b, the second half of roadmap U4). A second public mount mode
@@ -70,12 +70,44 @@ The controller provides this every frame:
70
70
  h: number, // Element height
71
71
  padding: number, // Canvas overflow padding
72
72
  dpr: number, // Device pixel ratio
73
+ reducedMotion: boolean, // U5: user prefers reduced motion (see below)
74
+ budget: number, // U5: 0--1 frame budget (1 at ~60fps, lower under load)
73
75
  // Decorate mode only (decorateUIFX): the live host's value + validity.
74
76
  text: string, // the host form-control's value string ('' if none)
75
77
  valid: boolean, // the host's validity (el.validity.valid, else true)
76
78
  }
77
79
  ```
78
80
 
81
+ ## Reduced Motion & Frame Budget (U5)
82
+
83
+ Two state fields let a recipe be a good citizen without changing the interface.
84
+
85
+ - **`state.reducedMotion`** is `true` when the user has set
86
+ `prefers-reduced-motion: reduce`. A recipe that animates should read it and
87
+ render a **static** alternative -- no continuous motion, no bursts, no shakes.
88
+ Fades and instant state changes are fine; sustained or positional motion is not.
89
+ When your recipe ships such a calm path, set its `RECIPE_META.motionSafe: true`;
90
+ that flag is a promise the calm path exists, so keep them in sync.
91
+
92
+ ```javascript
93
+ tick(c, dt, now, st) {
94
+ // full motion vs. a steady, motion-free render
95
+ const wobble = st.reducedMotion ? 0 : Math.sin(now / 200) * 4;
96
+ // ... draw using `wobble` (0 = no motion) ...
97
+ }
98
+ ```
99
+
100
+ - **`state.budget`** is `1` when frames are healthy and drops toward `0` as they
101
+ lengthen. A budget-aware recipe scales expensive work by it (fewer particles,
102
+ less glow) so it degrades before the host drops frames. Consuming it is optional.
103
+
104
+ ```javascript
105
+ const live = (this.count = Math.floor(MAX_PARTICLES * st.budget));
106
+ ```
107
+
108
+ Both are read-only per-frame numbers -- never write them, never allocate to honour
109
+ them (a branch on a boolean/number is free; a new array per frame is not).
110
+
79
111
  ## The Pointer Object
80
112
 
81
113
  ```javascript
@@ -27,6 +27,12 @@ export interface UIFXState {
27
27
  h: number;
28
28
  padding: number;
29
29
  dpr: number;
30
+ /** U5: true when the user prefers reduced motion (matchMedia). Calm-path
31
+ * recipes render statically when set; recipes that ignore it animate. */
32
+ reducedMotion: boolean;
33
+ /** U5: frame budget in 0..1 -- 1 at ~60fps, lower as frames lengthen.
34
+ * Budget-aware recipes shed work (particles/glow) when it drops. */
35
+ budget: number;
30
36
  /** Decorate mode only (decorateUIFX): the host form-control's current value.
31
37
  * Absent for hijack mounts and for a non-form host (then ''). */
32
38
  text?: string;
@@ -61,7 +67,30 @@ export interface UIFXRecipe {
61
67
 
62
68
  export type RecipeFactory = () => UIFXRecipe;
63
69
 
64
- export interface MountOptions {
70
+ /**
71
+ * A caller-supplied clock for the { ticker } host-clock mode (U5). Duck-typed to
72
+ * @zakkster/lite-ticker: it must expose add(fn) returning a remove function. The
73
+ * component registers its frame on it and, on destroy, removes that frame but
74
+ * NEVER destroys the ticker -- ownership stays with the caller.
75
+ */
76
+ export interface HostTicker {
77
+ add(fn: (dtMs: number) => void): () => void;
78
+ }
79
+
80
+ /**
81
+ * Host-clock options (U5, decisions/0005), shared by both mount modes. Three
82
+ * mutually-exclusive modes: omit both for the shared ref-counted ticker (default);
83
+ * `ticker` to ride a caller-supplied clock; `driven: true` for no clock at all
84
+ * (the host calls instance.tick(dtMs)). Passing both throws.
85
+ */
86
+ export interface HostClockOptions {
87
+ /** Ride a caller-supplied ticker instead of the shared one. Mutually exclusive with `driven`. */
88
+ ticker?: HostTicker;
89
+ /** No ticker/RAF: the host drives frames via instance.tick(dtMs). Mutually exclusive with `ticker`. */
90
+ driven?: boolean;
91
+ }
92
+
93
+ export interface MountOptions extends HostClockOptions {
65
94
  width?: number;
66
95
  height?: number;
67
96
  padding?: number;
@@ -97,7 +126,7 @@ export interface MountOptions {
97
126
  * the hijack-only options (width/height/value/checked/disabled/knobMode/announce/
98
127
  * label) are rejected -- passing one throws (fail closed).
99
128
  */
100
- export interface DecorateOptions {
129
+ export interface DecorateOptions extends HostClockOptions {
101
130
  /** Overlay padding around the host, in px (default 40). */
102
131
  padding?: number;
103
132
  seed?: number;
@@ -113,6 +142,14 @@ export interface UIFXInstance {
113
142
  wrapper: HTMLDivElement;
114
143
  state: UIFXState;
115
144
 
145
+ /**
146
+ * Drive one frame by hand (U5). Callable ONLY when mounted with { driven: true }
147
+ * -- it is the internal frame body, so a driven host pays exactly the internal
148
+ * per-frame cost. On a ticker-driven component it throws (that component owns
149
+ * its own clock).
150
+ */
151
+ tick(dtMs: number): void;
152
+
116
153
  /**
117
154
  * Set a valued control (SLIDER/KNOB/PROGRESS) to v in [0,1]: updates the
118
155
  * native element, state.val, any PROGRESS announcer, and fires onDrag once.
@@ -150,6 +187,9 @@ export interface DecorateInstance {
150
187
  /** The overlay canvas (the only DOM node decorate adds). */
151
188
  canvas: HTMLCanvasElement;
152
189
  state: UIFXState;
190
+ /** Drive one frame by hand (U5). Callable ONLY with { driven: true }; a
191
+ * ticker-driven decoration throws. */
192
+ tick(dtMs: number): void;
153
193
  /** Hijack-only. Throws in decorate mode. */
154
194
  setValue(v?: number | null): void;
155
195
  /** Hijack-only. Throws in decorate mode. */
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.6.0';
25
+ export const VERSION = '1.7.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -79,19 +79,51 @@ function releaseSliderStyle() {
79
79
  }
80
80
 
81
81
 
82
+ // ---------------------------------------------------------
83
+ // HOST CLOCK + FRAME STATE (U5) -- shared by both mount modes
84
+ // ---------------------------------------------------------
85
+
86
+ // Frame budget (state.budget, 0..1): 1 when frames hit ~60fps, degrading as the
87
+ // frame delta grows so budget-aware recipes shed work BEFORE frames drop. A
88
+ // smoothed instantaneous ratio -- zero allocation (module consts + arithmetic on
89
+ // the dt the clock already provides; no extra clock read).
90
+ const _TARGET_DT = 1 / 60; // seconds per frame at 60fps
91
+ const _BUDGET_SMOOTH = 0.1; // EMA weight toward the instantaneous ratio
92
+
93
+ // prefers-reduced-motion query, created once at mount (cold). Returns null when
94
+ // matchMedia is absent -- fail closed: state.reducedMotion then stays false and
95
+ // every recipe renders its full-motion path, never throwing. The caller reads
96
+ // .matches into state BEFORE recipe.init, then wires the change listener once the
97
+ // AbortController exists (so teardown removes it). One helper, both mount modes.
98
+ function _reducedMotionQuery() {
99
+ return (typeof window.matchMedia === 'function')
100
+ ? window.matchMedia('(prefers-reduced-motion: reduce)')
101
+ : null;
102
+ }
103
+
104
+ // instance.tick() outside { driven } mode. A shared module stub (not a per-mount
105
+ // closure) so a ticker-driven component allocates nothing for a member it fails
106
+ // closed on: a component that rides a ticker does not accept hand-driven frames.
107
+ function _drivenOnly() {
108
+ throw new Error('tick(dtMs) is only callable in driven mode ({ driven: true }); this component rides a ticker');
109
+ }
110
+
111
+
82
112
  // ---------------------------------------------------------
83
113
  // MOUNT-TIME VALIDATION (cold path only -- never a hot body)
84
114
  // ---------------------------------------------------------
85
115
 
86
116
  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', 'knobMode', 'announce'];
117
+ const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font', 'knobMode', 'announce', 'ticker', 'driven'];
88
118
  const KNOB_MODES = ['rotate', 'vertical'];
89
119
 
90
120
  // Options valid in decorate mode (decorateUIFX). A canvas AROUND a live element
91
121
  // inherits the host's geometry (offset box) and value (read from el), so the
92
122
  // 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'];
123
+ // label) are rejected here -- fail closed. The host-clock options (ticker/driven)
124
+ // ARE valid in decorate mode: a decoration wants host-clock control every bit as
125
+ // much as a hijack does. Cold: read only at mount.
126
+ const DECORATE_OPTIONS = ['padding', 'seed', 'colors', 'theme', 'text', 'font', 'ticker', 'driven'];
95
127
 
96
128
  // Levenshtein edit distance. Cold: only reached on the error path.
97
129
  function _editDistance(a, b) {
@@ -249,6 +281,25 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
249
281
  throw new Error('mountUIFX: option "seed" must be a finite number');
250
282
  }
251
283
 
284
+ // Host clock (U5, decisions/0005). Three mutually-exclusive modes, resolved
285
+ // once here (cold): default -> the shared ref-counted ticker; { ticker } -> a
286
+ // caller-supplied lite-ticker drives this component; { driven:true } -> no
287
+ // ticker/RAF, the host calls instance.tick(dtMs). Both-passed, a non-boolean
288
+ // driven, or a ticker missing .add() is an Error, never a silent pick.
289
+ const callerTicker = options.ticker;
290
+ if (options.driven !== undefined && typeof options.driven !== 'boolean') {
291
+ throw new Error('mountUIFX: option "driven" must be a boolean');
292
+ }
293
+ const driven = options.driven === true;
294
+ if (callerTicker !== undefined) {
295
+ if (driven) {
296
+ throw new Error('mountUIFX: options "ticker" and "driven" are mutually exclusive');
297
+ }
298
+ if (!callerTicker || typeof callerTicker.add !== 'function') {
299
+ throw new Error('mountUIFX: option "ticker" must be a ticker with an .add(fn) method');
300
+ }
301
+ }
302
+
252
303
  // Type-scoped options (U4a). knobMode belongs only to a KNOB; announce only
253
304
  // to a PROGRESS. Presence on the wrong type is a mistake, not a silent
254
305
  // ignore (fail closed). Both validated here, before any element exists.
@@ -415,6 +466,10 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
415
466
  }
416
467
  let _lastAnnouncePct = -1; // last announced 10% step (cold: only setValue writes)
417
468
 
469
+ // prefers-reduced-motion query (U5): read its initial value into state below,
470
+ // BEFORE recipe.init, so a recipe reading state.reducedMotion in init is right.
471
+ const rmq = _reducedMotionQuery();
472
+
418
473
  // -- State (value/checked/disabled land here BEFORE frame 1) --
419
474
  const state = {
420
475
  hover: false,
@@ -424,6 +479,8 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
424
479
  indeterminate: false, // CHECKBOX only; set via setValue(null)
425
480
  disabled, // recipes can render a disabled look
426
481
  val: value !== undefined ? value : (type === UIType.SLIDER || type === UIType.KNOB ? 0.5 : 0), // 0-1
482
+ reducedMotion: rmq ? !!rmq.matches : false, // U5: calm-path recipes honour it
483
+ budget: 1, // U5: 0..1 frame budget, updated in place per frame
427
484
  w, h, padding, dpr,
428
485
  };
429
486
 
@@ -583,17 +640,31 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
583
640
  }, { signal });
584
641
  }
585
642
 
586
- // -- Render loop (shared ticker) --
587
- const ticker = acquireTicker();
588
- tickerAcquired = true;
643
+ // -- Reduced-motion change watch (U5). Cold; through signal so destroy removes
644
+ // it. The initial value was already read into state above. --
645
+ if (rmq) {
646
+ rmq.addEventListener('change', () => { state.reducedMotion = !!rmq.matches; }, { signal });
647
+ }
648
+
649
+ // -- Render loop. The frame body is ONE named function so all three clock
650
+ // modes invoke the SAME code with no wrapper: default and { ticker } pass
651
+ // `frame` to a ticker's .add(); { driven } exposes it as instance.tick. --
589
652
  let destroyed = false;
590
653
  let quarantined = false; // a recipe.tick throw quarantines only this one
591
654
 
592
- removeTick = ticker.add((dtMs) => {
655
+ function frame(dtMs) {
593
656
  if (destroyed || quarantined) return;
594
657
  const dt = dtMs / 1000;
595
658
  const now = performance.now();
596
659
 
660
+ // Frame budget (U5): update in place from the dt already in hand -- no
661
+ // allocation, no extra clock read.
662
+ if (dt > 0) {
663
+ let inst = _TARGET_DT / dt;
664
+ if (inst > 1) inst = 1; else if (inst < 0) inst = 0;
665
+ state.budget += (inst - state.budget) * _BUDGET_SMOOTH;
666
+ }
667
+
597
668
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
598
669
  ctx.clearRect(0, 0, cw, ch);
599
670
  ctx.save();
@@ -610,7 +681,20 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
610
681
  return;
611
682
  }
612
683
  ctx.restore();
613
- });
684
+ }
685
+
686
+ // Clock mode (validated cold in phase 1). Only the shared path acquires the
687
+ // ref-counted ticker; { ticker } borrows the host's clock and must never
688
+ // destroy it; { driven } schedules no RAF (the host calls instance.tick).
689
+ if (driven) {
690
+ // no ticker acquired; removeTick stays null
691
+ } else if (callerTicker !== undefined) {
692
+ removeTick = callerTicker.add(frame);
693
+ } else {
694
+ const ticker = acquireTicker();
695
+ tickerAcquired = true;
696
+ removeTick = ticker.add(frame);
697
+ }
614
698
 
615
699
  // -- Public API --
616
700
  return {
@@ -626,6 +710,14 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
626
710
  /** Current state (read-only reference). */
627
711
  state,
628
712
 
713
+ /**
714
+ * Drive one frame by hand (U5). Callable ONLY in { driven: true } mode:
715
+ * it IS the internal frame body, so a driven host pays exactly the internal
716
+ * per-frame cost (no wrapper). A ticker-driven component owns its own clock,
717
+ * so its tick() fails closed.
718
+ */
719
+ tick: driven ? frame : _drivenOnly,
720
+
629
721
  /**
630
722
  * Programmatically set a valued control (SLIDER/KNOB/PROGRESS) to v in
631
723
  * [0,1]: updates the native element, state.val, any PROGRESS announcer,
@@ -675,9 +767,9 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
675
767
  if (destroyed) return;
676
768
  destroyed = true;
677
769
  ac.abort();
678
- removeTick();
770
+ if (removeTick) removeTick(); // shared OR caller ticker; null when driven
679
771
  if (recipe.destroy) recipe.destroy();
680
- releaseTicker();
772
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired -- never a caller's
681
773
  if (type === UIType.SLIDER || type === UIType.KNOB) releaseSliderStyle();
682
774
  wrapper.remove();
683
775
  },
@@ -689,10 +781,8 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
689
781
  // ticker exist) never releases a ticker or aborts an ac that was never
690
782
  // created. recipe.destroy() is deliberately NOT called: init did not
691
783
  // succeed, so there is no initialised recipe to tear down.
692
- if (tickerAcquired) {
693
- if (removeTick) removeTick();
694
- releaseTicker();
695
- }
784
+ if (removeTick) removeTick(); // shared OR caller ticker -- both must unwind
785
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired
696
786
  if (acCreated) ac.abort();
697
787
  if (wrapperAppended) wrapper.remove();
698
788
  if (styleAcquired) releaseSliderStyle();
@@ -780,6 +870,22 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
780
870
  throw new Error('decorateUIFX: option "seed" must be a finite number');
781
871
  }
782
872
 
873
+ // Host clock (U5, decisions/0005) -- same three modes as mountUIFX. A
874
+ // decoration wants host-clock control every bit as much as a hijack does.
875
+ const callerTicker = options.ticker;
876
+ if (options.driven !== undefined && typeof options.driven !== 'boolean') {
877
+ throw new Error('decorateUIFX: option "driven" must be a boolean');
878
+ }
879
+ const driven = options.driven === true;
880
+ if (callerTicker !== undefined) {
881
+ if (driven) {
882
+ throw new Error('decorateUIFX: options "ticker" and "driven" are mutually exclusive');
883
+ }
884
+ if (!callerTicker || typeof callerTicker.add !== 'function') {
885
+ throw new Error('decorateUIFX: option "ticker" must be a ticker with an .add(fn) method');
886
+ }
887
+ }
888
+
783
889
  // 3. recipeFactory + recipe object + hooks (same contract as mountUIFX).
784
890
  if (typeof recipeFactory !== 'function') {
785
891
  throw new Error('decorateUIFX: recipeFactory must be a function');
@@ -840,6 +946,10 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
840
946
  el.parentNode.insertBefore(canvas, el.nextSibling);
841
947
  canvasAppended = true;
842
948
 
949
+ // prefers-reduced-motion query (U5): initial value read into state below,
950
+ // BEFORE recipe.init (same as mountUIFX).
951
+ const rmq = _reducedMotionQuery();
952
+
843
953
  // -- State. Generic fields wire like hijack mode; text/valid mirror the host,
844
954
  // read now at init (law: hook initial values from the element) and refreshed
845
955
  // at event time only. --
@@ -849,6 +959,8 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
849
959
  focused: (typeof document !== 'undefined' && document.activeElement === el),
850
960
  text: (typeof el.value === 'string' ? el.value : ''),
851
961
  valid: (el.validity ? !!el.validity.valid : true),
962
+ reducedMotion: rmq ? !!rmq.matches : false, // U5: calm-path recipes honour it
963
+ budget: 1, // U5: 0..1 frame budget, updated in place per frame
852
964
  w: ow, h: oh, padding, dpr,
853
965
  };
854
966
  const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
@@ -948,17 +1060,28 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
948
1060
  }, { signal });
949
1061
  }
950
1062
 
951
- // -- Render loop (shared ticker; same quarantine-on-throw as mountUIFX). --
952
- const ticker = acquireTicker();
953
- tickerAcquired = true;
1063
+ // -- Reduced-motion change watch (U5). Cold; through signal. --
1064
+ if (rmq) {
1065
+ rmq.addEventListener('change', () => { state.reducedMotion = !!rmq.matches; }, { signal });
1066
+ }
1067
+
1068
+ // -- Render loop. ONE named frame body; three clock modes invoke it with no
1069
+ // wrapper (same as mountUIFX). Same quarantine-on-throw. --
954
1070
  let destroyed = false;
955
1071
  let quarantined = false;
956
1072
 
957
- removeTick = ticker.add((dtMs) => {
1073
+ function frame(dtMs) {
958
1074
  if (destroyed || quarantined) return;
959
1075
  const dt = dtMs / 1000;
960
1076
  const now = performance.now();
961
1077
 
1078
+ // Frame budget (U5): update in place, no allocation.
1079
+ if (dt > 0) {
1080
+ let inst = _TARGET_DT / dt;
1081
+ if (inst > 1) inst = 1; else if (inst < 0) inst = 0;
1082
+ state.budget += (inst - state.budget) * _BUDGET_SMOOTH;
1083
+ }
1084
+
962
1085
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
963
1086
  ctx.clearRect(0, 0, cw, ch);
964
1087
  ctx.save();
@@ -973,7 +1096,19 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
973
1096
  return;
974
1097
  }
975
1098
  ctx.restore();
976
- });
1099
+ }
1100
+
1101
+ // Clock mode (validated cold in phase 1). Shared ticker / caller ticker /
1102
+ // driven -- identical to mountUIFX.
1103
+ if (driven) {
1104
+ // no ticker acquired; removeTick stays null
1105
+ } else if (callerTicker !== undefined) {
1106
+ removeTick = callerTicker.add(frame);
1107
+ } else {
1108
+ const ticker = acquireTicker();
1109
+ tickerAcquired = true;
1110
+ removeTick = ticker.add(frame);
1111
+ }
977
1112
 
978
1113
  // -- Public API --
979
1114
  return {
@@ -986,6 +1121,12 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
986
1121
  /** Current state (read-only reference). */
987
1122
  state,
988
1123
 
1124
+ /**
1125
+ * Drive one frame by hand (U5). Callable ONLY in { driven: true } mode; it
1126
+ * IS the internal frame body. A ticker-driven decoration fails closed.
1127
+ */
1128
+ tick: driven ? frame : _drivenOnly,
1129
+
989
1130
  /**
990
1131
  * Hijack-only. A decoration reflects the host; it does not own or push
991
1132
  * into the host's value, so setValue/setChecked fail closed here (use the
@@ -1004,9 +1145,9 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
1004
1145
  if (destroyed) return;
1005
1146
  destroyed = true;
1006
1147
  ac.abort();
1007
- removeTick();
1148
+ if (removeTick) removeTick(); // shared OR caller ticker; null when driven
1008
1149
  if (recipe.destroy) recipe.destroy();
1009
- releaseTicker();
1150
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired
1010
1151
  canvas.remove(); // the ONLY DOM node decorate added
1011
1152
  },
1012
1153
  };
@@ -1014,10 +1155,8 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
1014
1155
  // A phase-2 step threw (realistically recipe.init). Unwind ONLY what was
1015
1156
  // acquired, reverse order, each flag-guarded. recipe.destroy is NOT called
1016
1157
  // (init did not succeed). Re-throw the ORIGINAL error, unwrapped.
1017
- if (tickerAcquired) {
1018
- if (removeTick) removeTick();
1019
- releaseTicker();
1020
- }
1158
+ if (removeTick) removeTick(); // shared OR caller ticker -- both must unwind
1159
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired
1021
1160
  if (acCreated) ac.abort();
1022
1161
  if (canvasAppended) canvas.remove();
1023
1162
  throw err;
package/UIFXRecipes.js CHANGED
@@ -235,16 +235,26 @@ export function SwarmToggle(o = {}) {
235
235
  // Knob target position
236
236
  const tx = st.toggled ? st.w - 18 : 18;
237
237
 
238
- // Render particles (spring toward formation)
238
+ // Render particles (spring toward formation). Under reduced motion
239
+ // (U5) they REST at the formation -- no swarm drift, no explosion --
240
+ // so the toggle reads as a static knob. This is the reference calm path.
239
241
  ctx.fillStyle = st.toggled ? P.accent : P.dim;
240
- for (let i = 0; i < N; i++) {
241
- vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
242
- vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
243
- vx[i] *= 0.85;
244
- vy[i] *= 0.85;
245
- px[i] += vx[i] * dt;
246
- py[i] += vy[i] * dt;
247
- ctx.fillRect(px[i], py[i], 1.5, 1.5);
242
+ if (st.reducedMotion) {
243
+ for (let i = 0; i < N; i++) {
244
+ px[i] = tx + ox[i];
245
+ py[i] = st.h / 2 + oy[i];
246
+ ctx.fillRect(px[i], py[i], 1.5, 1.5);
247
+ }
248
+ } else {
249
+ for (let i = 0; i < N; i++) {
250
+ vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
251
+ vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
252
+ vx[i] *= 0.85;
253
+ vy[i] *= 0.85;
254
+ px[i] += vx[i] * dt;
255
+ py[i] += vy[i] * dt;
256
+ ctx.fillRect(px[i], py[i], 1.5, 1.5);
257
+ }
248
258
  }
249
259
 
250
260
  if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
@@ -2335,7 +2345,8 @@ export function PasswordStrength(o = {}) {
2335
2345
  const t = st.text || '';
2336
2346
  if (t !== lastText) { lastText = t; strength = pwStrength(t); }
2337
2347
  const level=Math.ceil(strength*4);
2338
- for(let i=0;i<4;i++) segs[i]=lerp(segs[i],i<level?1:0,dt*10);
2348
+ // Reduced motion (U5): snap segments to their level (no grow animation).
2349
+ for(let i=0;i<4;i++) segs[i]=st.reducedMotion?(i<level?1:0):lerp(segs[i],i<level?1:0,dt*10);
2339
2350
 
2340
2351
  const segW=(st.w-12)/4,segH=8;
2341
2352
  for(let i=0;i<4;i++){
@@ -2604,15 +2615,17 @@ export function TypewriterField(o = {}) {
2604
2615
  return {
2605
2616
  tick(c,dt,now,st) {
2606
2617
  const len=(st.text || '').length;
2607
- if(len>lastLen) spark=1; // a new char landed -> caret pulse
2618
+ // Reduced motion (U5): no keystroke pulse, no caret blink, underline
2619
+ // snaps to length -- a steady underline + steady caret, zero animation.
2620
+ if(len>lastLen && !st.reducedMotion) spark=1; // a new char landed -> caret pulse
2608
2621
  lastLen=len;
2609
- blink=(blink+dt*3)%2;
2610
- spark=spark>0?spark-dt*3:0;
2622
+ if(!st.reducedMotion) blink=(blink+dt*3)%2;
2623
+ spark=st.reducedMotion?0:(spark>0?spark-dt*3:0);
2611
2624
 
2612
2625
  // Underline grows toward a fraction of the width set by text length
2613
2626
  // (capped at ~24 chars = full width). No measureText -> zero-alloc.
2614
2627
  const target=len===0?0:Math.min(len/24,1);
2615
- fill=lerp(fill,target,dt*8);
2628
+ fill=st.reducedMotion?target:lerp(fill,target,dt*8);
2616
2629
  const y=st.h-3, x0=2, x1=2+(st.w-4)*fill;
2617
2630
 
2618
2631
  c.strokeStyle='rgba(255,255,255,.08)';c.lineWidth=2;
@@ -2626,7 +2639,7 @@ export function TypewriterField(o = {}) {
2626
2639
  c.beginPath();c.arc(x1,y,4+spark*3,0,PI2);c.fill();
2627
2640
  c.globalAlpha=1;
2628
2641
  }
2629
- if(st.focused&&blink<1){
2642
+ if(st.focused&&(st.reducedMotion||blink<1)){
2630
2643
  c.fillStyle=P.accent;c.fillRect(x1,y-9,1.5,12);
2631
2644
  }
2632
2645
  },
@@ -2909,7 +2922,8 @@ export function FocusHalo(o = {}) {
2909
2922
  tick(c,dt,now,st) {
2910
2923
  halo=lerp(halo, st.focused?1:0, dt*8);
2911
2924
  if(halo<0.01) return;
2912
- const breathe=0.75+Math.sin(now/380)*0.25, r=8;
2925
+ // Reduced motion (U5): a steady halo, no breathing pulse.
2926
+ const breathe=st.reducedMotion?1:0.75+Math.sin(now/380)*0.25, r=8;
2913
2927
  c.strokeStyle=P.accent;
2914
2928
  for(let i=3;i>=1;i--){
2915
2929
  c.globalAlpha=halo*breathe*(0.10*i);
@@ -2936,7 +2950,9 @@ export function ErrorShake(o = {}) {
2936
2950
  shake = shake>0 ? shake-dt*1.6 : 0;
2937
2951
  if(shake<0.01 && st.valid!==false) return; // nothing to show
2938
2952
 
2939
- const dx = shake>0 ? Math.sin(now/22)*shake*6 : 0;
2953
+ // Reduced motion (U5): the border appears/holds but NEVER shakes
2954
+ // (dx stays 0) -- the whole point of the media query.
2955
+ const dx = (shake>0 && !st.reducedMotion) ? Math.sin(now/22)*shake*6 : 0;
2940
2956
  const a = st.valid===false ? 0.9 : shake, r=8;
2941
2957
  c.globalAlpha=a;c.strokeStyle=P.accent;c.lineWidth=2;
2942
2958
  rr(c,dx,0,st.w,st.h,r);c.stroke();
@@ -2955,6 +2971,7 @@ export function SuccessBloom(o = {}) {
2955
2971
  let wasValid=true, ring=0;
2956
2972
  function bloom(st){
2957
2973
  ring=1;
2974
+ if(st.reducedMotion) return; // U5 calm: a fading ring only, no particle burst
2958
2975
  const cx=st.w/2, cy=st.h/2;
2959
2976
  for(let i=0;i<N;i++){
2960
2977
  const ang=(i/N)*PI2, sp=40+(i%5)*8;
@@ -2969,7 +2986,8 @@ export function SuccessBloom(o = {}) {
2969
2986
 
2970
2987
  if(ring>0){
2971
2988
  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;
2989
+ // Reduced motion (U5): a fixed-radius ring that fades (alpha), no expansion.
2990
+ const cx=st.w/2, cy=st.h/2, rad=st.reducedMotion?st.w*0.5:(1-ring)*st.w*0.6;
2973
2991
  c.globalAlpha=ring;c.strokeStyle=P.accent;c.lineWidth=2;
2974
2992
  c.beginPath();c.arc(cx,cy,rad,0,PI2);c.stroke();
2975
2993
  c.globalAlpha=1;
@@ -3149,7 +3167,7 @@ export const RECIPES = Object.assign(Object.create(null), {
3149
3167
  * motionSafe inherently-calm under prefers-reduced-motion (false for all -- U5)
3150
3168
  */
3151
3169
  export const RECIPE_META = [
3152
- { id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3170
+ { id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: true },
3153
3171
  { id: 'liquidToggle', name: 'Liquid Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3154
3172
  { id: 'neonPulseToggle', name: 'Neon Pulse Toggle', type: 'toggle', family: 'Toggles', themeable: true, motionSafe: false },
3155
3173
  { id: 'magneticButton', name: 'Magnetic Button', type: 'button', family: 'Buttons', themeable: true, motionSafe: false },
@@ -3190,21 +3208,21 @@ export const RECIPE_META = [
3190
3208
  { id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3191
3209
  { id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3192
3210
  { id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
3193
- { id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: false },
3211
+ { id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: true },
3194
3212
  { id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
3195
3213
  { id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
3196
3214
  { id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: true, motionSafe: false },
3197
3215
  { id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
3198
3216
  { id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: true, motionSafe: false },
3199
- { id: 'typewriterField', name: 'Typewriter Field', type: 'decorate', family: 'Feedback', themeable: true, motionSafe: false },
3217
+ { id: 'typewriterField', name: 'Typewriter Field', type: 'decorate', family: 'Feedback', themeable: true, motionSafe: true },
3200
3218
  { id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: true, motionSafe: false },
3201
3219
  { id: 'uploadProgress', name: 'Upload Progress', type: 'progress', family: 'Feedback', themeable: true, motionSafe: false },
3202
3220
  { id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: true, motionSafe: false },
3203
3221
  { id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: true, motionSafe: false },
3204
3222
  { 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 },
3223
+ { id: 'focusHalo', name: 'Focus Halo', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
3224
+ { id: 'errorShake', name: 'Error Shake', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
3225
+ { id: 'successBloom', name: 'Success Bloom', type: 'decorate', family: 'Form', themeable: true, motionSafe: true },
3208
3226
  ];
3209
3227
 
3210
3228
  /** Names of every built-in recipe (the keys of RECIPES at load time). */
@@ -3329,6 +3347,15 @@ export function mountRecipe(container, id, options) {
3329
3347
  }
3330
3348
  const meta = RECIPE_META.find((m) => m.id === id) || null;
3331
3349
  const type = meta ? meta.type : undefined;
3350
+ // Reduced-motion advisory (U5, decisions/0005): a recipe with no calm path,
3351
+ // mounted while the user prefers reduced motion, gets a console.warn -- advice,
3352
+ // not a gate (a host may run its own toggle). Cold, mount-time only; a missing
3353
+ // matchMedia is a silent skip (fail closed, never throw).
3354
+ if (meta && meta.motionSafe === false &&
3355
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function' &&
3356
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
3357
+ console.warn('mountRecipe: recipe "' + id + '" is not motionSafe and the user prefers reduced motion; it will animate. See RECIPE_META.motionSafe.');
3358
+ }
3332
3359
  if (options && 'type' in options && options.type !== type) {
3333
3360
  throw new Error(
3334
3361
  'mountRecipe: recipe "' + id + '" mounts as "' + type +
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zakkster/lite-ui-fx
2
2
  > Canvas-hijacked UI components with pluggable recipe system. 56 built-in recipes.
3
3
 
4
- VERSION 1.6.0
4
+ VERSION 1.7.0
5
5
 
6
6
  ## Install
7
7
  npm i @zakkster/lite-ui-fx
@@ -54,10 +54,24 @@ text // visible canvas label; falls back to label, then the recipe default
54
54
  font // canvas font string; falls back to the recipe's historical font
55
55
  knobMode // KNOB only: 'rotate' | 'vertical' pointer mapping (default 'rotate'); wrong type throws
56
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
57
+ ticker // U5 host clock: a caller-supplied lite-ticker ({ add(fn)->removeFn }) drives this component
58
+ driven // U5 host clock: boolean; true = no ticker/RAF, the host calls instance.tick(dtMs)
59
+ // decorateUIFX accepts a SUBSET: padding, seed, colors, theme, text, font -- PLUS
60
+ // the host-clock keys ticker/driven (a decoration wants clock control too). The
58
61
  // hijack-only keys (width/height/value/checked/disabled/knobMode/announce/label)
59
62
  // throw in decorate mode -- geometry comes from the host, value is read from it.
60
63
 
64
+ ## Host clock (U5) -- three mutually-exclusive modes, both mount modes
65
+ // default (neither option): the shared ref-counted ticker -- one RAF for all
66
+ // components, byte-identical to earlier versions.
67
+ // { ticker: t }: a caller-supplied lite-ticker drives this component. destroy()
68
+ // removes the component's frame but NEVER destroys the caller's ticker.
69
+ const inst = mountUIFX(container, UIType.SLIDER, SparkSlider, { ticker: myTicker });
70
+ // { driven: true }: no ticker/RAF at all; the host drives each frame by hand.
71
+ const d = mountUIFX(container, UIType.BUTTON, MagneticButton, { driven: true });
72
+ d.tick(16.7); // one frame; the SAME body the ticker would call. throws when NOT driven.
73
+ // Passing both, a non-boolean driven, or a ticker without .add() throws (fail closed).
74
+
61
75
  ## Element Types
62
76
  UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
63
77
  UIType.BUTTON -> <button> -> state.active, onClick(x, y, state)
@@ -69,8 +83,13 @@ UIType.KNOB -> <input type="range"> -> state.val, arrows native + knobMode p
69
83
  RECIPE_META.type 'decorate' routes mountRecipe to decorateUIFX. State: focused + text + valid.
70
84
 
71
85
  ## State Object (provided to tick every frame)
72
- { hover, active, focused, toggled, indeterminate, disabled, val, w, h, padding, dpr }
73
- // decorate mode adds: text (host value string), valid (host validity boolean).
86
+ { hover, active, focused, toggled, indeterminate, disabled, val, w, h, padding, dpr,
87
+ reducedMotion, budget }
88
+ // reducedMotion (U5): boolean, true when the user prefers reduced motion; a
89
+ // calm-path recipe renders statically when set (RECIPE_META.motionSafe marks which).
90
+ // budget (U5): 0..1 frame budget, 1 at ~60fps, lower as frames lengthen; budget-aware
91
+ // recipes shed work (particles/glow) when it drops.
92
+ // decorate mode also adds: text (host value string), valid (host validity boolean).
74
93
 
75
94
  ## 56 Built-in Recipes
76
95
 
@@ -123,9 +142,19 @@ See UIFX-RECIPE-GUIDE.md (included in package).
123
142
  Gated per recipe by the t3-frame-alloc torture tier (default AND themed mount).
124
143
  - Themeable: all 56 recipes honour { colors, theme:{light,mid,dark}, text, font },
125
144
  resolved once in init (zero per-frame alloc). RECIPE_META.themeable is true for
126
- all 56; motionSafe stays false (reduced motion is a later pass). A bare mount is
127
- byte-identical to pre-theming. Shipped palettes + APCA contrast are authored with
128
- @zakkster/lite-hueforge (a dev-only tool, never a runtime dependency).
145
+ all 56. A bare mount is byte-identical to pre-theming. Shipped palettes + APCA
146
+ contrast are authored with @zakkster/lite-hueforge (a dev-only tool, never a
147
+ runtime dependency).
148
+ - U5 host clock: mount { ticker } to ride a caller-supplied lite-ticker (destroy
149
+ never destroys the caller's clock) or { driven:true } to drive instance.tick(dtMs)
150
+ by hand (no RAF); omit both for the shared ref-counted ticker. Default byte-identical.
151
+ - U5 reduced motion: state.reducedMotion (matchMedia, watched) makes calm-path
152
+ recipes render statically -- ErrorShake stops shaking, SwarmToggle/SuccessBloom/
153
+ FocusHalo drop their motion. RECIPE_META.motionSafe is true for EXACTLY the
154
+ recipes that ship a calm path (SwarmToggle + the 5 decorate recipes today; the
155
+ rest honestly false until each lands one). mountRecipe warns (not throws) mounting
156
+ a motionSafe:false recipe under active reduce. state.budget (0..1) lets budget-aware
157
+ recipes shed work before frames drop.
129
158
  - U4a element types: CHECKBOX (indeterminate), PROGRESS (setValue-driven, aria-live
130
159
  opt-in), KNOB (knobMode pointer map); setValue/setChecked sync native+state+hook once.
131
160
  - U4b decorate mode (decorateUIFX): a canvas AROUND a live element -- no hijack, no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-ui-fx",
3
- "version": "1.6.0",
3
+ "version": "1.7.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",