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