@zakkster/lite-ui-fx 1.5.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/UIFXController.js CHANGED
@@ -22,7 +22,7 @@ import { Ticker } from '@zakkster/lite-ticker';
22
22
 
23
23
  // Three-place version sync: this constant, package.json "version", and the
24
24
  // VERSION line in llms.txt must always match. /release keeps them locked.
25
- export const VERSION = '1.5.0';
25
+ export const VERSION = '1.7.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -79,14 +79,52 @@ 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
 
120
+ // Options valid in decorate mode (decorateUIFX). A canvas AROUND a live element
121
+ // inherits the host's geometry (offset box) and value (read from el), so the
122
+ // hijack-only options (width/height/value/checked/disabled/knobMode/announce/
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'];
127
+
90
128
  // Levenshtein edit distance. Cold: only reached on the error path.
91
129
  function _editDistance(a, b) {
92
130
  const al = a.length;
@@ -243,6 +281,25 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
243
281
  throw new Error('mountUIFX: option "seed" must be a finite number');
244
282
  }
245
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
+
246
303
  // Type-scoped options (U4a). knobMode belongs only to a KNOB; announce only
247
304
  // to a PROGRESS. Presence on the wrong type is a mistake, not a silent
248
305
  // ignore (fail closed). Both validated here, before any element exists.
@@ -409,6 +466,10 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
409
466
  }
410
467
  let _lastAnnouncePct = -1; // last announced 10% step (cold: only setValue writes)
411
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
+
412
473
  // -- State (value/checked/disabled land here BEFORE frame 1) --
413
474
  const state = {
414
475
  hover: false,
@@ -418,6 +479,8 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
418
479
  indeterminate: false, // CHECKBOX only; set via setValue(null)
419
480
  disabled, // recipes can render a disabled look
420
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
421
484
  w, h, padding, dpr,
422
485
  };
423
486
 
@@ -577,17 +640,31 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
577
640
  }, { signal });
578
641
  }
579
642
 
580
- // -- Render loop (shared ticker) --
581
- const ticker = acquireTicker();
582
- 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. --
583
652
  let destroyed = false;
584
653
  let quarantined = false; // a recipe.tick throw quarantines only this one
585
654
 
586
- removeTick = ticker.add((dtMs) => {
655
+ function frame(dtMs) {
587
656
  if (destroyed || quarantined) return;
588
657
  const dt = dtMs / 1000;
589
658
  const now = performance.now();
590
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
+
591
668
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
592
669
  ctx.clearRect(0, 0, cw, ch);
593
670
  ctx.save();
@@ -604,7 +681,20 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
604
681
  return;
605
682
  }
606
683
  ctx.restore();
607
- });
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
+ }
608
698
 
609
699
  // -- Public API --
610
700
  return {
@@ -620,6 +710,14 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
620
710
  /** Current state (read-only reference). */
621
711
  state,
622
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
+
623
721
  /**
624
722
  * Programmatically set a valued control (SLIDER/KNOB/PROGRESS) to v in
625
723
  * [0,1]: updates the native element, state.val, any PROGRESS announcer,
@@ -669,9 +767,9 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
669
767
  if (destroyed) return;
670
768
  destroyed = true;
671
769
  ac.abort();
672
- removeTick();
770
+ if (removeTick) removeTick(); // shared OR caller ticker; null when driven
673
771
  if (recipe.destroy) recipe.destroy();
674
- releaseTicker();
772
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired -- never a caller's
675
773
  if (type === UIType.SLIDER || type === UIType.KNOB) releaseSliderStyle();
676
774
  wrapper.remove();
677
775
  },
@@ -683,10 +781,8 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
683
781
  // ticker exist) never releases a ticker or aborts an ac that was never
684
782
  // created. recipe.destroy() is deliberately NOT called: init did not
685
783
  // succeed, so there is no initialised recipe to tear down.
686
- if (tickerAcquired) {
687
- if (removeTick) removeTick();
688
- releaseTicker();
689
- }
784
+ if (removeTick) removeTick(); // shared OR caller ticker -- both must unwind
785
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired
690
786
  if (acCreated) ac.abort();
691
787
  if (wrapperAppended) wrapper.remove();
692
788
  if (styleAcquired) releaseSliderStyle();
@@ -695,4 +791,376 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
695
791
  }
696
792
  }
697
793
 
794
+ // =========================================================
795
+ // decorateUIFX -- The second mount mode (canvas AROUND a live element)
796
+ // =========================================================
797
+
798
+ /**
799
+ * Decorate an EXISTING visible element with a canvas recipe, WITHOUT hijacking
800
+ * it. Unlike mountUIFX this creates no native element, never sets opacity:0, and
801
+ * never reparents `el`: it adds ONE absolutely-positioned overlay canvas as a
802
+ * sibling in el.parentNode (placed from el's offset box) plus the listeners it
803
+ * owns, and on destroy removes exactly those -- the host is byte-identical to
804
+ * before. Recipe state is wired from el's own events; for a form-control host,
805
+ * state.text and state.valid mirror el.value / el.validity (read at event time,
806
+ * never per frame). This is the honest home for a decoration over a real input
807
+ * (PasswordStrength, TypewriterField) and for generic form feedback (FocusHalo,
808
+ * ErrorShake, SuccessBloom). See decisions/0004.
809
+ *
810
+ * @param {HTMLElement} el The live element to decorate (stays visible).
811
+ * @param {Function} recipeFactory (options) => Recipe object
812
+ * @param {Object} [options] padding, seed, colors, theme, text, font
813
+ * @returns {{ el, canvas, state, setValue, setChecked, destroy }}
814
+ */
815
+ export function decorateUIFX(el, recipeFactory, options = {}) {
816
+ // =====================================================================
817
+ // PHASE 1 -- VALIDATION ONLY. No side effect until every check passes
818
+ // (fail closed, mirrors mountUIFX): no createElement, no insertBefore,
819
+ // no ticker acquire, no recipe.init.
820
+ // =====================================================================
821
+
822
+ // 1. el must be a live, attached DOM element -- we read its offset box and
823
+ // hang the overlay off its parent. A detached el has no parentNode to host
824
+ // the canvas: an Error, never a silent no-op.
825
+ if (!el || typeof el.addEventListener !== 'function' ||
826
+ typeof el.getBoundingClientRect !== 'function') {
827
+ throw new Error('decorateUIFX: el must be a DOM element');
828
+ }
829
+ if (!el.parentNode || typeof el.parentNode.insertBefore !== 'function') {
830
+ throw new Error('decorateUIFX: el must be attached to the DOM (no parentNode to host the overlay)');
831
+ }
832
+
833
+ // 2. options: decorate accepts a subset. A hijack-only key is a mistake, not a
834
+ // silent ignore; a truly unknown key gets a did-you-mean over the decorate
835
+ // set. Both fail closed, before any element exists.
836
+ for (const k in options) {
837
+ if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
838
+ if (DECORATE_OPTIONS.indexOf(k) === -1) {
839
+ if (KNOWN_OPTIONS.indexOf(k) !== -1) {
840
+ throw new Error('decorateUIFX: option "' + k + '" is not valid in decorate mode (hijack-only)');
841
+ }
842
+ throw new Error(_didYouMean('decorateUIFX: unknown option', k, DECORATE_OPTIONS));
843
+ }
844
+ }
845
+ const padding = options.padding === undefined ? 40 : options.padding;
846
+
847
+ // Theming options (decisions/0002): validated fail closed here, forwarded to
848
+ // the recipe factory which resolves them in init. Cold mount code.
849
+ const _theme = options.theme;
850
+ if (_theme !== undefined) {
851
+ if (_theme === null || typeof _theme !== 'object' ||
852
+ typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
853
+ typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
854
+ throw new Error('decorateUIFX: option "theme" must be { light, mid, dark } of color strings');
855
+ }
856
+ }
857
+ const _colors = options.colors;
858
+ if (_colors !== undefined &&
859
+ (!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
860
+ throw new Error('decorateUIFX: option "colors" must be an array of color strings');
861
+ }
862
+ if (options.text !== undefined && typeof options.text !== 'string') {
863
+ throw new Error('decorateUIFX: option "text" must be a string');
864
+ }
865
+ if (options.font !== undefined && typeof options.font !== 'string') {
866
+ throw new Error('decorateUIFX: option "font" must be a string');
867
+ }
868
+ if (options.seed !== undefined &&
869
+ (typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
870
+ throw new Error('decorateUIFX: option "seed" must be a finite number');
871
+ }
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
+
889
+ // 3. recipeFactory + recipe object + hooks (same contract as mountUIFX).
890
+ if (typeof recipeFactory !== 'function') {
891
+ throw new Error('decorateUIFX: recipeFactory must be a function');
892
+ }
893
+ const recipe = recipeFactory(options);
894
+ if (!recipe || typeof recipe !== 'object') {
895
+ throw new Error('decorateUIFX: recipe must be an object');
896
+ }
897
+ if (typeof recipe.tick !== 'function') {
898
+ throw new Error('decorateUIFX: recipe.tick must be a function');
899
+ }
900
+ for (const k in recipe) {
901
+ if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
902
+ if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
903
+ throw new Error(_didYouMean('decorateUIFX: unknown recipe hook', k, KNOWN_HOOKS));
904
+ }
905
+ }
906
+
907
+ // =====================================================================
908
+ // PHASE 2 -- SIDE EFFECTS (fail-closed unwind, mirrors mountUIFX). The
909
+ // only acquisitions are the overlay canvas, the AbortController, and the
910
+ // shared ticker -- unwound in reverse order on any throw.
911
+ // =====================================================================
912
+ let canvasAppended = false;
913
+ let acCreated = false;
914
+ let tickerAcquired = false;
915
+ let canvas = null;
916
+ let ac = null;
917
+ let removeTick = null;
918
+
919
+ try {
920
+ // -- Placement from el's OFFSET box. Because the overlay is a SIBLING of el,
921
+ // they share an offsetParent, so offset-box coords land the canvas over el
922
+ // WITHOUT writing any style onto the parent (decision 2). Read once here,
923
+ // refreshed on resize only. --
924
+ let ow = el.offsetWidth;
925
+ let oh = el.offsetHeight;
926
+ let dpr = window.devicePixelRatio || 1;
927
+ let cw = ow + padding * 2;
928
+ let ch = oh + padding * 2;
929
+
930
+ canvas = document.createElement('canvas');
931
+ canvas.width = cw * dpr;
932
+ canvas.height = ch * dpr;
933
+ Object.assign(canvas.style, {
934
+ position: 'absolute',
935
+ left: (el.offsetLeft - padding) + 'px',
936
+ top: (el.offsetTop - padding) + 'px',
937
+ width: cw + 'px', height: ch + 'px',
938
+ pointerEvents: 'none',
939
+ });
940
+ const ctx = canvas.getContext('2d');
941
+ ctx.scale(dpr, dpr);
942
+
943
+ // Insert the overlay right AFTER el: among auto-z siblings it paints on top,
944
+ // and pointerEvents:none keeps el receiving every event. el is NOT touched --
945
+ // no style write, no reparent (the whole point of decorate mode).
946
+ el.parentNode.insertBefore(canvas, el.nextSibling);
947
+ canvasAppended = true;
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
+
953
+ // -- State. Generic fields wire like hijack mode; text/valid mirror the host,
954
+ // read now at init (law: hook initial values from the element) and refreshed
955
+ // at event time only. --
956
+ const state = {
957
+ hover: false,
958
+ active: false,
959
+ focused: (typeof document !== 'undefined' && document.activeElement === el),
960
+ text: (typeof el.value === 'string' ? el.value : ''),
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
964
+ w: ow, h: oh, padding, dpr,
965
+ };
966
+ const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
967
+ // Cached rect for pointer math (U-11): filled lazily, refreshed on enter/
968
+ // scroll/resize; pointermove does ZERO layout reads at steady state.
969
+ let rect = null;
970
+
971
+ // -- Initialize recipe (validated in phase 1). ctx exists now. --
972
+ if (recipe.init) recipe.init(ctx, ow, oh, padding);
973
+
974
+ // -- Events (all via AbortController: destroy()'s abort removes exactly what
975
+ // decorate added and nothing the host owned). --
976
+ ac = new AbortController();
977
+ acCreated = true;
978
+ const signal = ac.signal;
979
+
980
+ function updatePointer(e) {
981
+ if (!rect) rect = el.getBoundingClientRect();
982
+ const nx = e.clientX - rect.left;
983
+ const ny = e.clientY - rect.top;
984
+ pointer.vx = nx - pointer.x;
985
+ pointer.vy = ny - pointer.y;
986
+ pointer.x = nx;
987
+ pointer.y = ny;
988
+ }
989
+ function refreshRect() { rect = el.getBoundingClientRect(); }
990
+ // Reposition the overlay from the offset box after a layout change (cold path).
991
+ // All layout READS are hoisted above the style WRITES: writing canvas.style
992
+ // dirties layout, so reading el.offset* afterwards would force a synchronous
993
+ // reflow. A decoration over live DOM is the one place this package can force
994
+ // layout (see the U4b brief HOT PATH note), so keep read-before-write even here.
995
+ function reposition() {
996
+ const nw = el.offsetWidth;
997
+ const nh = el.offsetHeight;
998
+ const ol = el.offsetLeft;
999
+ const ot = el.offsetTop;
1000
+ canvas.style.left = (ol - padding) + 'px';
1001
+ canvas.style.top = (ot - padding) + 'px';
1002
+ if (nw !== ow || nh !== oh) {
1003
+ ow = nw; oh = nh;
1004
+ cw = ow + padding * 2;
1005
+ ch = oh + padding * 2;
1006
+ canvas.width = cw * dpr;
1007
+ canvas.height = ch * dpr;
1008
+ canvas.style.width = cw + 'px';
1009
+ canvas.style.height = ch + 'px';
1010
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1011
+ state.w = ow; state.h = oh;
1012
+ }
1013
+ }
1014
+
1015
+ window.addEventListener('scroll', refreshRect, { passive: true, signal });
1016
+ window.addEventListener('resize', () => { reposition(); refreshRect(); }, { passive: true, signal });
1017
+
1018
+ el.addEventListener('pointermove', updatePointer, { signal });
1019
+ el.addEventListener('pointerenter', (e) => {
1020
+ state.hover = true;
1021
+ refreshRect();
1022
+ updatePointer(e);
1023
+ if (recipe.onHover) recipe.onHover(state, pointer);
1024
+ }, { signal });
1025
+ el.addEventListener('pointerleave', () => {
1026
+ state.hover = false;
1027
+ if (recipe.onLeave) recipe.onLeave(state, pointer);
1028
+ }, { signal });
1029
+ el.addEventListener('pointerdown', (e) => {
1030
+ state.active = true;
1031
+ updatePointer(e);
1032
+ if (recipe.onClick) recipe.onClick(pointer.x, pointer.y, state);
1033
+ }, { signal });
1034
+ el.addEventListener('pointerup', () => { state.active = false; }, { signal });
1035
+
1036
+ el.addEventListener('focus', () => { state.focused = true; }, { signal });
1037
+ el.addEventListener('blur', () => { state.focused = false; }, { signal });
1038
+
1039
+ // Host content -> state, at EVENT time only (el.value getter allocates a
1040
+ // string; keep it off the frame path). A non-form host never fires these.
1041
+ function syncHostValue() {
1042
+ state.text = (typeof el.value === 'string' ? el.value : '');
1043
+ state.valid = (el.validity ? !!el.validity.valid : true);
1044
+ }
1045
+ el.addEventListener('input', syncHostValue, { signal });
1046
+ el.addEventListener('change', syncHostValue, { signal });
1047
+ el.addEventListener('invalid', () => { state.valid = false; }, { signal });
1048
+
1049
+ // -- DPR re-read on display change (cold, feature-detected; absent matchMedia
1050
+ // is a silent no-op -- fail closed, never throw). --
1051
+ if (typeof window.matchMedia === 'function') {
1052
+ const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
1053
+ mq.addEventListener('change', () => {
1054
+ const nd = window.devicePixelRatio || 1;
1055
+ dpr = nd;
1056
+ canvas.width = cw * nd;
1057
+ canvas.height = ch * nd;
1058
+ ctx.setTransform(nd, 0, 0, nd, 0, 0);
1059
+ state.dpr = nd;
1060
+ }, { signal });
1061
+ }
1062
+
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. --
1070
+ let destroyed = false;
1071
+ let quarantined = false;
1072
+
1073
+ function frame(dtMs) {
1074
+ if (destroyed || quarantined) return;
1075
+ const dt = dtMs / 1000;
1076
+ const now = performance.now();
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
+
1085
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1086
+ ctx.clearRect(0, 0, cw, ch);
1087
+ ctx.save();
1088
+ ctx.translate(padding, padding); // Origin = the host element's top-left
1089
+ try {
1090
+ recipe.tick(ctx, dt, now, state, pointer);
1091
+ } catch (err) {
1092
+ quarantined = true;
1093
+ console.error('decorateUIFX: recipe.tick threw; decoration quarantined', err);
1094
+ ctx.restore();
1095
+ ctx.clearRect(0, 0, cw, ch);
1096
+ return;
1097
+ }
1098
+ ctx.restore();
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
+ }
1112
+
1113
+ // -- Public API --
1114
+ return {
1115
+ /** The decorated host element (unchanged; provided for external reads). */
1116
+ el,
1117
+
1118
+ /** The overlay canvas (for external styling). */
1119
+ canvas,
1120
+
1121
+ /** Current state (read-only reference). */
1122
+ state,
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
+
1130
+ /**
1131
+ * Hijack-only. A decoration reflects the host; it does not own or push
1132
+ * into the host's value, so setValue/setChecked fail closed here (use the
1133
+ * host's own API to change it -- the decoration follows via its events).
1134
+ */
1135
+ setValue() {
1136
+ throw new Error('decorateUIFX: setValue is hijack-only; a decoration reflects the host, it does not drive it');
1137
+ },
1138
+ setChecked() {
1139
+ throw new Error('decorateUIFX: setChecked is hijack-only; a decoration reflects the host, it does not drive it');
1140
+ },
1141
+
1142
+ /** Destroy: remove the overlay + every listener decorate added. Idempotent.
1143
+ * The host element is byte-identical to before decorate (never touched). */
1144
+ destroy() {
1145
+ if (destroyed) return;
1146
+ destroyed = true;
1147
+ ac.abort();
1148
+ if (removeTick) removeTick(); // shared OR caller ticker; null when driven
1149
+ if (recipe.destroy) recipe.destroy();
1150
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired
1151
+ canvas.remove(); // the ONLY DOM node decorate added
1152
+ },
1153
+ };
1154
+ } catch (err) {
1155
+ // A phase-2 step threw (realistically recipe.init). Unwind ONLY what was
1156
+ // acquired, reverse order, each flag-guarded. recipe.destroy is NOT called
1157
+ // (init did not succeed). Re-throw the ORIGINAL error, unwrapped.
1158
+ if (removeTick) removeTick(); // shared OR caller ticker -- both must unwind
1159
+ if (tickerAcquired) releaseTicker(); // release ONLY the shared ticker we acquired
1160
+ if (acCreated) ac.abort();
1161
+ if (canvasAppended) canvas.remove();
1162
+ throw err;
1163
+ }
1164
+ }
1165
+
698
1166
  export default mountUIFX;
package/UIFXRecipes.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { UIFXRecipe, UIFXInstance, MountOptions } from './UIFXController';
2
2
 
3
3
  // ===========================================================
4
- // RECIPE OPTIONS + FACTORIES (all 53)
4
+ // RECIPE OPTIONS + FACTORIES (all 56)
5
5
  // ===========================================================
6
6
 
7
7
  /**
@@ -119,6 +119,13 @@ export declare function ScratchReveal(options?: RecipeOptions): UIFXRecipe;
119
119
  export declare function TimerCountdown(options?: RecipeOptions): UIFXRecipe;
120
120
  export declare function PullRefresh(options?: RecipeOptions): UIFXRecipe;
121
121
 
122
+ // -- U4b: DECORATE recipes (mounted AROUND a live element via decorateUIFX).
123
+ // Generic form feedback; PasswordStrength + TypewriterField (above) re-home
124
+ // onto decorate mode too. See decisions/0004. --
125
+ export declare function FocusHalo(options?: RecipeOptions): UIFXRecipe;
126
+ export declare function ErrorShake(options?: RecipeOptions): UIFXRecipe;
127
+ export declare function SuccessBloom(options?: RecipeOptions): UIFXRecipe;
128
+
122
129
  // ===========================================================
123
130
  // BARREL OBJECTS (back-compat)
124
131
  // ===========================================================
@@ -189,11 +196,21 @@ export declare const UIFXRecipes4: {
189
196
  LiquidFill: typeof LiquidFill;
190
197
  };
191
198
 
199
+ /** U4b additions -- decorate-mode recipes (kept out of the Vol.1-3 + Vol.4 snapshots). */
200
+ export declare const UIFXRecipes5: {
201
+ FocusHalo: typeof FocusHalo;
202
+ ErrorShake: typeof ErrorShake;
203
+ SuccessBloom: typeof SuccessBloom;
204
+ };
205
+
192
206
  // ===========================================================
193
207
  // RECIPE REGISTRY
194
208
  // ===========================================================
195
209
 
196
- export type RecipeType = 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob';
210
+ // 'decorate' is not a UIType (it creates no native element); it is the registry
211
+ // routing tag for a recipe mounted AROUND a live element via decorateUIFX. See
212
+ // decisions/0004.
213
+ export type RecipeType = 'toggle' | 'button' | 'slider' | 'checkbox' | 'progress' | 'knob' | 'decorate';
197
214
 
198
215
  export type RecipeFactory = (options?: Record<string, unknown>) => UIFXRecipe;
199
216
 
@@ -223,7 +240,10 @@ export declare function registerRecipe(
223
240
  ): RecipeFactory;
224
241
 
225
242
  /**
226
- * Resolve a recipe id to its factory + declared type and mount it via mountUIFX.
243
+ * Resolve a recipe id to its factory + declared type and mount it. A hijack
244
+ * recipe mounts via mountUIFX (the native element is created inside `container`);
245
+ * a recipe whose meta.type is 'decorate' mounts via decorateUIFX, treating
246
+ * `container` as the LIVE element to decorate (a canvas is placed AROUND it).
227
247
  * Fail closed: unknown id or a conflicting options.type throws.
228
248
  */
229
249
  export declare function mountRecipe(
@@ -233,7 +253,7 @@ export declare function mountRecipe(
233
253
  ): UIFXInstance;
234
254
 
235
255
  // ===========================================================
236
- // DEFAULT EXPORT -- combined all-53 namespace
256
+ // DEFAULT EXPORT -- combined all-56 namespace
237
257
  // ===========================================================
238
258
 
239
259
  declare const UIFXAllRecipes: {
@@ -290,5 +310,8 @@ declare const UIFXAllRecipes: {
290
310
  ScratchReveal: typeof ScratchReveal;
291
311
  TimerCountdown: typeof TimerCountdown;
292
312
  PullRefresh: typeof PullRefresh;
313
+ FocusHalo: typeof FocusHalo;
314
+ ErrorShake: typeof ErrorShake;
315
+ SuccessBloom: typeof SuccessBloom;
293
316
  };
294
317
  export default UIFXAllRecipes;