pulse-js-framework 1.9.2 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -110,6 +110,26 @@
110
110
  "./server/hono": "./server/hono.js",
111
111
  "./server/fastify": "./server/fastify.js",
112
112
  "./server/utils": "./server/utils.js",
113
+ "./runtime/sse": {
114
+ "types": "./types/sse.d.ts",
115
+ "default": "./runtime/sse.js"
116
+ },
117
+ "./runtime/persistence": {
118
+ "types": "./types/persistence.d.ts",
119
+ "default": "./runtime/persistence.js"
120
+ },
121
+ "./runtime/animation": {
122
+ "types": "./types/animation.d.ts",
123
+ "default": "./runtime/animation.js"
124
+ },
125
+ "./runtime/i18n": {
126
+ "types": "./types/i18n.d.ts",
127
+ "default": "./runtime/i18n.js"
128
+ },
129
+ "./sw": {
130
+ "types": "./types/sw.d.ts",
131
+ "default": "./sw/index.js"
132
+ },
113
133
  "./package.json": "./package.json"
114
134
  },
115
135
  "files": [
@@ -121,6 +141,7 @@
121
141
  "loader/",
122
142
  "mobile/",
123
143
  "server/",
144
+ "sw/",
124
145
  "types/",
125
146
  "README.md",
126
147
  "LICENSE"
@@ -198,6 +219,12 @@
198
219
  "test:interceptor-manager": "node test/interceptor-manager.test.js",
199
220
  "test:vite-plugin": "node --test test/vite-plugin.test.js",
200
221
  "test:memory-cleanup": "node --test test/memory-cleanup.test.js",
222
+ "test:sse": "node --test test/sse.test.js",
223
+ "test:persistence": "node --test test/persistence.test.js",
224
+ "test:i18n": "node --test test/i18n.test.js",
225
+ "test:portal": "node --test test/portal.test.js",
226
+ "test:animation": "node --test test/animation.test.js",
227
+ "test:sw": "node --test test/sw.test.js",
201
228
  "test:dev-server": "node --test test/dev-server.test.js",
202
229
  "test:ssr-stream": "node --test test/ssr-stream.test.js",
203
230
  "test:ssr-mismatch": "node --test test/ssr-mismatch.test.js",
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Pulse Animation Module
3
+ * Web Animations API wrapper with Pulse reactivity and reduced motion support.
4
+ *
5
+ * @module pulse-js-framework/runtime/animation
6
+ */
7
+
8
+ import { pulse, computed, effect, onCleanup } from './pulse.js';
9
+ import { loggers } from './logger.js';
10
+ import { getAdapter, MockDOMAdapter } from './dom-adapter.js';
11
+ import { prefersReducedMotion } from './a11y.js';
12
+
13
+ const log = loggers.dom;
14
+
15
+ // =============================================================================
16
+ // CONFIGURATION
17
+ // =============================================================================
18
+
19
+ const _config = {
20
+ respectReducedMotion: true,
21
+ defaultDuration: 300,
22
+ defaultEasing: 'ease-out',
23
+ disabled: false,
24
+ };
25
+
26
+ /**
27
+ * Configure global animation settings
28
+ *
29
+ * @param {Object} options
30
+ * @param {boolean} [options.respectReducedMotion=true] - Respect prefers-reduced-motion
31
+ * @param {number} [options.defaultDuration=300] - Default animation duration (ms)
32
+ * @param {string} [options.defaultEasing='ease-out'] - Default easing function
33
+ * @param {boolean} [options.disabled=false] - Kill switch to disable all animations
34
+ */
35
+ export function configureAnimations(options = {}) {
36
+ if (options.respectReducedMotion !== undefined) _config.respectReducedMotion = options.respectReducedMotion;
37
+ if (options.defaultDuration !== undefined) _config.defaultDuration = options.defaultDuration;
38
+ if (options.defaultEasing !== undefined) _config.defaultEasing = options.defaultEasing;
39
+ if (options.disabled !== undefined) _config.disabled = options.disabled;
40
+ }
41
+
42
+ // =============================================================================
43
+ // INTERNAL HELPERS
44
+ // =============================================================================
45
+
46
+ function _isSSR() {
47
+ const adapter = getAdapter();
48
+ return adapter instanceof MockDOMAdapter;
49
+ }
50
+
51
+ function _shouldAnimate() {
52
+ if (_config.disabled) return false;
53
+ if (_isSSR()) return false;
54
+ if (_config.respectReducedMotion && prefersReducedMotion()) return false;
55
+ return true;
56
+ }
57
+
58
+ function _getEffectiveDuration(duration) {
59
+ if (!_shouldAnimate()) return 0;
60
+ return duration ?? _config.defaultDuration;
61
+ }
62
+
63
+ // =============================================================================
64
+ // animate()
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Animate an element using the Web Animations API
69
+ *
70
+ * @param {HTMLElement} element - Element to animate
71
+ * @param {Array<Object>|Object} keyframes - Keyframes (WAAPI format)
72
+ * @param {Object} [options] - Animation options
73
+ * @param {number} [options.duration] - Duration in ms (default: configureAnimations default)
74
+ * @param {string} [options.easing] - Easing function
75
+ * @param {string} [options.fill='none'] - Fill mode
76
+ * @param {number} [options.delay=0] - Delay in ms
77
+ * @param {number} [options.iterations=1] - Number of iterations
78
+ * @param {string} [options.direction='normal'] - Direction
79
+ * @returns {Object} AnimationControl with reactive state
80
+ */
81
+ export function animate(element, keyframes, options = {}) {
82
+ const isPlaying = pulse(false);
83
+ const progress = pulse(0);
84
+
85
+ // SSR or disabled: return no-op control
86
+ if (!_shouldAnimate() || !element || typeof element.animate !== 'function') {
87
+ return _createNoopControl(isPlaying, progress);
88
+ }
89
+
90
+ const duration = _getEffectiveDuration(options.duration);
91
+ const animationOptions = {
92
+ duration,
93
+ easing: options.easing ?? _config.defaultEasing,
94
+ fill: options.fill ?? 'none',
95
+ delay: options.delay ?? 0,
96
+ iterations: options.iterations ?? 1,
97
+ direction: options.direction ?? 'normal',
98
+ };
99
+
100
+ let animation = null;
101
+ let rafId = null;
102
+
103
+ try {
104
+ animation = element.animate(keyframes, animationOptions);
105
+ } catch (e) {
106
+ log.warn('Animation failed:', e.message);
107
+ return _createNoopControl(isPlaying, progress);
108
+ }
109
+
110
+ // Track playing state
111
+ isPlaying.set(true);
112
+
113
+ function _updateProgress() {
114
+ if (!animation) return;
115
+ const timing = animation.effect?.getComputedTiming?.();
116
+ if (timing) {
117
+ progress.set(typeof timing.progress === 'number' ? timing.progress : 0);
118
+ }
119
+ if (animation.playState === 'running') {
120
+ rafId = requestAnimationFrame(_updateProgress);
121
+ }
122
+ }
123
+
124
+ rafId = requestAnimationFrame(_updateProgress);
125
+
126
+ const finishedPromise = animation.finished
127
+ .then(() => {
128
+ isPlaying.set(false);
129
+ progress.set(1);
130
+ })
131
+ .catch(() => {
132
+ // Animation was cancelled
133
+ isPlaying.set(false);
134
+ });
135
+
136
+ animation.addEventListener('cancel', () => {
137
+ isPlaying.set(false);
138
+ if (rafId) {
139
+ cancelAnimationFrame(rafId);
140
+ rafId = null;
141
+ }
142
+ });
143
+
144
+ function _cleanup() {
145
+ if (rafId) {
146
+ cancelAnimationFrame(rafId);
147
+ rafId = null;
148
+ }
149
+ }
150
+
151
+ return {
152
+ isPlaying,
153
+ progress,
154
+ finished: finishedPromise,
155
+
156
+ play() {
157
+ if (animation) {
158
+ animation.play();
159
+ isPlaying.set(true);
160
+ rafId = requestAnimationFrame(_updateProgress);
161
+ }
162
+ },
163
+
164
+ pause() {
165
+ if (animation) {
166
+ animation.pause();
167
+ isPlaying.set(false);
168
+ _cleanup();
169
+ }
170
+ },
171
+
172
+ reverse() {
173
+ if (animation) {
174
+ animation.reverse();
175
+ isPlaying.set(true);
176
+ rafId = requestAnimationFrame(_updateProgress);
177
+ }
178
+ },
179
+
180
+ cancel() {
181
+ if (animation) {
182
+ animation.cancel();
183
+ isPlaying.set(false);
184
+ progress.set(0);
185
+ _cleanup();
186
+ }
187
+ },
188
+
189
+ finish() {
190
+ if (animation) {
191
+ animation.finish();
192
+ isPlaying.set(false);
193
+ progress.set(1);
194
+ _cleanup();
195
+ }
196
+ },
197
+
198
+ dispose() {
199
+ _cleanup();
200
+ if (animation) {
201
+ try { animation.cancel(); } catch { /* already finished */ }
202
+ }
203
+ isPlaying.set(false);
204
+ },
205
+ };
206
+ }
207
+
208
+ function _createNoopControl(isPlaying, progress) {
209
+ return {
210
+ isPlaying,
211
+ progress,
212
+ finished: Promise.resolve(),
213
+ play() {},
214
+ pause() {},
215
+ reverse() {},
216
+ cancel() {},
217
+ finish() { progress.set(1); },
218
+ dispose() {},
219
+ };
220
+ }
221
+
222
+ // =============================================================================
223
+ // useTransition()
224
+ // =============================================================================
225
+
226
+ /**
227
+ * Reactive enter/leave transition hook
228
+ *
229
+ * @param {Function} condition - Reactive condition function
230
+ * @param {Object} options - Transition options
231
+ * @param {Object} [options.enter] - Enter keyframes
232
+ * @param {Object} [options.leave] - Leave keyframes
233
+ * @param {number} [options.duration] - Duration in ms
234
+ * @param {string} [options.easing] - Easing function
235
+ * @param {Function} [options.onEnter] - Template factory for entering content
236
+ * @param {Function} [options.onLeave] - Template factory for leaving content (optional)
237
+ * @returns {Object} { nodes, isEntering, isLeaving }
238
+ */
239
+ export function useTransition(condition, options = {}) {
240
+ const dom = getAdapter();
241
+
242
+ const {
243
+ enter = { opacity: [0, 1] },
244
+ leave = { opacity: [1, 0] },
245
+ duration,
246
+ easing,
247
+ onEnter = null,
248
+ onLeave = null,
249
+ } = options;
250
+
251
+ const isEntering = pulse(false);
252
+ const isLeaving = pulse(false);
253
+
254
+ const container = dom.createDocumentFragment();
255
+ const marker = dom.createComment('transition');
256
+ dom.appendChild(container, marker);
257
+
258
+ let currentNodes = [];
259
+ let activeAnimation = null;
260
+
261
+ function _removeCurrentNodes() {
262
+ for (const node of currentNodes) {
263
+ dom.removeNode(node);
264
+ if (node._pulseUnmount) {
265
+ for (const cb of node._pulseUnmount) cb();
266
+ }
267
+ }
268
+ currentNodes = [];
269
+ }
270
+
271
+ function _insertNodes(nodes) {
272
+ const fragment = dom.createDocumentFragment();
273
+ for (const node of nodes) {
274
+ if (dom.isNode(node)) {
275
+ dom.appendChild(fragment, node);
276
+ currentNodes.push(node);
277
+ }
278
+ }
279
+ const markerParent = dom.getParentNode(marker);
280
+ if (markerParent) {
281
+ dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
282
+ }
283
+ }
284
+
285
+ effect(() => {
286
+ const show = typeof condition === 'function' ? condition() : condition;
287
+
288
+ if (show) {
289
+ // Enter
290
+ if (activeAnimation) {
291
+ activeAnimation.dispose();
292
+ activeAnimation = null;
293
+ }
294
+
295
+ // If we have leave content, animate it out first
296
+ if (currentNodes.length > 0 && _shouldAnimate()) {
297
+ isLeaving.set(true);
298
+ const nodesToRemove = [...currentNodes];
299
+ currentNodes = [];
300
+
301
+ // Animate leave on old nodes
302
+ const leaveAnims = nodesToRemove
303
+ .filter(n => typeof n.animate === 'function')
304
+ .map(n => animate(n, leave, { duration, easing }));
305
+
306
+ if (leaveAnims.length > 0) {
307
+ Promise.all(leaveAnims.map(a => a.finished)).then(() => {
308
+ for (const n of nodesToRemove) dom.removeNode(n);
309
+ isLeaving.set(false);
310
+ _enterContent();
311
+ });
312
+ return;
313
+ } else {
314
+ for (const n of nodesToRemove) dom.removeNode(n);
315
+ isLeaving.set(false);
316
+ }
317
+ } else {
318
+ _removeCurrentNodes();
319
+ }
320
+
321
+ _enterContent();
322
+ } else {
323
+ // Leave
324
+ if (activeAnimation) {
325
+ activeAnimation.dispose();
326
+ activeAnimation = null;
327
+ }
328
+
329
+ if (currentNodes.length > 0) {
330
+ isLeaving.set(true);
331
+ const nodesToRemove = [...currentNodes];
332
+ currentNodes = [];
333
+
334
+ if (_shouldAnimate()) {
335
+ const leaveAnims = nodesToRemove
336
+ .filter(n => typeof n.animate === 'function')
337
+ .map(n => animate(n, leave, { duration, easing }));
338
+
339
+ if (leaveAnims.length > 0) {
340
+ Promise.all(leaveAnims.map(a => a.finished)).then(() => {
341
+ for (const n of nodesToRemove) dom.removeNode(n);
342
+ isLeaving.set(false);
343
+ });
344
+ return;
345
+ }
346
+ }
347
+
348
+ for (const n of nodesToRemove) dom.removeNode(n);
349
+ isLeaving.set(false);
350
+ }
351
+ }
352
+ });
353
+
354
+ function _enterContent() {
355
+ if (!onEnter) return;
356
+
357
+ const result = onEnter();
358
+ if (!result) return;
359
+
360
+ const nodes = Array.isArray(result) ? result : [result];
361
+ _insertNodes(nodes);
362
+
363
+ if (_shouldAnimate()) {
364
+ isEntering.set(true);
365
+ const enterAnims = currentNodes
366
+ .filter(n => typeof n.animate === 'function')
367
+ .map(n => animate(n, enter, { duration, easing }));
368
+
369
+ if (enterAnims.length > 0) {
370
+ Promise.all(enterAnims.map(a => a.finished)).then(() => {
371
+ isEntering.set(false);
372
+ });
373
+ } else {
374
+ isEntering.set(false);
375
+ }
376
+ }
377
+ }
378
+
379
+ onCleanup(() => {
380
+ if (activeAnimation) activeAnimation.dispose();
381
+ _removeCurrentNodes();
382
+ });
383
+
384
+ return {
385
+ container,
386
+ isEntering,
387
+ isLeaving,
388
+ };
389
+ }
390
+
391
+ // =============================================================================
392
+ // useSpring()
393
+ // =============================================================================
394
+
395
+ /**
396
+ * Spring-based animation using damped harmonic oscillator
397
+ *
398
+ * @param {number|Function} target - Target value (or reactive function)
399
+ * @param {Object} [options]
400
+ * @param {number} [options.stiffness=170] - Spring stiffness
401
+ * @param {number} [options.damping=26] - Damping coefficient
402
+ * @param {number} [options.mass=1] - Mass
403
+ * @param {number} [options.precision=0.01] - Settle precision
404
+ * @returns {Object} { value, isAnimating, set, dispose }
405
+ */
406
+ export function useSpring(target, options = {}) {
407
+ const {
408
+ stiffness = 170,
409
+ damping = 26,
410
+ mass = 1,
411
+ precision = 0.01,
412
+ } = options;
413
+
414
+ const initial = typeof target === 'function' ? target() : target;
415
+ const value = pulse(initial);
416
+ const isAnimating = pulse(false);
417
+
418
+ let currentValue = initial;
419
+ let velocity = 0;
420
+ let targetValue = initial;
421
+ let rafId = null;
422
+ let lastTime = 0;
423
+
424
+ function _step(time) {
425
+ if (!lastTime) {
426
+ lastTime = time;
427
+ rafId = requestAnimationFrame(_step);
428
+ return;
429
+ }
430
+
431
+ const dt = Math.min((time - lastTime) / 1000, 0.064); // Cap at ~16fps minimum
432
+ lastTime = time;
433
+
434
+ // Damped spring: F = -kx - cv
435
+ const displacement = currentValue - targetValue;
436
+ const springForce = -stiffness * displacement;
437
+ const dampingForce = -damping * velocity;
438
+ const acceleration = (springForce + dampingForce) / mass;
439
+
440
+ velocity += acceleration * dt;
441
+ currentValue += velocity * dt;
442
+
443
+ // Check if settled
444
+ if (Math.abs(velocity) < precision && Math.abs(displacement) < precision) {
445
+ currentValue = targetValue;
446
+ velocity = 0;
447
+ value.set(currentValue);
448
+ isAnimating.set(false);
449
+ rafId = null;
450
+ return;
451
+ }
452
+
453
+ value.set(currentValue);
454
+ rafId = requestAnimationFrame(_step);
455
+ }
456
+
457
+ function _startAnimation() {
458
+ if (!_shouldAnimate()) {
459
+ currentValue = targetValue;
460
+ value.set(currentValue);
461
+ return;
462
+ }
463
+
464
+ isAnimating.set(true);
465
+ lastTime = 0;
466
+ if (rafId) cancelAnimationFrame(rafId);
467
+ rafId = requestAnimationFrame(_step);
468
+ }
469
+
470
+ // Watch target changes if reactive
471
+ if (typeof target === 'function') {
472
+ effect(() => {
473
+ const newTarget = target();
474
+ if (newTarget !== targetValue) {
475
+ targetValue = newTarget;
476
+ _startAnimation();
477
+ }
478
+ });
479
+ }
480
+
481
+ function set(newTarget) {
482
+ targetValue = newTarget;
483
+ _startAnimation();
484
+ }
485
+
486
+ function dispose() {
487
+ if (rafId) {
488
+ cancelAnimationFrame(rafId);
489
+ rafId = null;
490
+ }
491
+ isAnimating.set(false);
492
+ }
493
+
494
+ onCleanup(dispose);
495
+
496
+ return { value, isAnimating, set, dispose };
497
+ }
498
+
499
+ // =============================================================================
500
+ // stagger()
501
+ // =============================================================================
502
+
503
+ /**
504
+ * Stagger animation across multiple elements
505
+ *
506
+ * @param {Array<HTMLElement>} elements - Elements to animate
507
+ * @param {Array<Object>|Object} keyframes - Keyframes
508
+ * @param {Object} [options]
509
+ * @param {number} [options.duration] - Duration per element
510
+ * @param {number} [options.staggerDelay=50] - Delay between each element
511
+ * @param {string} [options.easing] - Easing function
512
+ * @returns {Array<Object>} Array of AnimationControl objects
513
+ */
514
+ export function stagger(elements, keyframes, options = {}) {
515
+ const { staggerDelay = 50, ...animOptions } = options;
516
+
517
+ return elements.map((element, index) => {
518
+ return animate(element, keyframes, {
519
+ ...animOptions,
520
+ delay: (animOptions.delay ?? 0) + index * staggerDelay,
521
+ });
522
+ });
523
+ }
524
+
525
+ // =============================================================================
526
+ // DEFAULT EXPORT
527
+ // =============================================================================
528
+
529
+ export default {
530
+ animate,
531
+ useTransition,
532
+ useSpring,
533
+ stagger,
534
+ configureAnimations,
535
+ };