@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/CHANGELOG.md +86 -0
- package/README.md +45 -7
- package/UIFX-RECIPE-GUIDE.md +91 -3
- package/UIFXController.d.ts +99 -1
- package/UIFXController.js +481 -13
- package/UIFXRecipes.d.ts +27 -4
- package/UIFXRecipes.js +239 -44
- package/llms.txt +68 -11
- package/package.json +1 -1
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.
|
|
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
|
-
// --
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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 (
|
|
687
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
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;
|