phase 0.0.1-alpha.1 → 0.0.2

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.
@@ -0,0 +1,948 @@
1
+ //#region src/core/_internal/abort/index.ts
2
+ /**
3
+ * Link an optional `AbortSignal` to a primitive's `stop`/cancel function.
4
+ *
5
+ * When the signal aborts, `stop` runs once. If the signal is already aborted,
6
+ * `stop` runs synchronously. Returns an unlink function that removes the abort
7
+ * listener. Call it from inside `stop` so a manual stop does not leave a
8
+ * dangling listener on a long-lived controller.
9
+ *
10
+ * The listener is registered with `{ once: true }`, so abort and manual stop
11
+ * are safe to interleave: whichever fires first wins, the other is a no-op.
12
+ */
13
+ function linkAbortSignal(signal, stop) {
14
+ if (!signal) return unlinkNoop;
15
+ if (signal.aborted) {
16
+ stop();
17
+ return unlinkNoop;
18
+ }
19
+ signal.addEventListener("abort", stop, { once: true });
20
+ return () => signal.removeEventListener("abort", stop);
21
+ }
22
+ /** Shared empty unlink for the no-signal and already-aborted paths. */
23
+ function unlinkNoop() {}
24
+ //#endregion
25
+ //#region src/core/_internal/errors/index.ts
26
+ /** Lightweight structured error for phase. */
27
+ var PhaseError = class extends Error {
28
+ code;
29
+ reason;
30
+ fix;
31
+ link;
32
+ constructor(message, options) {
33
+ super(message);
34
+ this.name = "PhaseError";
35
+ this.code = options.code;
36
+ this.reason = options.reason;
37
+ this.fix = options.fix;
38
+ this.link = options.link;
39
+ }
40
+ };
41
+ /** Check if a value is a PhaseError instance. */
42
+ function isPhaseError(error) {
43
+ return error instanceof PhaseError;
44
+ }
45
+ function serverContextError(fn) {
46
+ throw new PhaseError(`${fn}() cannot be called on the server.`, {
47
+ code: "server_context",
48
+ reason: "Browser APIs are unavailable during SSR.",
49
+ fix: "Move into a useEffect or client-only module."
50
+ });
51
+ }
52
+ function noElementError(fn) {
53
+ throw new PhaseError(`${fn}() requires a DOM element.`, {
54
+ code: "no_element",
55
+ reason: "The element was null or undefined.",
56
+ fix: "Pass a mounted Element, or use the React hook which manages the ref."
57
+ });
58
+ }
59
+ function invalidDurationError(fn, value) {
60
+ throw new PhaseError(`${fn}() received an invalid duration: ${value}`, {
61
+ code: "invalid_duration",
62
+ reason: "Duration must be a finite positive number.",
63
+ fix: "Pass a positive number (e.g., 300 for 300ms)."
64
+ });
65
+ }
66
+ function tickerStoppedError() {
67
+ throw new PhaseError("Cannot resume a stopped ticker.", {
68
+ code: "ticker_stopped",
69
+ reason: "stop() is terminal, so a stopped ticker cannot be resumed.",
70
+ fix: "Create a new ticker instance instead of resuming a stopped one."
71
+ });
72
+ }
73
+ function missingContextError(child, parent) {
74
+ throw new PhaseError(`<${child}> must be used inside <${parent}>.`, {
75
+ code: "missing_context",
76
+ reason: `<${child}> reads from a context that <${parent}> provides.`,
77
+ fix: `Wrap <${child}> with <${parent}>.`
78
+ });
79
+ }
80
+ //#endregion
81
+ //#region src/core/tick/index.ts
82
+ /** Prevents teleportation on resume. Matches motion's maxElapsed. */
83
+ const MAX_DELTA_MS = 40;
84
+ /** Default first-frame delta when no previous tick exists. */
85
+ const DEFAULT_FIRST_DELTA_MS = 16.67;
86
+ let sharedTime = 0;
87
+ let sharedRafId = 0;
88
+ const sharedSubscribers = /* @__PURE__ */ new Set();
89
+ function sharedTick() {
90
+ sharedTime = performance.now();
91
+ for (const callback of sharedSubscribers) callback();
92
+ if (sharedSubscribers.size > 0) sharedRafId = requestAnimationFrame(sharedTick);
93
+ }
94
+ function joinSharedClock(callback) {
95
+ const wasEmpty = sharedSubscribers.size === 0;
96
+ sharedSubscribers.add(callback);
97
+ if (wasEmpty) {
98
+ sharedTime = performance.now();
99
+ sharedRafId = requestAnimationFrame(sharedTick);
100
+ }
101
+ return () => {
102
+ sharedSubscribers.delete(callback);
103
+ if (sharedSubscribers.size === 0 && sharedRafId) {
104
+ cancelAnimationFrame(sharedRafId);
105
+ sharedRafId = 0;
106
+ }
107
+ };
108
+ }
109
+ function resetFrameState(state) {
110
+ state.time = 0;
111
+ state.delta = 0;
112
+ state.elapsed = 0;
113
+ state.frame = 0;
114
+ }
115
+ /**
116
+ * Core rAF loop primitive with FPS cap, delta clamping, and strong pause.
117
+ *
118
+ * @remarks
119
+ * `FrameState` is reused across frames. Do not store a reference to it.
120
+ * Read values immediately in your `onTick` callback.
121
+ */
122
+ function createTicker(options) {
123
+ if (typeof requestAnimationFrame === "undefined") serverContextError("createTicker");
124
+ const { onTick, fps, signal } = options;
125
+ const minFrameTime = fps ? 1e3 / fps : 0;
126
+ let _phase = "idle";
127
+ let _reason = "initial";
128
+ let leaveSharedClock = null;
129
+ let lastTickTime = 0;
130
+ let pauseStartTime = 0;
131
+ let totalPausedTime = 0;
132
+ let startTime = 0;
133
+ const frame = {
134
+ time: 0,
135
+ delta: 0,
136
+ elapsed: 0,
137
+ frame: 0
138
+ };
139
+ function tick() {
140
+ const now = sharedTime;
141
+ if (minFrameTime > 0 && now - lastTickTime < minFrameTime) return;
142
+ const rawDelta = lastTickTime === 0 ? DEFAULT_FIRST_DELTA_MS : now - lastTickTime;
143
+ lastTickTime = now;
144
+ frame.time = now;
145
+ frame.delta = rawDelta > MAX_DELTA_MS ? MAX_DELTA_MS : rawDelta;
146
+ frame.elapsed = now - startTime - totalPausedTime;
147
+ frame.frame++;
148
+ onTick(frame);
149
+ }
150
+ function start() {
151
+ if (_phase === "running") return;
152
+ if (_phase === "stopped") tickerStoppedError();
153
+ if (_phase === "paused") {
154
+ resume();
155
+ return;
156
+ }
157
+ _phase = "running";
158
+ _reason = "started";
159
+ startTime = performance.now();
160
+ lastTickTime = 0;
161
+ totalPausedTime = 0;
162
+ resetFrameState(frame);
163
+ leaveSharedClock = joinSharedClock(tick);
164
+ }
165
+ function pause() {
166
+ if (_phase !== "running") return;
167
+ _phase = "paused";
168
+ _reason = "manual";
169
+ pauseStartTime = performance.now();
170
+ leaveSharedClock?.();
171
+ leaveSharedClock = null;
172
+ }
173
+ function resume() {
174
+ if (_phase === "stopped") tickerStoppedError();
175
+ if (_phase !== "paused") return;
176
+ totalPausedTime += performance.now() - pauseStartTime;
177
+ lastTickTime = 0;
178
+ _phase = "running";
179
+ _reason = "resumed";
180
+ leaveSharedClock = joinSharedClock(tick);
181
+ }
182
+ function stop() {
183
+ if (_phase === "stopped") return;
184
+ _phase = "stopped";
185
+ _reason = _reason === "initial" ? "disposed" : "manual";
186
+ unlinkAbort?.();
187
+ leaveSharedClock?.();
188
+ leaveSharedClock = null;
189
+ }
190
+ let unlinkAbort;
191
+ unlinkAbort = linkAbortSignal(signal, stop);
192
+ return {
193
+ start,
194
+ stop,
195
+ pause,
196
+ resume,
197
+ get phase() {
198
+ return _phase;
199
+ },
200
+ get phaseReason() {
201
+ return _reason;
202
+ }
203
+ };
204
+ }
205
+ //#endregion
206
+ //#region src/core/_internal/pool/io-pool.ts
207
+ const pool$1 = /* @__PURE__ */ new Map();
208
+ /**
209
+ * Observe an element via a shared IntersectionObserver pool.
210
+ * Elements with identical options share one IO instance.
211
+ *
212
+ * @returns Cleanup function that unobserves the element and removes the IO if empty.
213
+ */
214
+ function observeIntersection(options) {
215
+ const { element, onIntersect, root, rootMargin, threshold } = options;
216
+ const ioInit = {
217
+ root,
218
+ rootMargin,
219
+ threshold
220
+ };
221
+ const key = getPoolKey(ioInit);
222
+ const entry = getOrCreatePoolEntry(key, ioInit);
223
+ entry.callbacks.set(element, onIntersect);
224
+ entry.observer.observe(element);
225
+ let disposed = false;
226
+ return () => {
227
+ if (disposed) return;
228
+ disposed = true;
229
+ const poolEntry = pool$1.get(key);
230
+ if (!poolEntry) return;
231
+ if (poolEntry.callbacks.get(element) === onIntersect) {
232
+ poolEntry.observer.unobserve(element);
233
+ poolEntry.callbacks.delete(element);
234
+ }
235
+ if (poolEntry.callbacks.size === 0) {
236
+ poolEntry.observer.disconnect();
237
+ pool$1.delete(key);
238
+ }
239
+ };
240
+ }
241
+ /**
242
+ * Produces a stable string key from IO options to group observers in the pool.
243
+ * IO options are immutable after construction, so identical options can share.
244
+ */
245
+ function getPoolKey(opts) {
246
+ const root = opts.root ? "custom" : "null";
247
+ const margin = opts.rootMargin ?? "0px";
248
+ const threshold = Array.isArray(opts.threshold) ? opts.threshold.join(",") : String(opts.threshold ?? 0);
249
+ return root + "|" + margin + "|" + threshold;
250
+ }
251
+ /** Return an existing pool entry for this key, or create and register a new one. */
252
+ function getOrCreatePoolEntry(key, options) {
253
+ const existing = pool$1.get(key);
254
+ if (existing) return existing;
255
+ const entry = createPoolEntry$1(options);
256
+ pool$1.set(key, entry);
257
+ return entry;
258
+ }
259
+ /** Create a new pool entry for the given options. */
260
+ const createPoolEntry$1 = (options) => {
261
+ const callbacks = /* @__PURE__ */ new Map();
262
+ return {
263
+ observer: new IntersectionObserver((entries) => {
264
+ for (const ioEntry of entries) {
265
+ const cb = callbacks.get(ioEntry.target);
266
+ if (cb) cb(ioEntry);
267
+ }
268
+ }, options),
269
+ callbacks
270
+ };
271
+ };
272
+ //#endregion
273
+ //#region src/core/sight/index.ts
274
+ /**
275
+ * Visibility observer combining document focus and viewport intersection.
276
+ *
277
+ * `phase` is `'visible'` only when both the document is visible (not backgrounded)
278
+ * and the element is within the viewport. Uses a shared IntersectionObserver
279
+ * pool. Multiple `createSight` calls with the same options share one observer.
280
+ *
281
+ * @example
282
+ * const sight = createSight({
283
+ * element: el,
284
+ * onPhaseChange: (phase) => phase === 'visible' ? loop.start() : loop.pause(),
285
+ * });
286
+ * // cleanup:
287
+ * sight.stop();
288
+ *
289
+ * @remarks
290
+ * `onPhaseChange` fires only on phase transitions, not on every IntersectionObserver callback.
291
+ */
292
+ function createSight(options) {
293
+ if (typeof document === "undefined") serverContextError("createSight");
294
+ const { element, intersectionOptions, onPhaseChange, signal } = options;
295
+ if (!element) noElementError("createSight");
296
+ let _phase = "unknown";
297
+ let _reason = "initial";
298
+ let stopped = false;
299
+ let documentVisible = !document.hidden;
300
+ let elementInView = false;
301
+ function recompute(trigger) {
302
+ if (stopped) return;
303
+ const prev = _phase;
304
+ const next = documentVisible && elementInView ? "visible" : "hidden";
305
+ _reason = next === "hidden" && !documentVisible && !elementInView ? "all-hidden" : trigger;
306
+ if (next === prev) return;
307
+ _phase = next;
308
+ onPhaseChange?.(_phase, _reason);
309
+ }
310
+ function onVisibilityChange() {
311
+ documentVisible = !document.hidden;
312
+ recompute("document");
313
+ }
314
+ function onPageShow(event) {
315
+ if (!event.persisted) return;
316
+ documentVisible = true;
317
+ recompute("bfcache");
318
+ }
319
+ function onIntersection(entry) {
320
+ elementInView = entry.isIntersecting;
321
+ recompute("viewport");
322
+ }
323
+ document.addEventListener("visibilitychange", onVisibilityChange);
324
+ window.addEventListener("pageshow", onPageShow);
325
+ const unobserveIO = observeIntersection({
326
+ element,
327
+ onIntersect: onIntersection,
328
+ ...intersectionOptions
329
+ });
330
+ let unlinkAbort;
331
+ function stop() {
332
+ if (stopped) return;
333
+ stopped = true;
334
+ unlinkAbort?.();
335
+ document.removeEventListener("visibilitychange", onVisibilityChange);
336
+ window.removeEventListener("pageshow", onPageShow);
337
+ unobserveIO();
338
+ _phase = "hidden";
339
+ _reason = "initial";
340
+ }
341
+ unlinkAbort = linkAbortSignal(signal, stop);
342
+ return {
343
+ get phase() {
344
+ return stopped ? "hidden" : _phase;
345
+ },
346
+ get phaseReason() {
347
+ return _reason;
348
+ },
349
+ stop
350
+ };
351
+ }
352
+ //#endregion
353
+ //#region src/core/_internal/pool/mql-pool.ts
354
+ const pool = /* @__PURE__ */ new Map();
355
+ /**
356
+ * Subscribe to a media query via a shared MediaQueryList pool.
357
+ * Multiple subscribers to the same query share one MQL and one change listener.
358
+ *
359
+ * @returns Cleanup function that removes the subscriber.
360
+ */
361
+ function subscribeMediaQuery(query, callback) {
362
+ getOrCreateEntry(query).listeners.add(callback);
363
+ let disposed = false;
364
+ return () => {
365
+ if (disposed) return;
366
+ disposed = true;
367
+ const poolEntry = pool.get(query);
368
+ if (!poolEntry) return;
369
+ poolEntry.listeners.delete(callback);
370
+ if (poolEntry.listeners.size === 0) {
371
+ poolEntry.mql.removeEventListener("change", poolEntry.handler);
372
+ pool.delete(query);
373
+ }
374
+ };
375
+ }
376
+ /**
377
+ * Synchronous read of a media query via the shared pool.
378
+ * Uses the existing pool entry if available, otherwise reads directly.
379
+ */
380
+ function readMediaQuery(query) {
381
+ const entry = pool.get(query);
382
+ if (entry) return entry.mql.matches;
383
+ return matchMedia(query).matches;
384
+ }
385
+ /** Return an existing pool entry for this query, or create and register a new one. */
386
+ function getOrCreateEntry(query) {
387
+ const existing = pool.get(query);
388
+ if (existing) return existing;
389
+ const entry = createPoolEntry(query);
390
+ entry.mql.addEventListener("change", entry.handler);
391
+ pool.set(query, entry);
392
+ return entry;
393
+ }
394
+ const createPoolEntry = (query) => {
395
+ const mql = matchMedia(query);
396
+ const listeners = /* @__PURE__ */ new Set();
397
+ const handler = (event) => {
398
+ for (const cb of listeners) cb(event.matches);
399
+ };
400
+ return {
401
+ mql,
402
+ listeners,
403
+ handler
404
+ };
405
+ };
406
+ //#endregion
407
+ //#region src/core/lifecycle/index.ts
408
+ const REDUCED_MOTION_QUERY$1 = "(prefers-reduced-motion: reduce)";
409
+ /**
410
+ * The activation decision for an animation, decoupled from who drives the frames.
411
+ *
412
+ * Composes visibility (`createSight`), reduced motion, and a manual pause into a
413
+ * single `active` / `paused` phase. Use when you own your render loop (WebGL,
414
+ * three.js, a Web Worker, or non-rAF work that should still pause off-screen or
415
+ * under reduced motion). For loops `phase` should drive, use `createLoop` instead.
416
+ *
417
+ * @example
418
+ * const lifecycle = createLifecycle({
419
+ * element: canvas,
420
+ * onPhaseChange: (phase) => {
421
+ * if (phase === 'active') renderer.start();
422
+ * else renderer.stop();
423
+ * },
424
+ * });
425
+ * // cleanup:
426
+ * lifecycle.stop();
427
+ */
428
+ function createLifecycle(options) {
429
+ if (typeof document === "undefined") serverContextError("createLifecycle");
430
+ const { element, reducedMotion = "pause", intersectionOptions, start: startMode = "auto", onPhaseChange, signal } = options;
431
+ if (!element) noElementError("createLifecycle");
432
+ let _phase = "idle";
433
+ let _reason = "initial";
434
+ let sightVisible = false;
435
+ let reducedMotionActive = false;
436
+ let manualPaused = false;
437
+ let intentStarted = false;
438
+ let hasBeenActive = false;
439
+ function setPhase(phase, reason) {
440
+ if (_phase === phase && _reason === reason) return;
441
+ _phase = phase;
442
+ _reason = reason;
443
+ onPhaseChange?.(phase, reason);
444
+ }
445
+ /** Highest-priority active pause signal, or null when nothing should pause. */
446
+ function pauseReason() {
447
+ if (reducedMotionActive && reducedMotion === "pause") return "reduced-motion";
448
+ if (!sightVisible) return "sight";
449
+ if (manualPaused) return "manual";
450
+ return null;
451
+ }
452
+ function reconcile() {
453
+ if (_phase === "stopped" || !intentStarted) return;
454
+ const reason = pauseReason();
455
+ if (reason) {
456
+ setPhase("paused", reason);
457
+ return;
458
+ }
459
+ setPhase("active", hasBeenActive ? "resumed" : "started");
460
+ hasBeenActive = true;
461
+ }
462
+ function onSightChange(phase) {
463
+ sightVisible = phase === "visible";
464
+ reconcile();
465
+ }
466
+ function onReducedMotionChange(matches) {
467
+ reducedMotionActive = matches;
468
+ reconcile();
469
+ }
470
+ const sight = createSight({
471
+ element,
472
+ intersectionOptions,
473
+ onPhaseChange: onSightChange
474
+ });
475
+ let unsubReducedMotion = null;
476
+ if (reducedMotion !== "ignore") {
477
+ reducedMotionActive = readMediaQuery(REDUCED_MOTION_QUERY$1);
478
+ unsubReducedMotion = subscribeMediaQuery(REDUCED_MOTION_QUERY$1, onReducedMotionChange);
479
+ }
480
+ function start() {
481
+ if (_phase === "stopped") return;
482
+ intentStarted = true;
483
+ reconcile();
484
+ }
485
+ function stop() {
486
+ if (_phase === "stopped") return;
487
+ unlinkAbort?.();
488
+ sight.stop();
489
+ unsubReducedMotion?.();
490
+ unsubReducedMotion = null;
491
+ setPhase("stopped", "disposed");
492
+ }
493
+ function pause() {
494
+ if (manualPaused) return;
495
+ manualPaused = true;
496
+ reconcile();
497
+ }
498
+ function resume() {
499
+ if (!manualPaused) return;
500
+ manualPaused = false;
501
+ reconcile();
502
+ }
503
+ let unlinkAbort;
504
+ unlinkAbort = linkAbortSignal(signal, stop);
505
+ if (startMode === "auto") start();
506
+ return {
507
+ start,
508
+ stop,
509
+ pause,
510
+ resume,
511
+ get phase() {
512
+ return _phase;
513
+ },
514
+ get phaseReason() {
515
+ return _reason;
516
+ }
517
+ };
518
+ }
519
+ //#endregion
520
+ //#region src/core/loop/index.ts
521
+ /** How many consecutive over-budget frames before degrading quality. */
522
+ const OVER_BUDGET_THRESHOLD = 3;
523
+ /** FPS cap applied when quality is degraded (throttle mode). */
524
+ const DEGRADED_FPS_CAP = 30;
525
+ /**
526
+ * In `degraded: 'pause'` mode a frame-budget degrade pauses the loop, so no
527
+ * further frames tick to clear it. Without a timer the loop would stay paused
528
+ * forever after a transient spike. After this delay the loop optimistically
529
+ * un-pauses and re-measures, rescheduling on each subsequent degrade. (Throttle
530
+ * mode keeps ticking, so it does not use this.)
531
+ */
532
+ const RECOVERY_RETRY_MS = 2e3;
533
+ /**
534
+ * Lifecycle-aware animation loop composing ticker, visibility, and reduced motion.
535
+ *
536
+ * Pass an element and get a loop that pauses when the element leaves the viewport
537
+ * or the tab is backgrounded, resumes when it returns, and cleans up with `stop()`.
538
+ *
539
+ * @remarks
540
+ * The loop is signal-driven and exposes only `start()` and `stop()`. There is no
541
+ * imperative `pause()`/`resume()`. Pausing is decided by visibility, reduced
542
+ * motion, and quality, so an imperative pause would compete with those signals.
543
+ * For manual control, use `useLoop`'s `enabled` option (React) or `createLifecycle`,
544
+ * which exposes `pause()`/`resume()` for loops you drive yourself.
545
+ *
546
+ * @example
547
+ * const loop = createLoop({
548
+ * element: el,
549
+ * onTick: (frame) => draw(ctx, frame),
550
+ * });
551
+ * // cleanup:
552
+ * loop.stop();
553
+ */
554
+ function createLoop(options) {
555
+ if (typeof requestAnimationFrame === "undefined") serverContextError("createLoop");
556
+ const { element, onTick, fps: baseFps, reducedMotion = "pause", degraded = "throttle", degradedFps: configuredDegradedFps, intersectionOptions, start: startMode = "auto", onPhaseChange, signal } = options;
557
+ if (!element) noElementError("createLoop");
558
+ const degradedFps = degraded === "throttle" ? configuredDegradedFps : void 0;
559
+ let _phase = "idle";
560
+ let _reason = "initial";
561
+ let _quality = "full";
562
+ let _qualityReason;
563
+ let intentStarted = false;
564
+ let overBudgetCount = 0;
565
+ let ticker = null;
566
+ let focusDegraded = false;
567
+ let recoveryTimer = null;
568
+ function setPhase(phase, reason) {
569
+ if (_phase === phase && _reason === reason) return;
570
+ _phase = phase;
571
+ _reason = reason;
572
+ onPhaseChange?.(phase, reason);
573
+ }
574
+ function setQuality(quality, reason) {
575
+ const changed = _quality !== quality;
576
+ _quality = quality;
577
+ _qualityReason = reason;
578
+ if (!changed) return;
579
+ if (degraded === "throttle") {
580
+ if (ticker && _phase === "running") queueMicrotask(rebuildTicker);
581
+ } else if (degraded === "pause") reconcile();
582
+ }
583
+ /** Evaluate all quality signals and pick the highest-priority active one. */
584
+ function reconcileQuality() {
585
+ if (focusDegraded) {
586
+ setQuality("degraded", "unfocused");
587
+ return;
588
+ }
589
+ if (overBudgetCount >= OVER_BUDGET_THRESHOLD) {
590
+ setQuality("degraded", "frame-budget");
591
+ return;
592
+ }
593
+ setQuality("full");
594
+ }
595
+ /**
596
+ * Un-pause and re-measure after a pause-mode frame-budget degrade. If frames
597
+ * are still over budget, the next degrade reschedules this.
598
+ */
599
+ function scheduleBudgetRecovery() {
600
+ if (recoveryTimer !== null) return;
601
+ recoveryTimer = setTimeout(() => {
602
+ recoveryTimer = null;
603
+ overBudgetCount = 0;
604
+ reconcileQuality();
605
+ }, RECOVERY_RETRY_MS);
606
+ }
607
+ function clearBudgetRecovery() {
608
+ if (recoveryTimer === null) return;
609
+ clearTimeout(recoveryTimer);
610
+ recoveryTimer = null;
611
+ }
612
+ function getEffectiveFps() {
613
+ if (_quality !== "degraded") return baseFps;
614
+ const cap = degradedFps ?? DEGRADED_FPS_CAP;
615
+ if (baseFps === void 0) return cap;
616
+ return Math.min(baseFps, cap);
617
+ }
618
+ function destroyTicker() {
619
+ if (!ticker) return;
620
+ ticker.stop();
621
+ ticker = null;
622
+ }
623
+ function buildTicker() {
624
+ const targetFps = getEffectiveFps();
625
+ const budget = 1e3 / (targetFps ?? 60);
626
+ destroyTicker();
627
+ ticker = createTicker({
628
+ fps: targetFps,
629
+ onTick: (frame) => {
630
+ checkFrameBudget(frame.delta, budget);
631
+ onTick(frame);
632
+ }
633
+ });
634
+ ticker.start();
635
+ }
636
+ function rebuildTicker() {
637
+ if (_phase !== "running" || !ticker) return;
638
+ buildTicker();
639
+ }
640
+ function checkFrameBudget(delta, budget) {
641
+ if (delta <= budget * 1.5) {
642
+ overBudgetCount = 0;
643
+ return;
644
+ }
645
+ overBudgetCount++;
646
+ if (overBudgetCount < OVER_BUDGET_THRESHOLD) return;
647
+ reconcileQuality();
648
+ if (degraded === "pause") scheduleBudgetRecovery();
649
+ }
650
+ /** Check if any signal requires the loop to be paused. */
651
+ function shouldPause() {
652
+ if (lifecycle.phase === "paused") return lifecycle.phaseReason;
653
+ if (degraded === "pause" && _quality === "degraded") return "degraded";
654
+ return null;
655
+ }
656
+ /** Evaluate all signals and transition to the correct phase. */
657
+ function reconcile() {
658
+ if (_phase === "stopped" || !intentStarted) return;
659
+ const pauseReason = shouldPause();
660
+ if (pauseReason) {
661
+ if (ticker && _phase === "running") ticker.pause();
662
+ setPhase("paused", pauseReason);
663
+ return;
664
+ }
665
+ if (!ticker) {
666
+ buildTicker();
667
+ setPhase("running", _reason === "initial" ? "started" : "resumed");
668
+ } else if (_phase === "paused") {
669
+ ticker.resume();
670
+ setPhase("running", "resumed");
671
+ }
672
+ }
673
+ function onFocusChange() {
674
+ focusDegraded = !document.hasFocus();
675
+ reconcileQuality();
676
+ }
677
+ const lifecycle = createLifecycle({
678
+ element,
679
+ reducedMotion: reducedMotion === "pause" ? "pause" : "ignore",
680
+ intersectionOptions,
681
+ start: "manual",
682
+ onPhaseChange: reconcile
683
+ });
684
+ const unsubFocus = subscribeFocusTracking(onFocusChange);
685
+ function start() {
686
+ if (_phase === "stopped") return;
687
+ intentStarted = true;
688
+ lifecycle.start();
689
+ reconcile();
690
+ }
691
+ function stop() {
692
+ if (_phase === "stopped") return;
693
+ unlinkAbort?.();
694
+ clearBudgetRecovery();
695
+ destroyTicker();
696
+ lifecycle.stop();
697
+ unsubFocus();
698
+ setPhase("stopped", "disposed");
699
+ }
700
+ let unlinkAbort;
701
+ unlinkAbort = linkAbortSignal(signal, stop);
702
+ if (startMode === "auto") start();
703
+ return {
704
+ start,
705
+ stop,
706
+ get phase() {
707
+ return _phase;
708
+ },
709
+ get phaseReason() {
710
+ return _reason;
711
+ },
712
+ get quality() {
713
+ return _quality;
714
+ },
715
+ get qualityReason() {
716
+ return _qualityReason;
717
+ }
718
+ };
719
+ }
720
+ function subscribeFocusTracking(onChange) {
721
+ window.addEventListener("focus", onChange);
722
+ window.addEventListener("blur", onChange);
723
+ return () => {
724
+ window.removeEventListener("focus", onChange);
725
+ window.removeEventListener("blur", onChange);
726
+ };
727
+ }
728
+ //#endregion
729
+ //#region src/core/scroll-progress/index.ts
730
+ const DEFAULT_STEPS = 20;
731
+ const thresholdCache = /* @__PURE__ */ new Map();
732
+ function buildThresholds(steps) {
733
+ const cached = thresholdCache.get(steps);
734
+ if (cached) return cached;
735
+ const thresholds = [];
736
+ for (let i = 0; i <= steps; i++) thresholds.push(i / steps);
737
+ thresholdCache.set(steps, thresholds);
738
+ return thresholds;
739
+ }
740
+ /**
741
+ * Observe what fraction of an element is visible in the viewport (0–1).
742
+ *
743
+ * Uses the shared IntersectionObserver pool with multi-threshold options.
744
+ * Multiple instances with the same `steps` share a single IO.
745
+ *
746
+ * @example
747
+ * const progress = createScrollProgress({
748
+ * element: el,
749
+ * onProgress: (ratio) => {
750
+ * el.style.opacity = String(ratio);
751
+ * },
752
+ * });
753
+ * // cleanup:
754
+ * progress.stop();
755
+ */
756
+ function createScrollProgress(options) {
757
+ if (typeof IntersectionObserver === "undefined") serverContextError("createScrollProgress");
758
+ const { element, onProgress, steps = DEFAULT_STEPS, root, rootMargin, signal } = options;
759
+ if (!element) noElementError("createScrollProgress");
760
+ let _ratio = 0;
761
+ let stopped = false;
762
+ const unobserve = observeIntersection({
763
+ element,
764
+ onIntersect: (entry) => {
765
+ const newRatio = entry.intersectionRatio;
766
+ if (newRatio === _ratio) return;
767
+ _ratio = newRatio;
768
+ onProgress(newRatio);
769
+ },
770
+ root,
771
+ rootMargin,
772
+ threshold: buildThresholds(steps)
773
+ });
774
+ let unlinkAbort;
775
+ function stop() {
776
+ if (stopped) return;
777
+ stopped = true;
778
+ unlinkAbort?.();
779
+ unobserve();
780
+ }
781
+ unlinkAbort = linkAbortSignal(signal, stop);
782
+ return {
783
+ get ratio() {
784
+ return _ratio;
785
+ },
786
+ stop
787
+ };
788
+ }
789
+ //#endregion
790
+ //#region src/core/render-state/index.ts
791
+ /**
792
+ * Report whether the browser is rendering an element or skipping it under
793
+ * `content-visibility`. Listens to the `contentvisibilityautostatechange`
794
+ * event, the browser's ground-truth paint decision.
795
+ *
796
+ * Use it to pause raw, non-phase work (a hand-written rAF loop, `setInterval`,
797
+ * expensive effects) when a `Defer` subtree stops painting. phase's own loops
798
+ * already self-pause off-screen, so they do not need this.
799
+ *
800
+ * Listening and reacting has zero layout effect. It never breaks the
801
+ * no-layout-shift guarantee of `content-visibility`.
802
+ *
803
+ * @example
804
+ * const render = createRenderState({
805
+ * element: el,
806
+ * onPhaseChange: (phase) => phase === 'skipped' ? clock.pause() : clock.resume(),
807
+ * });
808
+ * // cleanup:
809
+ * render.stop();
810
+ *
811
+ * @remarks
812
+ * Where `content-visibility` is unsupported, `phase` stays `'rendered'`.
813
+ *
814
+ * Per the CSS Containment spec, `ResizeObserver` callbacks pause for elements
815
+ * inside a skipped `content-visibility: auto` subtree. Use this primitive to
816
+ * detect that transition when your code depends on size observations resuming.
817
+ */
818
+ function createRenderState(options) {
819
+ if (typeof document === "undefined") serverContextError("createRenderState");
820
+ const { element, onPhaseChange, signal } = options;
821
+ if (!element) noElementError("createRenderState");
822
+ let _phase = "rendered";
823
+ let stopped = false;
824
+ function onStateChange(event) {
825
+ if (stopped) return;
826
+ const next = event.skipped ? "skipped" : "rendered";
827
+ if (next === _phase) return;
828
+ _phase = next;
829
+ onPhaseChange?.(_phase);
830
+ }
831
+ element.addEventListener("contentvisibilityautostatechange", onStateChange);
832
+ let unlinkAbort;
833
+ function stop() {
834
+ if (stopped) return;
835
+ stopped = true;
836
+ unlinkAbort?.();
837
+ element.removeEventListener("contentvisibilityautostatechange", onStateChange);
838
+ }
839
+ unlinkAbort = linkAbortSignal(signal, stop);
840
+ return {
841
+ get phase() {
842
+ return _phase;
843
+ },
844
+ stop
845
+ };
846
+ }
847
+ //#endregion
848
+ //#region src/core/_internal/pool/dpr.ts
849
+ const listeners = /* @__PURE__ */ new Set();
850
+ let currentDpr = 1;
851
+ let mql = null;
852
+ let handler = null;
853
+ /**
854
+ * Subscribe to devicePixelRatio changes (e.g. user drags window between monitors).
855
+ *
856
+ * Uses a single shared `matchMedia` query that re-subscribes on every DPR change,
857
+ * so chained monitor switches (A -> B -> C) are all caught.
858
+ *
859
+ * @returns Cleanup function that removes the subscriber.
860
+ */
861
+ function subscribeDpr(callback) {
862
+ listeners.add(callback);
863
+ if (listeners.size === 1) bind();
864
+ let disposed = false;
865
+ return () => {
866
+ if (disposed) return;
867
+ disposed = true;
868
+ listeners.delete(callback);
869
+ if (listeners.size === 0) unbind();
870
+ };
871
+ }
872
+ /** Read the current devicePixelRatio. */
873
+ function readDpr() {
874
+ if (typeof window === "undefined") return 1;
875
+ return window.devicePixelRatio || 1;
876
+ }
877
+ function bind() {
878
+ if (typeof matchMedia === "undefined") return;
879
+ currentDpr = window.devicePixelRatio || 1;
880
+ mql = matchMedia(`(resolution: ${currentDpr}dppx)`);
881
+ handler = onDprChange;
882
+ mql.addEventListener("change", handler);
883
+ }
884
+ function unbind() {
885
+ if (mql && handler) mql.removeEventListener("change", handler);
886
+ mql = null;
887
+ handler = null;
888
+ }
889
+ function onDprChange() {
890
+ const newDpr = window.devicePixelRatio || 1;
891
+ if (newDpr === currentDpr) return;
892
+ currentDpr = newDpr;
893
+ unbind();
894
+ bind();
895
+ for (const cb of listeners) cb(newDpr);
896
+ }
897
+ //#endregion
898
+ //#region src/core/idle/index.ts
899
+ /** Fallback delay when `requestIdleCallback` is unavailable (e.g. Safari). */
900
+ const FALLBACK_DELAY = 1;
901
+ /**
902
+ * Run a callback once the browser is idle. Wraps `requestIdleCallback`, falling
903
+ * back to a near-immediate `setTimeout` where it is unavailable (Safari).
904
+ *
905
+ * Returns a cancel function. Calling it before the callback runs prevents it.
906
+ *
907
+ * @example
908
+ * const cancel = whenIdle(() => warmCache(), { timeout: 2000 });
909
+ * // later, if no longer needed:
910
+ * cancel();
911
+ */
912
+ function whenIdle(callback, options) {
913
+ if (typeof window === "undefined") serverContextError("whenIdle");
914
+ const { timeout, signal } = options ?? {};
915
+ if (signal?.aborted) return () => {};
916
+ let cancel;
917
+ if (typeof window.requestIdleCallback === "function") {
918
+ const cancelIdle = window.cancelIdleCallback.bind(window);
919
+ const handle = window.requestIdleCallback(() => callback(), timeout === void 0 ? void 0 : { timeout });
920
+ cancel = () => cancelIdle(handle);
921
+ } else {
922
+ const handle = setTimeout(callback, FALLBACK_DELAY);
923
+ cancel = () => clearTimeout(handle);
924
+ }
925
+ const unlinkAbort = linkAbortSignal(signal, cancel);
926
+ return () => {
927
+ unlinkAbort();
928
+ cancel();
929
+ };
930
+ }
931
+ //#endregion
932
+ //#region src/core/reduced-motion/index.ts
933
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
934
+ /**
935
+ * Synchronous check for `prefers-reduced-motion: reduce`.
936
+ *
937
+ * Returns `false` on the server (no `matchMedia`). On the client, reads from
938
+ * the shared MQL pool so the underlying `MediaQueryList` is reused across
939
+ * all callers.
940
+ */
941
+ function prefersReducedMotion() {
942
+ if (typeof matchMedia === "undefined") return false;
943
+ return readMediaQuery(REDUCED_MOTION_QUERY);
944
+ }
945
+ //#endregion
946
+ export { missingContextError as _, subscribeDpr as a, createLoop as c, subscribeMediaQuery as d, createSight as f, isPhaseError as g, invalidDurationError as h, readDpr as i, createLifecycle as l, PhaseError as m, prefersReducedMotion as n, createRenderState as o, createTicker as p, whenIdle as r, createScrollProgress as s, REDUCED_MOTION_QUERY as t, readMediaQuery as u, serverContextError as v, linkAbortSignal as y };
947
+
948
+ //# sourceMappingURL=reduced-motion-CEJtegNG.js.map