@humanspeak/svelte-motion 0.4.8 → 0.5.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 (49) hide show
  1. package/dist/html/_MotionContainer.svelte +8 -8
  2. package/dist/index.d.ts +17 -11
  3. package/dist/index.js +9 -9
  4. package/dist/utils/attachable.js +14 -9
  5. package/dist/utils/augmentMotionValue.svelte.d.ts +156 -0
  6. package/dist/utils/augmentMotionValue.svelte.js +193 -0
  7. package/dist/utils/booleanSnapshot.svelte.d.ts +37 -0
  8. package/dist/utils/booleanSnapshot.svelte.js +48 -0
  9. package/dist/utils/dom.d.ts +13 -0
  10. package/dist/utils/dom.js +19 -0
  11. package/dist/utils/inView.svelte.d.ts +209 -0
  12. package/dist/utils/inView.svelte.js +323 -0
  13. package/dist/utils/motionTemplate.svelte.d.ts +53 -0
  14. package/dist/utils/motionTemplate.svelte.js +78 -0
  15. package/dist/utils/motionValue.svelte.d.ts +61 -0
  16. package/dist/utils/motionValue.svelte.js +49 -0
  17. package/dist/utils/reducedMotion.svelte.d.ts +43 -0
  18. package/dist/utils/reducedMotion.svelte.js +80 -0
  19. package/dist/utils/reducedMotionConfig.svelte.d.ts +74 -0
  20. package/dist/utils/reducedMotionConfig.svelte.js +144 -0
  21. package/dist/utils/scroll.svelte.d.ts +91 -0
  22. package/dist/utils/scroll.svelte.js +259 -0
  23. package/dist/utils/spring.svelte.d.ts +2 -6
  24. package/dist/utils/spring.svelte.js +6 -70
  25. package/dist/utils/time.svelte.d.ts +47 -0
  26. package/dist/utils/time.svelte.js +128 -0
  27. package/dist/utils/transform.svelte.d.ts +170 -0
  28. package/dist/utils/transform.svelte.js +189 -0
  29. package/dist/utils/velocity.svelte.d.ts +61 -0
  30. package/dist/utils/velocity.svelte.js +132 -0
  31. package/package.json +1 -1
  32. package/dist/utils/inView.d.ts +0 -136
  33. package/dist/utils/inView.js +0 -266
  34. package/dist/utils/motionTemplate.d.ts +0 -21
  35. package/dist/utils/motionTemplate.js +0 -33
  36. package/dist/utils/motionValue.d.ts +0 -6
  37. package/dist/utils/motionValue.js +0 -13
  38. package/dist/utils/reducedMotion.d.ts +0 -20
  39. package/dist/utils/reducedMotion.js +0 -42
  40. package/dist/utils/reducedMotionConfig.d.ts +0 -39
  41. package/dist/utils/reducedMotionConfig.js +0 -92
  42. package/dist/utils/scroll.d.ts +0 -63
  43. package/dist/utils/scroll.js +0 -79
  44. package/dist/utils/time.d.ts +0 -14
  45. package/dist/utils/time.js +0 -68
  46. package/dist/utils/transform.d.ts +0 -74
  47. package/dist/utils/transform.js +0 -211
  48. package/dist/utils/velocity.d.ts +0 -15
  49. package/dist/utils/velocity.js +0 -62
@@ -0,0 +1,259 @@
1
+ import { scroll } from 'motion';
2
+ import { cancelMicrotask, microtask, motionValue, supportsScrollTimeline, supportsViewTimeline } from 'motion-dom';
3
+ import { augmentMotionValue } from './augmentMotionValue.svelte.js';
4
+ import { isRefPending, resolveElement } from './dom.js';
5
+ /**
6
+ * Named view-timeline presets — mirror framer-motion's ScrollOffset table.
7
+ * Each entry is a pair of `[target, container]` intersection points that
8
+ * defines a phase of the scroll relationship.
9
+ */
10
+ const VIEW_TIMELINE_PRESETS = [
11
+ [
12
+ [
13
+ [0, 1],
14
+ [1, 1]
15
+ ],
16
+ 'entry'
17
+ ],
18
+ [
19
+ [
20
+ [0, 0],
21
+ [1, 0]
22
+ ],
23
+ 'exit'
24
+ ],
25
+ [
26
+ [
27
+ [1, 0],
28
+ [0, 1]
29
+ ],
30
+ 'cover'
31
+ ],
32
+ [
33
+ [
34
+ [0, 0],
35
+ [1, 1]
36
+ ],
37
+ 'contain'
38
+ ]
39
+ ];
40
+ const PROGRESS_BY_STRING = { start: 0, end: 1 };
41
+ /**
42
+ * Parse `"start end"` / `"end start"` / etc. into an explicit
43
+ * `[target, container]` intersection pair.
44
+ */
45
+ const parseStringOffset = (s) => {
46
+ const parts = s.trim().split(/\s+/);
47
+ if (parts.length !== 2)
48
+ return undefined;
49
+ const a = PROGRESS_BY_STRING[parts[0]];
50
+ const b = PROGRESS_BY_STRING[parts[1]];
51
+ if (a === undefined || b === undefined)
52
+ return undefined;
53
+ return [a, b];
54
+ };
55
+ /**
56
+ * Normalize a {@link ScrollOffset} into an array of explicit intersection
57
+ * pairs, or `undefined` if the shape doesn't match the simple two-element
58
+ * preset form.
59
+ */
60
+ const normaliseOffset = (offset) => {
61
+ if (offset.length !== 2)
62
+ return undefined;
63
+ const result = [];
64
+ for (const item of offset) {
65
+ if (Array.isArray(item)) {
66
+ result.push(item);
67
+ }
68
+ else if (typeof item === 'string') {
69
+ const parsed = parseStringOffset(item);
70
+ if (!parsed)
71
+ return undefined;
72
+ result.push(parsed);
73
+ }
74
+ else {
75
+ return undefined;
76
+ }
77
+ }
78
+ return result;
79
+ };
80
+ const matchesPreset = (offset, preset) => {
81
+ const normalised = normaliseOffset(offset);
82
+ if (!normalised)
83
+ return false;
84
+ for (let i = 0; i < 2; i++) {
85
+ const o = normalised[i];
86
+ const p = preset[i];
87
+ if (o[0] !== p[0] || o[1] !== p[1])
88
+ return false;
89
+ }
90
+ return true;
91
+ };
92
+ /**
93
+ * Map a {@link ScrollOffset} to its corresponding CSS view-timeline range, if
94
+ * any. Returning `undefined` signals the caller to fall back to JS-driven
95
+ * scroll tracking. Mirrors framer-motion's `offsetToViewTimelineRange`.
96
+ */
97
+ const offsetToViewTimelineRange = (offset) => {
98
+ if (!offset)
99
+ return { rangeStart: 'contain 0%', rangeEnd: 'contain 100%' };
100
+ for (const [preset, name] of VIEW_TIMELINE_PRESETS) {
101
+ if (matchesPreset(offset, preset)) {
102
+ return { rangeStart: `${name} 0%`, rangeEnd: `${name} 100%` };
103
+ }
104
+ }
105
+ return undefined;
106
+ };
107
+ /**
108
+ * Whether this scroll configuration can be driven by a native CSS
109
+ * scroll-timeline / view-timeline. When `true`, the resulting motion value
110
+ * runs on the compositor thread without per-frame JS callbacks.
111
+ */
112
+ const canAccelerateScroll = (target, offset) => {
113
+ if (typeof window === 'undefined')
114
+ return false;
115
+ return target
116
+ ? supportsViewTimeline() && !!offsetToViewTimelineRange(offset)
117
+ : supportsScrollTimeline();
118
+ };
119
+ /**
120
+ * Build the AccelerateConfig for a progress motion value driven by a native
121
+ * scroll-timeline / view-timeline animation. Mirrors framer-motion's
122
+ * `makeAccelerateConfig` 1:1: the `factory` defers `scroll()` until refs
123
+ * hydrate via `microtask.read`, then attaches the animation; the `times` /
124
+ * `keyframes` / `ease` / `duration` describe the 0→1 linear mapping.
125
+ */
126
+ const makeAccelerateConfig = (axis, options) => ({
127
+ factory: (animation) => {
128
+ let cleanup;
129
+ const start = () => {
130
+ if (isRefPending(options.container) || isRefPending(options.target)) {
131
+ microtask.read(start);
132
+ return;
133
+ }
134
+ cleanup = scroll(animation, {
135
+ offset: options.offset,
136
+ axis,
137
+ container: resolveElement(options.container),
138
+ target: resolveElement(options.target)
139
+ });
140
+ };
141
+ microtask.read(start);
142
+ return () => {
143
+ cancelMicrotask(start);
144
+ cleanup?.();
145
+ };
146
+ },
147
+ times: [0, 1],
148
+ keyframes: [0, 1],
149
+ ease: (v) => v,
150
+ duration: 1
151
+ });
152
+ /**
153
+ * Creates scroll-linked motion values for building scroll-driven animations
154
+ * such as progress indicators and parallax effects.
155
+ *
156
+ * Returns four `MotionValue<number>`s: `scrollX` / `scrollY` (current
157
+ * position in px) and `scrollXProgress` / `scrollYProgress` (0–1
158
+ * normalised). Each is a real motion-dom `MotionValue` augmented with a
159
+ * `$state`-backed `.current` getter and a `.subscribe` shim, so they
160
+ * compose with `useTransform`, `useSpring`, and the rest of the Tier 2
161
+ * surface, and they read reactively in Svelte 5 templates.
162
+ *
163
+ * **GPU-accelerated when supported.** On browsers that implement CSS
164
+ * scroll-timeline (no `target`) or view-timeline (with a `target` and a
165
+ * preset `offset`), the *Progress motion values run on the compositor
166
+ * thread via WAAPI — no per-frame JS callback. The non-progress
167
+ * `scrollX` / `scrollY` motion values always use the JS-driven `scroll()`
168
+ * primitive since the absolute pixel offset isn't directly available from
169
+ * native timelines.
170
+ *
171
+ * `container` and `target` accept either an `HTMLElement` directly or a
172
+ * getter `() => HTMLElement | undefined`. The getter form is the right
173
+ * choice with `bind:this`. Element resolution is deferred to a microtask
174
+ * (matches React framer-motion 1:1, faster than rAF polling), so a getter
175
+ * that hasn't hydrated yet is retried as soon as Svelte's mount tick
176
+ * settles it.
177
+ *
178
+ * Lifecycle: the underlying `scroll()` observer and any accelerate factory
179
+ * attach at mount via `$effect` and detach at unmount, regardless of how
180
+ * many consumers are reading the values. The four motion values are torn
181
+ * down at the same time.
182
+ *
183
+ * SSR-safe: returns four static `motionValue(0)`s with no scroll observer
184
+ * on the server.
185
+ *
186
+ * @param options Optional scroll tracking configuration.
187
+ * @returns Four `MotionValue<number>`s — `scrollX`, `scrollY`, `scrollXProgress`, `scrollYProgress`.
188
+ *
189
+ * @example
190
+ * ```svelte
191
+ * <script>
192
+ * import { useScroll, useSpring } from '@humanspeak/svelte-motion'
193
+ *
194
+ * const { scrollYProgress } = useScroll()
195
+ * const scaleX = useSpring(scrollYProgress)
196
+ * </script>
197
+ *
198
+ * <div style="transform: scaleX({scaleX.current}); transform-origin: left;" />
199
+ * ```
200
+ *
201
+ * @see https://motion.dev/docs/react-use-scroll
202
+ */
203
+ export const useScroll = (options = {}) => {
204
+ const scrollX = motionValue(0);
205
+ const scrollY = motionValue(0);
206
+ const scrollXProgress = motionValue(0);
207
+ const scrollYProgress = motionValue(0);
208
+ // SSR: return static motion values with no observer and no $effect.
209
+ if (typeof window === 'undefined') {
210
+ return {
211
+ scrollX: augmentMotionValue(scrollX),
212
+ scrollY: augmentMotionValue(scrollY),
213
+ scrollXProgress: augmentMotionValue(scrollXProgress),
214
+ scrollYProgress: augmentMotionValue(scrollYProgress)
215
+ };
216
+ }
217
+ // The *Progress MVs accelerate to the compositor thread when the browser
218
+ // supports CSS scroll/view-timelines; the non-progress scrollX/scrollY
219
+ // MVs always need the JS callback for absolute pixel offsets.
220
+ if (canAccelerateScroll(options.target, options.offset)) {
221
+ scrollXProgress.accelerate = makeAccelerateConfig('x', options);
222
+ scrollYProgress.accelerate = makeAccelerateConfig('y', options);
223
+ }
224
+ let cleanup;
225
+ const start = () => {
226
+ if (isRefPending(options.container) || isRefPending(options.target)) {
227
+ microtask.read(start);
228
+ return;
229
+ }
230
+ cleanup = scroll((_progress, info) => {
231
+ scrollX.set(info.x.current);
232
+ scrollY.set(info.y.current);
233
+ scrollXProgress.set(info.x.progress);
234
+ scrollYProgress.set(info.y.progress);
235
+ }, {
236
+ container: resolveElement(options.container),
237
+ target: resolveElement(options.target),
238
+ offset: options.offset,
239
+ axis: options.axis
240
+ });
241
+ };
242
+ $effect(() => {
243
+ microtask.read(start);
244
+ return () => {
245
+ cancelMicrotask(start);
246
+ cleanup?.();
247
+ scrollX.destroy();
248
+ scrollY.destroy();
249
+ scrollXProgress.destroy();
250
+ scrollYProgress.destroy();
251
+ };
252
+ });
253
+ return {
254
+ scrollX: augmentMotionValue(scrollX),
255
+ scrollY: augmentMotionValue(scrollY),
256
+ scrollXProgress: augmentMotionValue(scrollXProgress),
257
+ scrollYProgress: augmentMotionValue(scrollYProgress)
258
+ };
259
+ };
@@ -1,5 +1,6 @@
1
1
  import { type FollowValueOptions, type MotionValue, type SpringOptions } from 'motion-dom';
2
2
  import { type Readable } from 'svelte/store';
3
+ import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
3
4
  /**
4
5
  * Spring + follow options for {@link useSpring}.
5
6
  *
@@ -25,12 +26,7 @@ export type UseSpringOptions = SpringOptions & Pick<FollowValueOptions, 'skipIni
25
26
  * spring be used with `$spring` template syntax, `get(spring)`, and as a
26
27
  * dependency in `useTransform`'s function form.
27
28
  */
28
- export type SpringMotionValue<T extends number | string> = Omit<MotionValue<T>, 'current'> & {
29
- /** Reactive read in Svelte 5 templates / `$derived` / `$effect`. */
30
- readonly current: T;
31
- /** Svelte readable store compatibility. */
32
- subscribe: (run: (value: T) => void) => () => void;
33
- };
29
+ export type SpringMotionValue<T extends number | string> = AugmentedMotionValue<T>;
34
30
  /**
35
31
  * Creates a spring-animated `MotionValue`.
36
32
  *
@@ -1,26 +1,16 @@
1
1
  import { attachFollow, isMotionValue, motionValue } from 'motion-dom';
2
- import { get } from 'svelte/store';
3
- /**
4
- * Detects a Svelte readable store. Excludes motion-dom `MotionValue` instances
5
- * (which also expose `subscribe`-shaped APIs in some versions) so the
6
- * MotionValue path is preferred.
7
- */
8
- const isSvelteReadable = (value) => {
9
- return (!!value &&
10
- typeof value === 'object' &&
11
- typeof value.subscribe === 'function' &&
12
- !isMotionValue(value));
13
- };
2
+ import {} from 'svelte/store';
3
+ import { augmentMotionValue, isSvelteReadable, sampleSource } from './augmentMotionValue.svelte.js';
14
4
  export function useSpring(source, options = {}) {
15
5
  // SSR: return a static MotionValue with no animation. Reads return the
16
6
  // best-effort initial value; .set / .jump become no-ops to avoid drifting
17
7
  // away from the server-rendered snapshot.
18
8
  if (typeof window === 'undefined') {
19
- const initial = readInitial(source);
9
+ const initial = sampleSource(source);
20
10
  const ssrValue = motionValue(initial);
21
11
  ssrValue.set = () => undefined;
22
12
  ssrValue.jump = () => undefined;
23
- return augmentForSvelte(ssrValue, () => undefined);
13
+ return augmentMotionValue(ssrValue);
24
14
  }
25
15
  // Resolve initial + follow source.
26
16
  let followSource;
@@ -32,7 +22,7 @@ export function useSpring(source, options = {}) {
32
22
  else if (isSvelteReadable(source)) {
33
23
  // Bridge a Svelte readable into a MotionValue so attachFollow can
34
24
  // track it. Synchronous initial sample comes from svelte/store's get().
35
- const initialFromReadable = get(source);
25
+ const initialFromReadable = sampleSource(source);
36
26
  svelteBridge = motionValue(initialFromReadable);
37
27
  cleanupReadableBridge = source.subscribe((v) => {
38
28
  // The Svelte readable contract calls the subscriber synchronously
@@ -60,59 +50,5 @@ export function useSpring(source, options = {}) {
60
50
  svelteBridge?.destroy();
61
51
  };
62
52
  $effect(() => () => value.destroy());
63
- return augmentForSvelte(value, dispose);
53
+ return augmentMotionValue(value, dispose);
64
54
  }
65
- /**
66
- * Pull the synchronous initial value out of any accepted source form.
67
- */
68
- const readInitial = (source) => {
69
- if (typeof source === 'number' || typeof source === 'string')
70
- return source;
71
- if (isMotionValue(source))
72
- return source.get();
73
- if (isSvelteReadable(source))
74
- return get(source);
75
- return 0;
76
- };
77
- /**
78
- * Layer Svelte-friendly affordances onto a motion-dom MotionValue: a
79
- * `$state`-tracked `.current` accessor (routing motion-dom's internal
80
- * `this.current = v` writes through `$state` so templates and `$derived` /
81
- * `$effect` re-run) and a Svelte readable store `.subscribe(run)` shim.
82
- */
83
- const augmentForSvelte = (value, dispose) => {
84
- let current = $state(value.get());
85
- Object.defineProperty(value, 'current', {
86
- get: () => current,
87
- // Same-value writes are no-ops: motion-dom's `updateAndNotify` calls
88
- // `setCurrent(v)` before its own change check, so spring frames at
89
- // rest still hit this setter; skipping equal writes avoids gratuitous
90
- // accessor work even though $state would itself dedupe.
91
- set: (v) => {
92
- if (v !== current)
93
- current = v;
94
- },
95
- enumerable: true,
96
- configurable: true
97
- });
98
- const originalDestroy = value.destroy.bind(value);
99
- let destroyed = false;
100
- value.destroy = () => {
101
- if (destroyed)
102
- return;
103
- destroyed = true;
104
- dispose();
105
- originalDestroy();
106
- };
107
- const subscribe = (run) => {
108
- run(value.get());
109
- return value.on('change', run);
110
- };
111
- Object.defineProperty(value, 'subscribe', {
112
- value: subscribe,
113
- writable: false,
114
- enumerable: false,
115
- configurable: true
116
- });
117
- return value;
118
- };
@@ -0,0 +1,47 @@
1
+ import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
2
+ /**
3
+ * Returns an augmented `MotionValue<number>` that ticks once per render
4
+ * frame with the milliseconds elapsed since the value was created.
5
+ *
6
+ * Mirrors React framer-motion's `useTime` 1:1: a `MotionValue<number>`
7
+ * driven by motion-dom's `frame.update(tick, true)` keep-alive callback.
8
+ * The frame loop dedupes per-frame work across all motion-dom consumers,
9
+ * so multiple `useTime()` calls share the same render schedule.
10
+ *
11
+ * Two modes:
12
+ *
13
+ * - **Unique timeline** — `useTime()` starts its own keep-alive callback.
14
+ * The motion value and the callback are torn down when the surrounding
15
+ * `$effect` scope unmounts.
16
+ * - **Shared timeline** — `useTime(id)` callers passing the same `id` all
17
+ * observe a single shared frame-loop callback. Each call still returns
18
+ * an independent motion value (so destroying one consumer's value
19
+ * doesn't ripple to others) but the values stay in lockstep. The
20
+ * shared callback cancels the moment the last consumer unmounts; the
21
+ * next `useTime(id)` call restarts it.
22
+ *
23
+ * The result is augmented with a `$state`-backed `.current` getter and a
24
+ * `.subscribe` shim — it composes with `useTransform`, `useSpring`, and
25
+ * the rest of the Tier 2 surface.
26
+ *
27
+ * Lifecycle: must be called during component initialization. SSR-safe:
28
+ * returns a static `motionValue(0)` with no frame loop on the server.
29
+ *
30
+ * @param id Optional timeline identifier for sharing across components.
31
+ * @returns A `MotionValue<number>` with `.current` and `.subscribe`.
32
+ *
33
+ * @example
34
+ * ```svelte
35
+ * <script>
36
+ * import { useTime, useTransform } from '@humanspeak/svelte-motion'
37
+ *
38
+ * const time = useTime()
39
+ * const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
40
+ * </script>
41
+ *
42
+ * <div style="transform: rotate({rotate.current}deg)">↻</div>
43
+ * ```
44
+ *
45
+ * @see https://motion.dev/docs/react-use-time
46
+ */
47
+ export declare const useTime: (id?: string) => AugmentedMotionValue<number>;
@@ -0,0 +1,128 @@
1
+ import { cancelFrame, frame, motionValue } from 'motion-dom';
2
+ import { SvelteMap } from 'svelte/reactivity';
3
+ import { augmentMotionValue } from './augmentMotionValue.svelte.js';
4
+ // `SvelteMap` (not plain Map) per `eslint/svelte/prefer-svelte-reactivity`
5
+ // inside `.svelte.ts` files. The contents aren't read in reactive scopes —
6
+ // this is a plain keyed cache — but the linter rule applies uniformly.
7
+ const sharedTimelines = new SvelteMap();
8
+ // Clear shared timelines on HMR dispose to avoid stale entries across hot
9
+ // reloads.
10
+ const hot = import.meta.hot;
11
+ if (hot) {
12
+ hot.dispose(() => {
13
+ for (const t of sharedTimelines.values()) {
14
+ t.cancel();
15
+ t.base.destroy();
16
+ }
17
+ sharedTimelines.clear();
18
+ });
19
+ }
20
+ /**
21
+ * Starts a keep-alive frame-loop callback that writes elapsed-milliseconds
22
+ * into a fresh `MotionValue<number>`. Returns the value and a cancel
23
+ * function that stops the loop. Caller owns the value's destroy lifecycle.
24
+ *
25
+ * Uses motion-dom's `frame.update(cb, true)` — `true` is the `keepAlive`
26
+ * flag, telling the frame loop to re-schedule the callback every frame
27
+ * automatically. Matches React framer-motion's `useAnimationFrame`.
28
+ */
29
+ const startTimeBase = () => {
30
+ const base = motionValue(0);
31
+ let start = 0;
32
+ const tick = ({ timestamp }) => {
33
+ if (!start)
34
+ start = timestamp;
35
+ base.set(timestamp - start);
36
+ };
37
+ frame.update(tick, true);
38
+ return {
39
+ base,
40
+ cancel: () => cancelFrame(tick)
41
+ };
42
+ };
43
+ /**
44
+ * Returns an augmented `MotionValue<number>` that ticks once per render
45
+ * frame with the milliseconds elapsed since the value was created.
46
+ *
47
+ * Mirrors React framer-motion's `useTime` 1:1: a `MotionValue<number>`
48
+ * driven by motion-dom's `frame.update(tick, true)` keep-alive callback.
49
+ * The frame loop dedupes per-frame work across all motion-dom consumers,
50
+ * so multiple `useTime()` calls share the same render schedule.
51
+ *
52
+ * Two modes:
53
+ *
54
+ * - **Unique timeline** — `useTime()` starts its own keep-alive callback.
55
+ * The motion value and the callback are torn down when the surrounding
56
+ * `$effect` scope unmounts.
57
+ * - **Shared timeline** — `useTime(id)` callers passing the same `id` all
58
+ * observe a single shared frame-loop callback. Each call still returns
59
+ * an independent motion value (so destroying one consumer's value
60
+ * doesn't ripple to others) but the values stay in lockstep. The
61
+ * shared callback cancels the moment the last consumer unmounts; the
62
+ * next `useTime(id)` call restarts it.
63
+ *
64
+ * The result is augmented with a `$state`-backed `.current` getter and a
65
+ * `.subscribe` shim — it composes with `useTransform`, `useSpring`, and
66
+ * the rest of the Tier 2 surface.
67
+ *
68
+ * Lifecycle: must be called during component initialization. SSR-safe:
69
+ * returns a static `motionValue(0)` with no frame loop on the server.
70
+ *
71
+ * @param id Optional timeline identifier for sharing across components.
72
+ * @returns A `MotionValue<number>` with `.current` and `.subscribe`.
73
+ *
74
+ * @example
75
+ * ```svelte
76
+ * <script>
77
+ * import { useTime, useTransform } from '@humanspeak/svelte-motion'
78
+ *
79
+ * const time = useTime()
80
+ * const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
81
+ * </script>
82
+ *
83
+ * <div style="transform: rotate({rotate.current}deg)">↻</div>
84
+ * ```
85
+ *
86
+ * @see https://motion.dev/docs/react-use-time
87
+ */
88
+ export const useTime = (id) => {
89
+ // SSR: return a static motion value with no frame loop. Matches
90
+ // useSpring / useScroll's SSR branch — no $effect is registered.
91
+ if (typeof window === 'undefined') {
92
+ return augmentMotionValue(motionValue(0));
93
+ }
94
+ // Unique timeline: own callback, own MV, own lifecycle.
95
+ if (!id) {
96
+ const { base, cancel } = startTimeBase();
97
+ $effect(() => () => {
98
+ cancel();
99
+ base.destroy();
100
+ });
101
+ return augmentMotionValue(base);
102
+ }
103
+ // Shared timeline: one frame callback per `id`, mirrored into per-
104
+ // consumer MVs.
105
+ let timeline = sharedTimelines.get(id);
106
+ if (!timeline) {
107
+ const { base, cancel } = startTimeBase();
108
+ timeline = { base, refcount: 0, cancel };
109
+ sharedTimelines.set(id, timeline);
110
+ }
111
+ timeline.refcount++;
112
+ const consumerMv = motionValue(timeline.base.get());
113
+ const unsubBase = timeline.base.on('change', (t) => consumerMv.set(t));
114
+ $effect(() => () => {
115
+ unsubBase();
116
+ consumerMv.destroy();
117
+ const t = sharedTimelines.get(id);
118
+ if (!t)
119
+ return;
120
+ t.refcount--;
121
+ if (t.refcount > 0)
122
+ return;
123
+ t.cancel();
124
+ t.base.destroy();
125
+ sharedTimelines.delete(id);
126
+ });
127
+ return augmentMotionValue(consumerMv);
128
+ };