rune-scroller 0.1.11 → 2.0.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.
Files changed (51) hide show
  1. package/README.md +195 -29
  2. package/dist/__mocks__/IntersectionObserver.d.ts +25 -0
  3. package/dist/__mocks__/IntersectionObserver.js +116 -0
  4. package/dist/__mocks__/svelte-runes.d.ts +25 -0
  5. package/dist/__mocks__/svelte-runes.js +117 -0
  6. package/dist/__test-helpers__/dom.d.ts +118 -0
  7. package/dist/__test-helpers__/dom.js +305 -0
  8. package/dist/animate.d.ts +4 -0
  9. package/dist/animate.js +152 -0
  10. package/dist/animate.test.js +370 -0
  11. package/dist/animations.comprehensive.test.d.ts +1 -0
  12. package/dist/animations.comprehensive.test.js +432 -0
  13. package/dist/animations.css +21 -12
  14. package/dist/animations.d.ts +12 -9
  15. package/dist/animations.js +31 -6
  16. package/dist/animations.test.js +23 -41
  17. package/dist/dom-utils.d.ts +40 -0
  18. package/dist/dom-utils.js +111 -0
  19. package/dist/dom-utils.test.d.ts +1 -0
  20. package/dist/dom-utils.test.js +220 -0
  21. package/dist/index.d.ts +6 -6
  22. package/dist/index.js +17 -4
  23. package/dist/observer-utils.d.ts +40 -0
  24. package/dist/observer-utils.js +50 -0
  25. package/dist/robustness.test.d.ts +1 -0
  26. package/dist/robustness.test.js +317 -0
  27. package/dist/runeScroller.d.ts +25 -0
  28. package/dist/runeScroller.integration.test.d.ts +1 -0
  29. package/dist/runeScroller.integration.test.js +419 -0
  30. package/dist/runeScroller.js +183 -0
  31. package/dist/runeScroller.test.d.ts +1 -0
  32. package/dist/runeScroller.test.js +375 -0
  33. package/dist/types.d.ts +104 -24
  34. package/dist/types.js +58 -0
  35. package/dist/useIntersection.svelte.d.ts +7 -12
  36. package/dist/useIntersection.svelte.js +75 -54
  37. package/dist/useIntersection.test.d.ts +1 -0
  38. package/dist/useIntersection.test.js +98 -0
  39. package/package.json +19 -18
  40. package/dist/BaseAnimated.svelte +0 -48
  41. package/dist/BaseAnimated.svelte.d.ts +0 -16
  42. package/dist/RuneScroller.svelte +0 -37
  43. package/dist/RuneScroller.svelte.d.ts +0 -16
  44. package/dist/animate.svelte.d.ts +0 -14
  45. package/dist/animate.svelte.js +0 -79
  46. package/dist/dom-utils.svelte.d.ts +0 -22
  47. package/dist/dom-utils.svelte.js +0 -46
  48. package/dist/runeScroller.svelte.d.ts +0 -24
  49. package/dist/runeScroller.svelte.js +0 -79
  50. package/dist/scroll-animate.test.js +0 -57
  51. /package/dist/{scroll-animate.test.d.ts → animate.test.d.ts} +0 -0
package/README.md CHANGED
@@ -1,4 +1,12 @@
1
- # ⚡ Rune Scroller
1
+ # ⚡ Rune Scroller - Full Reference
2
+
3
+ **📚 Complete API Reference** — Detailed documentation for all features and options.
4
+
5
+ **Quick start?** See [README.md](../README.md) for a simpler introduction.
6
+
7
+ **Development?** See [CLAUDE.md](../CLAUDE.md) for the developer guide.
8
+
9
+ ---
2
10
 
3
11
  <div align="center">
4
12
  <img src="./logo.png" alt="Rune Scroller Logo" width="200" />
@@ -13,13 +21,28 @@
13
21
 
14
22
  ## ✨ Features
15
23
 
16
- - **~2KB gzipped** - Minimal overhead
24
+ - **~3.4KB gzipped** (11.5KB uncompressed) - Minimal overhead
17
25
  - **Zero dependencies** - Pure Svelte 5 + IntersectionObserver
18
26
  - **14 animations** - Fade, Zoom, Flip, Slide, Bounce variants
19
- - **TypeScript** - Full type coverage
27
+ - **Full TypeScript support** - Type definitions generated from JSDoc
20
28
  - **SSR-ready** - SvelteKit compatible
21
29
  - **GPU-accelerated** - Pure CSS transforms
22
30
  - **Accessible** - Respects `prefers-reduced-motion`
31
+ - **v2.0.0 New** - `onVisible` callback, ResizeObserver support, animation validation, sentinel customization
32
+ - **✨ Latest** - `useIntersection` migrated to Svelte 5 `$effect` rune for better lifecycle management
33
+
34
+ ---
35
+
36
+ ## 📊 Performance & Quality
37
+
38
+ **Recent Optimization (2026-01-06):**
39
+ - ✅ **278/278 tests passing** (100%)
40
+ - ✅ **Bundle size:** 10.5KiB gzipped (stable, no regression)
41
+ - ✅ **Type safety:** 0 errors (JSDoc + TypeScript)
42
+ - ✅ **Memory leaks:** 0 detected
43
+ - ✅ **Svelte 5 aligned:** Full runes support
44
+
45
+ See [`MIGRATION_METRICS.md`](../MIGRATION_METRICS.md) for detailed performance benchmarks.
23
46
 
24
47
  ---
25
48
 
@@ -37,11 +60,37 @@ yarn add rune-scroller
37
60
 
38
61
  ## 🚀 Quick Start
39
62
 
63
+ ### Step 1: Import CSS (required)
64
+
65
+ **⚠️ Important:** You must import the CSS file once in your app.
66
+
67
+ **Option A - In your root layout (recommended for SvelteKit):**
68
+
69
+ ```svelte
70
+ <!-- src/routes/+layout.svelte -->
71
+ <script>
72
+ import 'rune-scroller/animations.css';
73
+ </script>
74
+
75
+ <slot />
76
+ ```
77
+
78
+ **Option B - In each component that uses animations:**
79
+
40
80
  ```svelte
41
81
  <script>
42
82
  import runeScroller from 'rune-scroller';
43
83
  import 'rune-scroller/animations.css';
44
84
  </script>
85
+ ```
86
+
87
+ ### Step 2: Use the animations
88
+
89
+ ```svelte
90
+ <script>
91
+ import runeScroller from 'rune-scroller';
92
+ // CSS already imported in layout or above
93
+ </script>
45
94
 
46
95
  <!-- Simple animation -->
47
96
  <div use:runeScroller={{ animation: 'fade-in' }}>
@@ -65,17 +114,17 @@ yarn add rune-scroller
65
114
 
66
115
  ### Fade (5)
67
116
  - `fade-in` - Simple opacity fade
68
- - `fade-in-up` - Fade + move up 100px
69
- - `fade-in-down` - Fade + move down 100px
70
- - `fade-in-left` - Fade + move from right
71
- - `fade-in-right` - Fade + move from left
117
+ - `fade-in-up` - Fade + move up 300px
118
+ - `fade-in-down` - Fade + move down 300px
119
+ - `fade-in-left` - Fade + move from right 300px
120
+ - `fade-in-right` - Fade + move from left 300px
72
121
 
73
122
  ### Zoom (5)
74
- - `zoom-in` - Scale from 0.6 to 1
75
- - `zoom-out` - Scale from 1.2 to 1
76
- - `zoom-in-up` - Zoom + move up
77
- - `zoom-in-left` - Zoom + move from right
78
- - `zoom-in-right` - Zoom + move from left
123
+ - `zoom-in` - Scale from 0.3 to 1
124
+ - `zoom-out` - Scale from 2 to 1
125
+ - `zoom-in-up` - Zoom (0.5→1) + move up 300px
126
+ - `zoom-in-left` - Zoom (0.5→1) + move from right 300px
127
+ - `zoom-in-right` - Zoom (0.5→1) + move from left 300px
79
128
 
80
129
  ### Others (4)
81
130
  - `flip` - 3D flip on Y-axis
@@ -90,12 +139,27 @@ yarn add rune-scroller
90
139
  ```typescript
91
140
  interface RuneScrollerOptions {
92
141
  animation?: AnimationType; // Animation name (default: 'fade-in')
93
- duration?: number; // Duration in ms (default: 2000)
142
+ duration?: number; // Duration in ms (default: 800)
94
143
  repeat?: boolean; // Repeat on scroll (default: false)
95
- debug?: boolean; // Show sentinel element (default: false)
144
+ debug?: boolean; // Show sentinel as visible line (default: false)
145
+ offset?: number; // Sentinel offset in px (default: 0, negative = above)
146
+ onVisible?: (element: HTMLElement) => void; // Callback when animation triggers (v2.0.0+)
147
+ sentinelColor?: string; // Sentinel debug color, e.g. '#ff6b6b' (v2.0.0+)
148
+ sentinelId?: string; // Custom ID for sentinel identification (v2.0.0+)
96
149
  }
97
150
  ```
98
151
 
152
+ ### Option Details
153
+
154
+ - **`animation`** - Type of animation to play. Choose from 14 built-in animations listed above. Invalid animations automatically fallback to 'fade-in' with a console warning.
155
+ - **`duration`** - How long the animation lasts in milliseconds (default: 800ms).
156
+ - **`repeat`** - If `true`, animation plays every time sentinel enters viewport. If `false`, plays only once.
157
+ - **`debug`** - If `true`, displays the sentinel element as a visible line below your element. Useful for seeing exactly when animations trigger. Default color is cyan (#00e0ff), customize with `sentinelColor`.
158
+ - **`offset`** - Offset of the sentinel in pixels. Positive values move sentinel down (delays animation), negative values move it up (triggers earlier). Useful for large elements where you want animation to trigger before the entire element is visible.
159
+ - **`onVisible`** *(v2.0.0+)* - Callback function triggered when the animation becomes visible. Receives the animated element as parameter. Useful for analytics, lazy loading, or triggering custom effects.
160
+ - **`sentinelColor`** *(v2.0.0+)* - Customize the debug sentinel color (e.g., '#ff6b6b' for red). Only visible when `debug: true`. Useful for distinguishing multiple sentinels on the same page.
161
+ - **`sentinelId`** *(v2.0.0+)* - Set a custom ID for the sentinel element. If not provided, an auto-ID is generated (`sentinel-1`, `sentinel-2`, etc.). Useful for identifying sentinels in DevTools and tracking which element owns which sentinel.
162
+
99
163
  ### Examples
100
164
 
101
165
  ```svelte
@@ -114,9 +178,74 @@ interface RuneScrollerOptions {
114
178
  Repeats every time you scroll
115
179
  </div>
116
180
 
117
- <!-- Debug mode (shows invisible sentinel) -->
181
+ <!-- Debug mode - shows cyan line marking sentinel position -->
118
182
  <div use:runeScroller={{ animation: 'fade-in', debug: true }}>
119
- You'll see a cyan line (the sentinel trigger)
183
+ The cyan line below this shows when animation will trigger
184
+ </div>
185
+
186
+ <!-- Multiple options -->
187
+ <div use:runeScroller={{
188
+ animation: 'fade-in-up',
189
+ duration: 1200,
190
+ repeat: true,
191
+ debug: true
192
+ }}>
193
+ Full featured example
194
+ </div>
195
+
196
+ <!-- Large element - trigger animation earlier with negative offset -->
197
+ <div use:runeScroller={{
198
+ animation: 'fade-in-up',
199
+ offset: -200 // Trigger 200px before element bottom
200
+ }}>
201
+ Large content that needs early triggering
202
+ </div>
203
+
204
+ <!-- Delay animation by moving sentinel down -->
205
+ <div use:runeScroller={{
206
+ animation: 'zoom-in',
207
+ offset: 300 // Trigger 300px after element bottom
208
+ }}>
209
+ Content with delayed animation
210
+ </div>
211
+
212
+ <!-- v2.0.0: onVisible callback for analytics tracking -->
213
+ <div use:runeScroller={{
214
+ animation: 'fade-in-up',
215
+ onVisible: (el) => {
216
+ console.log('Animation visible!', el);
217
+ // Track analytics, load images, trigger API calls, etc.
218
+ window.gtag?.('event', 'animation_visible', { element: el.id });
219
+ }
220
+ }}>
221
+ Tracked animation
222
+ </div>
223
+
224
+ <!-- v2.0.0: Custom sentinel color for debugging -->
225
+ <div use:runeScroller={{
226
+ animation: 'fade-in',
227
+ debug: true,
228
+ sentinelColor: '#ff6b6b' // Red instead of default cyan
229
+ }}>
230
+ Red debug sentinel
231
+ </div>
232
+
233
+ <!-- v2.0.0: Custom sentinel ID for identification -->
234
+ <div use:runeScroller={{
235
+ animation: 'zoom-in',
236
+ sentinelId: 'hero-zoom',
237
+ debug: true
238
+ }}>
239
+ Identified sentinel (shows "hero-zoom" in debug mode)
240
+ </div>
241
+
242
+ <!-- v2.0.0: Auto-ID (sentinel-1, sentinel-2, etc) -->
243
+ <div use:runeScroller={{
244
+ animation: 'fade-in-up',
245
+ debug: true
246
+ // sentinelId omitted → auto generates "sentinel-1", "sentinel-2", etc
247
+ }}>
248
+ Auto-identified sentinel
120
249
  </div>
121
250
  ```
122
251
 
@@ -139,19 +268,19 @@ For advanced use cases, use `animate` for fine-grained IntersectionObserver cont
139
268
  duration: 1000,
140
269
  delay: 200,
141
270
  threshold: 0.5,
142
- offset: 20,
143
- once: true
271
+ offset: 20
144
272
  }}>
145
273
  Advanced control
146
274
  </div>
147
275
  ```
148
276
 
149
277
  **Options:**
150
- - `threshold` - Intersection ratio to trigger (0-1)
278
+ - `threshold` - Intersection ratio to trigger (0-1, default: 0)
151
279
  - `offset` - Viewport offset percentage (0-100)
152
280
  - `rootMargin` - Custom IntersectionObserver margin
153
- - `delay` - Animation delay in ms
154
- - `once` - Trigger only once
281
+ - `delay` - Animation delay in ms (default: 0)
282
+
283
+ **Note:** `animate` triggers the animation **once** when the element enters the viewport (it's one-time by default, unlike `runeScroller` with `repeat: true`)
155
284
 
156
285
  ### Using Composables
157
286
 
@@ -184,24 +313,42 @@ Rune Scroller uses **sentinel-based triggering**:
184
313
  3. This ensures precise timing regardless of element size
185
314
  4. Uses native IntersectionObserver for performance
186
315
  5. Pure CSS animations (GPU-accelerated)
316
+ 6. *(v2.0.0)* Sentinel automatically repositions on element resize via ResizeObserver
187
317
 
188
318
  **Why sentinels?**
189
319
  - Accurate timing across all screen sizes
190
320
  - No complex offset calculations
191
321
  - Handles staggered animations naturally
322
+ - Sentinel stays fixed while element animates (no observer confusion with transforms)
323
+
324
+ **Automatic ResizeObserver** *(v2.0.0+)*
325
+ - Sentinel repositions automatically when element resizes
326
+ - Works with responsive layouts and dynamic content
327
+ - No configuration needed—it just works
192
328
 
193
329
  ---
194
330
 
195
331
  ## 🌐 SSR Compatibility
196
332
 
197
- Works seamlessly with SvelteKit:
333
+ Works seamlessly with SvelteKit. Import CSS in your root layout:
198
334
 
199
335
  ```svelte
336
+ <!-- src/routes/+layout.svelte -->
200
337
  <script>
201
- import runeScroller from 'rune-scroller';
202
338
  import 'rune-scroller/animations.css';
203
339
  </script>
204
340
 
341
+ <slot />
342
+ ```
343
+
344
+ Then use animations anywhere in your app:
345
+
346
+ ```svelte
347
+ <!-- src/routes/+page.svelte -->
348
+ <script>
349
+ import runeScroller from 'rune-scroller';
350
+ </script>
351
+
205
352
  <!-- No special handling needed -->
206
353
  <div use:runeScroller={{ animation: 'fade-in-up' }}>
207
354
  Works in SvelteKit SSR!
@@ -233,6 +380,19 @@ Users who prefer reduced motion will see content without animations.
233
380
 
234
381
  ## 📚 API Reference
235
382
 
383
+ ### Public API
384
+
385
+ Rune Scroller exports **2 action-based APIs** (no components):
386
+
387
+ 1. **`runeScroller`** (default) - Recommended, sentinel-based, simple
388
+ 2. **`animate`** (advanced) - Direct observation, fine-grained control
389
+
390
+ **Why actions instead of components?**
391
+ - Actions are lightweight directives
392
+ - No DOM wrapper overhead
393
+ - Better performance
394
+ - More flexible
395
+
236
396
  ### Main Export
237
397
 
238
398
  ```typescript
@@ -270,16 +430,23 @@ interface RuneScrollerOptions {
270
430
  duration?: number;
271
431
  repeat?: boolean;
272
432
  debug?: boolean;
433
+ offset?: number;
434
+ onVisible?: (element: HTMLElement) => void; // v2.0.0+
435
+ sentinelColor?: string; // v2.0.0+
436
+ sentinelId?: string; // v2.0.0+
273
437
  }
274
438
 
275
439
  interface AnimateOptions {
276
440
  animation?: AnimationType;
277
- duration?: number;
441
+ duration?: number; // default: 2500
278
442
  delay?: number;
279
443
  threshold?: number;
280
444
  rootMargin?: string;
281
445
  offset?: number;
282
446
  once?: boolean;
447
+ onVisible?: (element: HTMLElement) => void; // v2.0.0+
448
+ sentinelColor?: string; // v2.0.0+
449
+ sentinelId?: string; // v2.0.0+
283
450
  }
284
451
  ```
285
452
 
@@ -333,7 +500,6 @@ interface AnimateOptions {
333
500
 
334
501
  - **npm Package**: [rune-scroller](https://www.npmjs.com/package/rune-scroller)
335
502
  - **GitHub**: [lelabdev/rune-scroller](https://github.com/lelabdev/rune-scroller)
336
- - **Documentation**: [CLAUDE.md](./CLAUDE.md)
337
503
  - **Changelog**: [CHANGELOG.md](./CHANGELOG.md)
338
504
 
339
505
  ---
@@ -350,10 +516,10 @@ Contributions welcome! Please open an issue or PR on GitHub.
350
516
 
351
517
  ```bash
352
518
  # Development
353
- pnpm install
354
- pnpm dev
355
- pnpm test
356
- pnpm build
519
+ bun install
520
+ bun run dev
521
+ bun test
522
+ bun run build
357
523
  ```
358
524
 
359
525
  ---
@@ -0,0 +1,25 @@
1
+ export class IntersectionObserverMock {
2
+ constructor(callback: any, options?: {});
3
+ id: number;
4
+ callback: any;
5
+ options: {};
6
+ observedElements: Set<any>;
7
+ isConnected: boolean;
8
+ observe(element: any): void;
9
+ unobserve(element: any): void;
10
+ disconnect(): void;
11
+ trigger(element: any, isIntersecting: any): void;
12
+ }
13
+ export namespace mockIntersectionObserver {
14
+ let observers: Map<any, any>;
15
+ let lastObserver: null;
16
+ function install(): void;
17
+ function uninstall(): void;
18
+ function reset(): void;
19
+ function getLastCreated(): any;
20
+ function getAll(): any[];
21
+ function trigger(element: any, isIntersecting: any): void;
22
+ function triggerAll(isIntersecting: any): void;
23
+ function getObserverFor(element: any): any;
24
+ }
25
+ export default IntersectionObserverMock;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Mock IntersectionObserver for testing
3
+ * Allows manual triggering of intersection events for animation testing
4
+ */
5
+
6
+ const observers = new Map();
7
+ let observerId = 0;
8
+
9
+ export class IntersectionObserverMock {
10
+ constructor(callback, options = {}) {
11
+ this.id = observerId++;
12
+ this.callback = callback;
13
+ this.options = options;
14
+ this.observedElements = new Set();
15
+ this.isConnected = true;
16
+
17
+ observers.set(this.id, this);
18
+ }
19
+
20
+ observe(element) {
21
+ if (!this.isConnected) return;
22
+ this.observedElements.add(element);
23
+ }
24
+
25
+ unobserve(element) {
26
+ this.observedElements.delete(element);
27
+ }
28
+
29
+ disconnect() {
30
+ this.isConnected = false;
31
+ this.observedElements.clear();
32
+ observers.delete(this.id);
33
+ }
34
+
35
+ // Testing API: Manually trigger intersection
36
+ trigger(element, isIntersecting) {
37
+ if (!this.observedElements.has(element)) return;
38
+
39
+ const entry = {
40
+ target: element,
41
+ isIntersecting,
42
+ intersectionRatio: isIntersecting ? 1 : 0,
43
+ boundingClientRect: element.getBoundingClientRect?.() || {},
44
+ intersectionRect: isIntersecting ? { top: 0, height: 100 } : {},
45
+ rootBounds: { top: 0, height: window.innerHeight || 768 },
46
+ time: Date.now(),
47
+ toJSON: () => ({})
48
+ };
49
+
50
+ this.callback([entry], this);
51
+ }
52
+ }
53
+
54
+ // Global API
55
+ export const mockIntersectionObserver = {
56
+ observers: new Map(),
57
+ lastObserver: null,
58
+
59
+ install() {
60
+ global.IntersectionObserver = IntersectionObserverMock;
61
+ this.reset();
62
+ },
63
+
64
+ uninstall() {
65
+ if (typeof global !== 'undefined') {
66
+ delete global.IntersectionObserver;
67
+ }
68
+ },
69
+
70
+ reset() {
71
+ observers.forEach((obs) => obs.disconnect());
72
+ observers.clear();
73
+ observerId = 0;
74
+ this.lastObserver = null;
75
+ this.observers.clear();
76
+ },
77
+
78
+ // Get the last created observer
79
+ getLastCreated() {
80
+ if (observers.size === 0) return null;
81
+ const ids = Array.from(observers.keys());
82
+ return observers.get(ids[ids.length - 1]);
83
+ },
84
+
85
+ // Get all observers
86
+ getAll() {
87
+ return Array.from(observers.values());
88
+ },
89
+
90
+ // Trigger intersection on an element
91
+ trigger(element, isIntersecting) {
92
+ const matchingObservers = Array.from(observers.values()).filter((obs) =>
93
+ obs.observedElements.has(element)
94
+ );
95
+
96
+ matchingObservers.forEach((obs) => obs.trigger(element, isIntersecting));
97
+ },
98
+
99
+ // Trigger intersection on all observed elements
100
+ triggerAll(isIntersecting) {
101
+ observers.forEach((obs) => {
102
+ obs.observedElements.forEach((element) => {
103
+ obs.trigger(element, isIntersecting);
104
+ });
105
+ });
106
+ },
107
+
108
+ // Get observer for specific element
109
+ getObserverFor(element) {
110
+ return Array.from(observers.values()).find((obs) =>
111
+ obs.observedElements.has(element)
112
+ );
113
+ }
114
+ };
115
+
116
+ export default IntersectionObserverMock;
@@ -0,0 +1,25 @@
1
+ export namespace mockSvelteRunes {
2
+ export { effects };
3
+ export { effectCleanups };
4
+ export function install(): void;
5
+ export function uninstall(): void;
6
+ export function reset(): void;
7
+ export function getEffects(): any[];
8
+ export function runCleanups(): void;
9
+ export function createState(initialValue: any): ReactiveValue;
10
+ }
11
+ export default mockSvelteRunes;
12
+ declare let effects: any[];
13
+ declare let effectCleanups: any[];
14
+ /**
15
+ * Mock Svelte 5 Runes for testing useIntersection composables
16
+ * Allows testing reactive state and effects without Svelte runtime
17
+ */
18
+ declare class ReactiveValue {
19
+ constructor(initialValue: any);
20
+ _value: any;
21
+ _subscribers: Set<any>;
22
+ set value(newValue: any);
23
+ get value(): any;
24
+ subscribe(fn: any): () => boolean;
25
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Mock Svelte 5 Runes for testing useIntersection composables
3
+ * Allows testing reactive state and effects without Svelte runtime
4
+ */
5
+
6
+ class ReactiveValue {
7
+ constructor(initialValue) {
8
+ this._value = initialValue;
9
+ this._subscribers = new Set();
10
+ }
11
+
12
+ get value() {
13
+ return this._value;
14
+ }
15
+
16
+ set value(newValue) {
17
+ if (this._value !== newValue) {
18
+ this._value = newValue;
19
+ this._subscribers.forEach((fn) => fn(newValue));
20
+ }
21
+ }
22
+
23
+ subscribe(fn) {
24
+ this._subscribers.add(fn);
25
+ return () => this._subscribers.delete(fn);
26
+ }
27
+ }
28
+
29
+ // Global state management for mocks
30
+ let effects = [];
31
+ let effectCleanups = [];
32
+
33
+ export const mockSvelteRunes = {
34
+ effects,
35
+ effectCleanups,
36
+
37
+ install() {
38
+ // Mock $state
39
+ global.$state = (initialValue) => {
40
+ return new ReactiveValue(initialValue);
41
+ };
42
+
43
+ // Mock $effect
44
+ global.$effect = (fn) => {
45
+ const cleanup = fn();
46
+ effects.push(fn);
47
+ effectCleanups.push(cleanup);
48
+ return cleanup;
49
+ };
50
+
51
+ // Mock $effect.pre
52
+ global.$effect.pre = (fn) => {
53
+ const cleanup = fn();
54
+ effects.push(fn);
55
+ effectCleanups.push(cleanup);
56
+ return cleanup;
57
+ };
58
+
59
+ // Mock $derived
60
+ global.$derived = (expression) => {
61
+ return expression;
62
+ };
63
+
64
+ // Mock $derived.by
65
+ global.$derived.by = (fn) => {
66
+ return fn();
67
+ };
68
+
69
+ this.reset();
70
+ },
71
+
72
+ uninstall() {
73
+ if (typeof global !== 'undefined') {
74
+ delete global.$state;
75
+ delete global.$effect;
76
+ delete global.$derived;
77
+ }
78
+ },
79
+
80
+ reset() {
81
+ // Run all cleanups
82
+ effectCleanups.forEach((cleanup) => {
83
+ if (typeof cleanup === 'function') {
84
+ try {
85
+ cleanup();
86
+ } catch (e) {
87
+ // Silently ignore cleanup errors
88
+ }
89
+ }
90
+ });
91
+
92
+ effects.length = 0;
93
+ effectCleanups.length = 0;
94
+ },
95
+
96
+ getEffects() {
97
+ return effects;
98
+ },
99
+
100
+ runCleanups() {
101
+ effectCleanups.forEach((cleanup) => {
102
+ if (typeof cleanup === 'function') {
103
+ try {
104
+ cleanup();
105
+ } catch (e) {
106
+ // Silently ignore cleanup errors
107
+ }
108
+ }
109
+ });
110
+ },
111
+
112
+ createState(initialValue) {
113
+ return new ReactiveValue(initialValue);
114
+ }
115
+ };
116
+
117
+ export default mockSvelteRunes;