@zakkster/lite-ui-fx 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/UIFXController.js CHANGED
@@ -22,7 +22,7 @@ import { Ticker } from '@zakkster/lite-ticker';
22
22
 
23
23
  // Three-place version sync: this constant, package.json "version", and the
24
24
  // VERSION line in llms.txt must always match. /release keeps them locked.
25
- export const VERSION = '1.4.0';
25
+ export const VERSION = '1.6.0';
26
26
 
27
27
  // ---------------------------------------------------------
28
28
  // SHARED TICKER (ref-counted, one RAF for all UI components)
@@ -84,7 +84,14 @@ function releaseSliderStyle() {
84
84
  // ---------------------------------------------------------
85
85
 
86
86
  const KNOWN_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'destroy'];
87
- const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font'];
87
+ const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font', 'knobMode', 'announce'];
88
+ const KNOB_MODES = ['rotate', 'vertical'];
89
+
90
+ // Options valid in decorate mode (decorateUIFX). A canvas AROUND a live element
91
+ // inherits the host's geometry (offset box) and value (read from el), so the
92
+ // hijack-only options (width/height/value/checked/disabled/knobMode/announce/
93
+ // label) are rejected here -- fail closed. Cold: read only at mount.
94
+ const DECORATE_OPTIONS = ['padding', 'seed', 'colors', 'theme', 'text', 'font'];
88
95
 
89
96
  // Levenshtein edit distance. Cold: only reached on the error path.
90
97
  function _editDistance(a, b) {
@@ -143,8 +150,15 @@ export const UIType = Object.freeze({
143
150
  BUTTON: 'button',
144
151
  TOGGLE: 'toggle',
145
152
  SLIDER: 'slider',
153
+ CHECKBOX: 'checkbox', // <input type=checkbox> WITHOUT role=switch (a check is not a switch)
154
+ PROGRESS: 'progress', // native <progress>, non-interactive, value driven programmatically
155
+ KNOB: 'knob', // <input type=range>, arrows native, canvas-side knobMode pointer map
146
156
  });
147
157
 
158
+ // The valid mount types, derived once from UIType so the controller guard, the
159
+ // recipe registry, and the d.ts never drift apart. Cold: read only at mount.
160
+ const _KNOWN_TYPES = new Set(Object.values(UIType));
161
+
148
162
 
149
163
  // =========================================================
150
164
  // UIFXController -- The Canvas Hijacker
@@ -177,11 +191,11 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
177
191
  throw new Error('mountUIFX: container must be a DOM element');
178
192
  }
179
193
 
180
- // 1b. type: exactly one of the three known element types. An unknown or
194
+ // 1b. type: exactly one of the known element types (UIType). An unknown or
181
195
  // undefined type is an Error here, never a silent default to a button
182
196
  // (fail closed -- the type selects the native element).
183
- if (type !== UIType.BUTTON && type !== UIType.TOGGLE && type !== UIType.SLIDER) {
184
- throw new Error('mountUIFX: type must be UIType.BUTTON, UIType.TOGGLE, or UIType.SLIDER');
197
+ if (!_KNOWN_TYPES.has(type)) {
198
+ throw new Error('mountUIFX: type must be one of UIType.BUTTON, TOGGLE, SLIDER, CHECKBOX, PROGRESS, KNOB');
185
199
  }
186
200
 
187
201
  // 2. options: unknown keys -> did-you-mean; value/checked/disabled
@@ -205,6 +219,8 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
205
219
  const height = options.height;
206
220
  const padding = options.padding === undefined ? 40 : options.padding;
207
221
  const label = options.label === undefined ? '' : options.label;
222
+ const knobMode = options.knobMode || 'rotate'; // KNOB pointer map; validated below
223
+ const announce = options.announce === true; // PROGRESS aria-live; validated below
208
224
 
209
225
  // Reserved theming options (decisions/0002): all optional, validated fail
210
226
  // closed here, then forwarded to the recipe factory which resolves them in
@@ -233,6 +249,26 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
233
249
  throw new Error('mountUIFX: option "seed" must be a finite number');
234
250
  }
235
251
 
252
+ // Type-scoped options (U4a). knobMode belongs only to a KNOB; announce only
253
+ // to a PROGRESS. Presence on the wrong type is a mistake, not a silent
254
+ // ignore (fail closed). Both validated here, before any element exists.
255
+ if (options.knobMode !== undefined) {
256
+ if (type !== UIType.KNOB) {
257
+ throw new Error('mountUIFX: option "knobMode" is only valid for UIType.KNOB');
258
+ }
259
+ if (KNOB_MODES.indexOf(options.knobMode) === -1) {
260
+ throw new Error('mountUIFX: option "knobMode" must be "rotate" or "vertical"');
261
+ }
262
+ }
263
+ if (options.announce !== undefined) {
264
+ if (type !== UIType.PROGRESS) {
265
+ throw new Error('mountUIFX: option "announce" is only valid for UIType.PROGRESS');
266
+ }
267
+ if (typeof options.announce !== 'boolean') {
268
+ throw new Error('mountUIFX: option "announce" must be a boolean');
269
+ }
270
+ }
271
+
236
272
  // 3. recipeFactory
237
273
  if (typeof recipeFactory !== 'function') {
238
274
  throw new Error('mountUIFX: recipeFactory must be a function');
@@ -279,24 +315,37 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
279
315
 
280
316
  try {
281
317
  // -- Resolve dimensions --
282
- const w = width || (type === UIType.BUTTON ? 160 : type === UIType.SLIDER ? 200 : 64);
283
- const h = height || (type === UIType.BUTTON ? 48 : type === UIType.SLIDER ? 28 : 36);
318
+ // SLIDER/PROGRESS/KNOB share the 200x28 range geometry (so every vol.3
319
+ // re-home renders byte-identical to its slider era); CHECKBOX shares the
320
+ // 64x36 toggle geometry; BUTTON keeps 160x48.
321
+ const _rangeLike = type === UIType.SLIDER || type === UIType.PROGRESS || type === UIType.KNOB;
322
+ const w = width || (type === UIType.BUTTON ? 160 : _rangeLike ? 200 : 64);
323
+ const h = height || (type === UIType.BUTTON ? 48 : _rangeLike ? 28 : 36);
284
324
  let dpr = window.devicePixelRatio || 1;
285
325
 
286
326
  // -- Create native element (invisible, accessible, receives events) --
287
327
  let el;
288
- if (type === UIType.TOGGLE) {
328
+ if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
289
329
  el = document.createElement('input');
290
330
  el.type = 'checkbox';
291
- el.setAttribute('role', 'switch');
331
+ // TOGGLE is a switch; CHECKBOX is a plain checkbox. A check is not a
332
+ // switch -- U4a drops the role for CHECKBOX (the vol.3 mis-mount fix).
333
+ if (type === UIType.TOGGLE) el.setAttribute('role', 'switch');
292
334
  el.checked = checked; // coerced boolean; lands before frame 1
293
335
  if (label) el.setAttribute('aria-label', label);
294
- } else if (type === UIType.SLIDER) {
336
+ } else if (type === UIType.SLIDER || type === UIType.KNOB) {
295
337
  el = document.createElement('input');
296
338
  el.type = 'range';
297
339
  el.min = '0'; el.max = '100';
298
340
  el.value = value !== undefined ? String(value * 100) : '50'; // 0..1 -> 0..100
299
341
  if (label) el.setAttribute('aria-label', label);
342
+ } else if (type === UIType.PROGRESS) {
343
+ // Non-interactive: value is written programmatically (setValue) only,
344
+ // and exposed to assistive tech by the native <progress> element.
345
+ el = document.createElement('progress');
346
+ el.max = 1;
347
+ el.value = value !== undefined ? value : 0; // 0..1
348
+ if (label) el.setAttribute('aria-label', label);
300
349
  } else {
301
350
  el = document.createElement('button');
302
351
  el.textContent = label || 'Action';
@@ -316,7 +365,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
316
365
  // Slider thumb needs explicit sizing for hit area. One shared, ref-counted
317
366
  // <style> for all sliders (U-09): released in destroy() when the last slider
318
367
  // goes -- head child count nets to zero across mount/destroy.
319
- if (type === UIType.SLIDER) {
368
+ if (type === UIType.SLIDER || type === UIType.KNOB) {
320
369
  acquireSliderStyle();
321
370
  styleAcquired = true;
322
371
  el.classList.add('uifx-slider');
@@ -349,14 +398,32 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
349
398
  container.appendChild(wrapper);
350
399
  wrapperAppended = true;
351
400
 
401
+ // -- Optional aria-live announcer for PROGRESS (U4a). Opt-in via
402
+ // { announce: true }: a visually-hidden polite region that setValue
403
+ // updates at 10% steps. It lives in the wrapper, so wrapper.remove() in
404
+ // destroy() takes it with everything else -- no separate teardown. --
405
+ let announceRegion = null;
406
+ if (type === UIType.PROGRESS && announce) {
407
+ announceRegion = document.createElement('span');
408
+ announceRegion.setAttribute('aria-live', 'polite');
409
+ Object.assign(announceRegion.style, {
410
+ position: 'absolute', width: '1px', height: '1px',
411
+ overflow: 'hidden', clipPath: 'inset(50%)',
412
+ whiteSpace: 'nowrap', border: '0', padding: '0', margin: '-1px',
413
+ });
414
+ wrapper.appendChild(announceRegion);
415
+ }
416
+ let _lastAnnouncePct = -1; // last announced 10% step (cold: only setValue writes)
417
+
352
418
  // -- State (value/checked/disabled land here BEFORE frame 1) --
353
419
  const state = {
354
420
  hover: false,
355
421
  active: false, // pointer is down
356
422
  focused: false, // keyboard focus
357
423
  toggled: checked, // coerced boolean; element + state AGREE
424
+ indeterminate: false, // CHECKBOX only; set via setValue(null)
358
425
  disabled, // recipes can render a disabled look
359
- val: value !== undefined ? value : (type === UIType.SLIDER ? 0.5 : 0), // 0-1
426
+ val: value !== undefined ? value : (type === UIType.SLIDER || type === UIType.KNOB ? 0.5 : 0), // 0-1
360
427
  w, h, padding, dpr,
361
428
  };
362
429
 
@@ -366,6 +433,28 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
366
433
  // pointer event, then lazily filled once (see updatePointer).
367
434
  let rect = null;
368
435
 
436
+ // Apply a numeric value to a valued control (SLIDER/KNOB/PROGRESS): reflect
437
+ // it to the native element, update state, announce (PROGRESS), and fire
438
+ // onDrag exactly once when asked. A programmatic el.value write fires NO
439
+ // native 'input', so this explicit hook call is the only one -- no double
440
+ // fire. Cold path (setValue + knob drag), never a per-frame body.
441
+ function _applyVal(v, fireHook) {
442
+ state.val = v;
443
+ if (type === UIType.PROGRESS) {
444
+ el.value = v; // 0..1, native max=1
445
+ if (announceRegion) {
446
+ const pct = Math.round(v * 10) * 10;
447
+ if (pct !== _lastAnnouncePct) {
448
+ _lastAnnouncePct = pct;
449
+ announceRegion.textContent = pct + '%';
450
+ }
451
+ }
452
+ } else {
453
+ el.value = String(v * 100); // range 0..100
454
+ }
455
+ if (fireHook && recipe.onDrag) recipe.onDrag(v, pointer.vx, state);
456
+ }
457
+
369
458
  // -- Initialize recipe (already validated in phase 1: object, tick fn,
370
459
  // only known hooks). ctx exists now, so init can run. --
371
460
  if (recipe.init) recipe.init(ctx, w, h, padding);
@@ -417,9 +506,12 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
417
506
  el.addEventListener('focus', () => { state.focused = true; }, { signal });
418
507
  el.addEventListener('blur', () => { state.focused = false; }, { signal });
419
508
 
420
- // Toggle events
421
- if (type === UIType.TOGGLE) {
509
+ // Toggle + checkbox events (both are a native <input type=checkbox>)
510
+ if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
422
511
  el.addEventListener('change', () => {
512
+ // A user interaction resolves any indeterminate state (native does
513
+ // this too); keep state.indeterminate in agreement.
514
+ state.indeterminate = false;
423
515
  state.toggled = el.checked;
424
516
  if (recipe.onToggle) recipe.onToggle(state.toggled, state);
425
517
  }, { signal });
@@ -431,14 +523,51 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
431
523
  }, { signal });
432
524
  }
433
525
 
434
- // Slider events
435
- if (type === UIType.SLIDER) {
526
+ // Slider + knob value events. A range input fires 'input' on native drag
527
+ // (slider) AND on arrow keys (both) -- one path for keyboard on either type.
528
+ if (type === UIType.SLIDER || type === UIType.KNOB) {
436
529
  el.addEventListener('input', () => {
437
530
  state.val = el.value / 100;
438
531
  if (recipe.onDrag) recipe.onDrag(state.val, pointer.vx, state);
439
532
  }, { signal });
440
533
  }
441
534
 
535
+ // KNOB pointer remap (U4a). A range input maps value to horizontal thumb
536
+ // position; a knob maps a rotational or vertical drag instead. Arrow keys
537
+ // stay native (the 'input' handler above); for pointer we drive the value
538
+ // ourselves and preventDefault the native jump-to-pointer, restoring focus
539
+ // by hand. All cold: pointer handlers, no per-frame work.
540
+ if (type === UIType.KNOB) {
541
+ let knobActive = false;
542
+ let knobStartVal = 0;
543
+ let knobStartY = 0;
544
+ el.addEventListener('pointerdown', (e) => {
545
+ knobActive = true;
546
+ knobStartVal = state.val;
547
+ knobStartY = e.clientY;
548
+ refreshRect();
549
+ el.focus();
550
+ e.preventDefault(); // suppress the range's native jump-to-pointer
551
+ if (el.setPointerCapture) el.setPointerCapture(e.pointerId);
552
+ }, { signal });
553
+ el.addEventListener('pointermove', (e) => {
554
+ if (!knobActive) return;
555
+ let v;
556
+ if (knobMode === 'vertical') {
557
+ v = knobStartVal + (knobStartY - e.clientY) / 150; // 150px = full sweep
558
+ } else {
559
+ const cx = rect ? rect.left + rect.width / 2 : e.clientX;
560
+ const cy = rect ? rect.top + rect.height / 2 : e.clientY;
561
+ let a = Math.atan2(e.clientY - cy, e.clientX - cx); // -PI..PI
562
+ a = (a + Math.PI * 2.5) % (Math.PI * 2); // 0 at bottom, clockwise
563
+ v = a / (Math.PI * 2);
564
+ }
565
+ if (v < 0) v = 0; else if (v > 1) v = 1;
566
+ _applyVal(v, true); // reflect + fire onDrag once
567
+ }, { signal });
568
+ el.addEventListener('pointerup', () => { knobActive = false; }, { signal });
569
+ }
570
+
442
571
  // -- DPR re-read on display change (cold, feature-detected). Absent
443
572
  // matchMedia is a silent no-op: the canvas stays at mount DPR (fail
444
573
  // closed, never throw). Listener bound to signal for teardown. --
@@ -497,6 +626,50 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
497
626
  /** Current state (read-only reference). */
498
627
  state,
499
628
 
629
+ /**
630
+ * Programmatically set a valued control (SLIDER/KNOB/PROGRESS) to v in
631
+ * [0,1]: updates the native element, state.val, any PROGRESS announcer,
632
+ * and fires onDrag exactly once (a programmatic write emits no native
633
+ * event, so there is no second fire). For a CHECKBOX, setValue(null)
634
+ * sets the indeterminate state. Fail closed on wrong type / bad value.
635
+ */
636
+ setValue(v) {
637
+ if (destroyed) return;
638
+ if (type === UIType.CHECKBOX) {
639
+ if (v === null) {
640
+ el.indeterminate = true;
641
+ state.indeterminate = true;
642
+ return;
643
+ }
644
+ throw new Error('setValue: a checkbox takes setChecked(bool), or setValue(null) for indeterminate');
645
+ }
646
+ if (type !== UIType.SLIDER && type !== UIType.KNOB && type !== UIType.PROGRESS) {
647
+ throw new Error('setValue: only SLIDER, KNOB, and PROGRESS carry a numeric value');
648
+ }
649
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) {
650
+ throw new Error('setValue: v must be a number in [0,1]');
651
+ }
652
+ _applyVal(v, true);
653
+ },
654
+
655
+ /**
656
+ * Programmatically set a TOGGLE/CHECKBOX checked state: updates the
657
+ * native element, state.toggled, clears indeterminate, and fires
658
+ * onToggle exactly once. Fail closed on the wrong type.
659
+ */
660
+ setChecked(b) {
661
+ if (destroyed) return;
662
+ if (type !== UIType.TOGGLE && type !== UIType.CHECKBOX) {
663
+ throw new Error('setChecked: only TOGGLE and CHECKBOX carry a checked state');
664
+ }
665
+ const nb = !!b;
666
+ el.indeterminate = false;
667
+ el.checked = nb;
668
+ state.indeterminate = false;
669
+ state.toggled = nb;
670
+ if (recipe.onToggle) recipe.onToggle(nb, state);
671
+ },
672
+
500
673
  /** Destroy everything. Idempotent. */
501
674
  destroy() {
502
675
  if (destroyed) return;
@@ -505,7 +678,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
505
678
  removeTick();
506
679
  if (recipe.destroy) recipe.destroy();
507
680
  releaseTicker();
508
- if (type === UIType.SLIDER) releaseSliderStyle();
681
+ if (type === UIType.SLIDER || type === UIType.KNOB) releaseSliderStyle();
509
682
  wrapper.remove();
510
683
  },
511
684
  };
@@ -528,4 +701,327 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
528
701
  }
529
702
  }
530
703
 
704
+ // =========================================================
705
+ // decorateUIFX -- The second mount mode (canvas AROUND a live element)
706
+ // =========================================================
707
+
708
+ /**
709
+ * Decorate an EXISTING visible element with a canvas recipe, WITHOUT hijacking
710
+ * it. Unlike mountUIFX this creates no native element, never sets opacity:0, and
711
+ * never reparents `el`: it adds ONE absolutely-positioned overlay canvas as a
712
+ * sibling in el.parentNode (placed from el's offset box) plus the listeners it
713
+ * owns, and on destroy removes exactly those -- the host is byte-identical to
714
+ * before. Recipe state is wired from el's own events; for a form-control host,
715
+ * state.text and state.valid mirror el.value / el.validity (read at event time,
716
+ * never per frame). This is the honest home for a decoration over a real input
717
+ * (PasswordStrength, TypewriterField) and for generic form feedback (FocusHalo,
718
+ * ErrorShake, SuccessBloom). See decisions/0004.
719
+ *
720
+ * @param {HTMLElement} el The live element to decorate (stays visible).
721
+ * @param {Function} recipeFactory (options) => Recipe object
722
+ * @param {Object} [options] padding, seed, colors, theme, text, font
723
+ * @returns {{ el, canvas, state, setValue, setChecked, destroy }}
724
+ */
725
+ export function decorateUIFX(el, recipeFactory, options = {}) {
726
+ // =====================================================================
727
+ // PHASE 1 -- VALIDATION ONLY. No side effect until every check passes
728
+ // (fail closed, mirrors mountUIFX): no createElement, no insertBefore,
729
+ // no ticker acquire, no recipe.init.
730
+ // =====================================================================
731
+
732
+ // 1. el must be a live, attached DOM element -- we read its offset box and
733
+ // hang the overlay off its parent. A detached el has no parentNode to host
734
+ // the canvas: an Error, never a silent no-op.
735
+ if (!el || typeof el.addEventListener !== 'function' ||
736
+ typeof el.getBoundingClientRect !== 'function') {
737
+ throw new Error('decorateUIFX: el must be a DOM element');
738
+ }
739
+ if (!el.parentNode || typeof el.parentNode.insertBefore !== 'function') {
740
+ throw new Error('decorateUIFX: el must be attached to the DOM (no parentNode to host the overlay)');
741
+ }
742
+
743
+ // 2. options: decorate accepts a subset. A hijack-only key is a mistake, not a
744
+ // silent ignore; a truly unknown key gets a did-you-mean over the decorate
745
+ // set. Both fail closed, before any element exists.
746
+ for (const k in options) {
747
+ if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
748
+ if (DECORATE_OPTIONS.indexOf(k) === -1) {
749
+ if (KNOWN_OPTIONS.indexOf(k) !== -1) {
750
+ throw new Error('decorateUIFX: option "' + k + '" is not valid in decorate mode (hijack-only)');
751
+ }
752
+ throw new Error(_didYouMean('decorateUIFX: unknown option', k, DECORATE_OPTIONS));
753
+ }
754
+ }
755
+ const padding = options.padding === undefined ? 40 : options.padding;
756
+
757
+ // Theming options (decisions/0002): validated fail closed here, forwarded to
758
+ // the recipe factory which resolves them in init. Cold mount code.
759
+ const _theme = options.theme;
760
+ if (_theme !== undefined) {
761
+ if (_theme === null || typeof _theme !== 'object' ||
762
+ typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
763
+ typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
764
+ throw new Error('decorateUIFX: option "theme" must be { light, mid, dark } of color strings');
765
+ }
766
+ }
767
+ const _colors = options.colors;
768
+ if (_colors !== undefined &&
769
+ (!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
770
+ throw new Error('decorateUIFX: option "colors" must be an array of color strings');
771
+ }
772
+ if (options.text !== undefined && typeof options.text !== 'string') {
773
+ throw new Error('decorateUIFX: option "text" must be a string');
774
+ }
775
+ if (options.font !== undefined && typeof options.font !== 'string') {
776
+ throw new Error('decorateUIFX: option "font" must be a string');
777
+ }
778
+ if (options.seed !== undefined &&
779
+ (typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
780
+ throw new Error('decorateUIFX: option "seed" must be a finite number');
781
+ }
782
+
783
+ // 3. recipeFactory + recipe object + hooks (same contract as mountUIFX).
784
+ if (typeof recipeFactory !== 'function') {
785
+ throw new Error('decorateUIFX: recipeFactory must be a function');
786
+ }
787
+ const recipe = recipeFactory(options);
788
+ if (!recipe || typeof recipe !== 'object') {
789
+ throw new Error('decorateUIFX: recipe must be an object');
790
+ }
791
+ if (typeof recipe.tick !== 'function') {
792
+ throw new Error('decorateUIFX: recipe.tick must be a function');
793
+ }
794
+ for (const k in recipe) {
795
+ if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
796
+ if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
797
+ throw new Error(_didYouMean('decorateUIFX: unknown recipe hook', k, KNOWN_HOOKS));
798
+ }
799
+ }
800
+
801
+ // =====================================================================
802
+ // PHASE 2 -- SIDE EFFECTS (fail-closed unwind, mirrors mountUIFX). The
803
+ // only acquisitions are the overlay canvas, the AbortController, and the
804
+ // shared ticker -- unwound in reverse order on any throw.
805
+ // =====================================================================
806
+ let canvasAppended = false;
807
+ let acCreated = false;
808
+ let tickerAcquired = false;
809
+ let canvas = null;
810
+ let ac = null;
811
+ let removeTick = null;
812
+
813
+ try {
814
+ // -- Placement from el's OFFSET box. Because the overlay is a SIBLING of el,
815
+ // they share an offsetParent, so offset-box coords land the canvas over el
816
+ // WITHOUT writing any style onto the parent (decision 2). Read once here,
817
+ // refreshed on resize only. --
818
+ let ow = el.offsetWidth;
819
+ let oh = el.offsetHeight;
820
+ let dpr = window.devicePixelRatio || 1;
821
+ let cw = ow + padding * 2;
822
+ let ch = oh + padding * 2;
823
+
824
+ canvas = document.createElement('canvas');
825
+ canvas.width = cw * dpr;
826
+ canvas.height = ch * dpr;
827
+ Object.assign(canvas.style, {
828
+ position: 'absolute',
829
+ left: (el.offsetLeft - padding) + 'px',
830
+ top: (el.offsetTop - padding) + 'px',
831
+ width: cw + 'px', height: ch + 'px',
832
+ pointerEvents: 'none',
833
+ });
834
+ const ctx = canvas.getContext('2d');
835
+ ctx.scale(dpr, dpr);
836
+
837
+ // Insert the overlay right AFTER el: among auto-z siblings it paints on top,
838
+ // and pointerEvents:none keeps el receiving every event. el is NOT touched --
839
+ // no style write, no reparent (the whole point of decorate mode).
840
+ el.parentNode.insertBefore(canvas, el.nextSibling);
841
+ canvasAppended = true;
842
+
843
+ // -- State. Generic fields wire like hijack mode; text/valid mirror the host,
844
+ // read now at init (law: hook initial values from the element) and refreshed
845
+ // at event time only. --
846
+ const state = {
847
+ hover: false,
848
+ active: false,
849
+ focused: (typeof document !== 'undefined' && document.activeElement === el),
850
+ text: (typeof el.value === 'string' ? el.value : ''),
851
+ valid: (el.validity ? !!el.validity.valid : true),
852
+ w: ow, h: oh, padding, dpr,
853
+ };
854
+ const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
855
+ // Cached rect for pointer math (U-11): filled lazily, refreshed on enter/
856
+ // scroll/resize; pointermove does ZERO layout reads at steady state.
857
+ let rect = null;
858
+
859
+ // -- Initialize recipe (validated in phase 1). ctx exists now. --
860
+ if (recipe.init) recipe.init(ctx, ow, oh, padding);
861
+
862
+ // -- Events (all via AbortController: destroy()'s abort removes exactly what
863
+ // decorate added and nothing the host owned). --
864
+ ac = new AbortController();
865
+ acCreated = true;
866
+ const signal = ac.signal;
867
+
868
+ function updatePointer(e) {
869
+ if (!rect) rect = el.getBoundingClientRect();
870
+ const nx = e.clientX - rect.left;
871
+ const ny = e.clientY - rect.top;
872
+ pointer.vx = nx - pointer.x;
873
+ pointer.vy = ny - pointer.y;
874
+ pointer.x = nx;
875
+ pointer.y = ny;
876
+ }
877
+ function refreshRect() { rect = el.getBoundingClientRect(); }
878
+ // Reposition the overlay from the offset box after a layout change (cold path).
879
+ // All layout READS are hoisted above the style WRITES: writing canvas.style
880
+ // dirties layout, so reading el.offset* afterwards would force a synchronous
881
+ // reflow. A decoration over live DOM is the one place this package can force
882
+ // layout (see the U4b brief HOT PATH note), so keep read-before-write even here.
883
+ function reposition() {
884
+ const nw = el.offsetWidth;
885
+ const nh = el.offsetHeight;
886
+ const ol = el.offsetLeft;
887
+ const ot = el.offsetTop;
888
+ canvas.style.left = (ol - padding) + 'px';
889
+ canvas.style.top = (ot - padding) + 'px';
890
+ if (nw !== ow || nh !== oh) {
891
+ ow = nw; oh = nh;
892
+ cw = ow + padding * 2;
893
+ ch = oh + padding * 2;
894
+ canvas.width = cw * dpr;
895
+ canvas.height = ch * dpr;
896
+ canvas.style.width = cw + 'px';
897
+ canvas.style.height = ch + 'px';
898
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
899
+ state.w = ow; state.h = oh;
900
+ }
901
+ }
902
+
903
+ window.addEventListener('scroll', refreshRect, { passive: true, signal });
904
+ window.addEventListener('resize', () => { reposition(); refreshRect(); }, { passive: true, signal });
905
+
906
+ el.addEventListener('pointermove', updatePointer, { signal });
907
+ el.addEventListener('pointerenter', (e) => {
908
+ state.hover = true;
909
+ refreshRect();
910
+ updatePointer(e);
911
+ if (recipe.onHover) recipe.onHover(state, pointer);
912
+ }, { signal });
913
+ el.addEventListener('pointerleave', () => {
914
+ state.hover = false;
915
+ if (recipe.onLeave) recipe.onLeave(state, pointer);
916
+ }, { signal });
917
+ el.addEventListener('pointerdown', (e) => {
918
+ state.active = true;
919
+ updatePointer(e);
920
+ if (recipe.onClick) recipe.onClick(pointer.x, pointer.y, state);
921
+ }, { signal });
922
+ el.addEventListener('pointerup', () => { state.active = false; }, { signal });
923
+
924
+ el.addEventListener('focus', () => { state.focused = true; }, { signal });
925
+ el.addEventListener('blur', () => { state.focused = false; }, { signal });
926
+
927
+ // Host content -> state, at EVENT time only (el.value getter allocates a
928
+ // string; keep it off the frame path). A non-form host never fires these.
929
+ function syncHostValue() {
930
+ state.text = (typeof el.value === 'string' ? el.value : '');
931
+ state.valid = (el.validity ? !!el.validity.valid : true);
932
+ }
933
+ el.addEventListener('input', syncHostValue, { signal });
934
+ el.addEventListener('change', syncHostValue, { signal });
935
+ el.addEventListener('invalid', () => { state.valid = false; }, { signal });
936
+
937
+ // -- DPR re-read on display change (cold, feature-detected; absent matchMedia
938
+ // is a silent no-op -- fail closed, never throw). --
939
+ if (typeof window.matchMedia === 'function') {
940
+ const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
941
+ mq.addEventListener('change', () => {
942
+ const nd = window.devicePixelRatio || 1;
943
+ dpr = nd;
944
+ canvas.width = cw * nd;
945
+ canvas.height = ch * nd;
946
+ ctx.setTransform(nd, 0, 0, nd, 0, 0);
947
+ state.dpr = nd;
948
+ }, { signal });
949
+ }
950
+
951
+ // -- Render loop (shared ticker; same quarantine-on-throw as mountUIFX). --
952
+ const ticker = acquireTicker();
953
+ tickerAcquired = true;
954
+ let destroyed = false;
955
+ let quarantined = false;
956
+
957
+ removeTick = ticker.add((dtMs) => {
958
+ if (destroyed || quarantined) return;
959
+ const dt = dtMs / 1000;
960
+ const now = performance.now();
961
+
962
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
963
+ ctx.clearRect(0, 0, cw, ch);
964
+ ctx.save();
965
+ ctx.translate(padding, padding); // Origin = the host element's top-left
966
+ try {
967
+ recipe.tick(ctx, dt, now, state, pointer);
968
+ } catch (err) {
969
+ quarantined = true;
970
+ console.error('decorateUIFX: recipe.tick threw; decoration quarantined', err);
971
+ ctx.restore();
972
+ ctx.clearRect(0, 0, cw, ch);
973
+ return;
974
+ }
975
+ ctx.restore();
976
+ });
977
+
978
+ // -- Public API --
979
+ return {
980
+ /** The decorated host element (unchanged; provided for external reads). */
981
+ el,
982
+
983
+ /** The overlay canvas (for external styling). */
984
+ canvas,
985
+
986
+ /** Current state (read-only reference). */
987
+ state,
988
+
989
+ /**
990
+ * Hijack-only. A decoration reflects the host; it does not own or push
991
+ * into the host's value, so setValue/setChecked fail closed here (use the
992
+ * host's own API to change it -- the decoration follows via its events).
993
+ */
994
+ setValue() {
995
+ throw new Error('decorateUIFX: setValue is hijack-only; a decoration reflects the host, it does not drive it');
996
+ },
997
+ setChecked() {
998
+ throw new Error('decorateUIFX: setChecked is hijack-only; a decoration reflects the host, it does not drive it');
999
+ },
1000
+
1001
+ /** Destroy: remove the overlay + every listener decorate added. Idempotent.
1002
+ * The host element is byte-identical to before decorate (never touched). */
1003
+ destroy() {
1004
+ if (destroyed) return;
1005
+ destroyed = true;
1006
+ ac.abort();
1007
+ removeTick();
1008
+ if (recipe.destroy) recipe.destroy();
1009
+ releaseTicker();
1010
+ canvas.remove(); // the ONLY DOM node decorate added
1011
+ },
1012
+ };
1013
+ } catch (err) {
1014
+ // A phase-2 step threw (realistically recipe.init). Unwind ONLY what was
1015
+ // acquired, reverse order, each flag-guarded. recipe.destroy is NOT called
1016
+ // (init did not succeed). Re-throw the ORIGINAL error, unwrapped.
1017
+ if (tickerAcquired) {
1018
+ if (removeTick) removeTick();
1019
+ releaseTicker();
1020
+ }
1021
+ if (acCreated) ac.abort();
1022
+ if (canvasAppended) canvas.remove();
1023
+ throw err;
1024
+ }
1025
+ }
1026
+
531
1027
  export default mountUIFX;