@zakkster/lite-ui-fx 1.6.0 → 1.8.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.
@@ -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.8.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 +