solid-drift 0.12.0 → 0.13.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/README.md CHANGED
@@ -1130,13 +1130,28 @@ success("Payment sent", { description: "0.5 SOL to alice.sol" })
1130
1130
 
1131
1131
  Push helpers: `toast(title, options?)`, `info(...)`, `success(...)`, `warning(...)`, `error(...)`. Each returns the toast id. Per-toast options: `kind`, `description`, `duration` (ms; `0` means sticky). `dismiss(id)` starts the leave transition for one toast; `clear()` dismisses all. SSR-safe: toasts pushed on the server start `"visible"`. Under reduced motion the enter and leave transitions are instant, but auto-dismiss timing still applies.
1132
1132
 
1133
+ ### `useLowPowerMode(options?)`
1134
+
1135
+ One reactive signal for mobile-first degradation. It combines the OS `prefers-reduced-motion` and `prefers-reduced-data` media queries with low-end device signals (`navigator.deviceMemory`, `navigator.hardwareConcurrency`), so a single check covers user preference, network thrift, and weak hardware. The media queries update live; the device signals are sampled once. There is also a one-shot `isLowPowerMode(options?)` for non-reactive checks.
1136
+
1137
+ ```tsx
1138
+ import { useLowPowerMode } from "solid-drift"
1139
+
1140
+ const lowPower = useLowPowerMode()
1141
+ // Degrade gracefully: shorter, cheaper motion on weak devices.
1142
+ const duration = () => (lowPower() ? 0 : 400)
1143
+ const confettiCount = () => (lowPower() ? 20 : 150)
1144
+ ```
1145
+
1146
+ Options: `maxDeviceMemory` (GB, default `4`), `maxHardwareConcurrency` (default `4`): a device at or below either threshold counts as low-end. Where the device signals are unsupported they degrade to "not low-end". SSR-safe: always `false` on the server.
1147
+
1133
1148
  ### Easings
1134
1149
 
1135
1150
  Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
1136
1151
 
1137
1152
  ## How it works
1138
1153
 
1139
- One shared `requestAnimationFrame` loop drives every animation in the app, so hundreds of springs cost a single rAF tick per frame. Springs integrate with semi-implicit Euler, tweens sample an easing curve. Everything is SSR-safe (animations simply don't run on the server).
1154
+ One shared `requestAnimationFrame` loop drives every animation in the app, so hundreds of springs cost a single rAF tick per frame. Springs integrate with semi-implicit Euler, tweens sample an easing curve. When the tab becomes hidden the engine pauses the loop and freezes its clock, so nothing burns battery in the background; on return the clock continues where it left off and in-flight animations resume seamlessly. Everything is SSR-safe (animations simply don't run on the server).
1140
1155
 
1141
1156
  ## License
1142
1157
 
package/dist/engine.d.ts CHANGED
@@ -4,6 +4,11 @@
4
4
  * A single requestAnimationFrame loop drives every active animation in the
5
5
  * app, so hundreds of springs and tweens cost exactly one rAF tick per frame.
6
6
  * Tasks return `false` when finished and are removed automatically.
7
+ *
8
+ * Battery saving: when the tab becomes hidden the loop stops and the clock
9
+ * freezes, so no animation burns CPU in the background. When the tab is
10
+ * visible again the loop resumes and the clock continues where it left off,
11
+ * so in-flight animations continue seamlessly instead of jumping forward.
7
12
  */
8
13
  export type AnimationTask = (now: number) => boolean;
9
14
  /**
@@ -11,5 +16,8 @@ export type AnimationTask = (now: number) => boolean;
11
16
  * Safe to call during SSR (no-op without requestAnimationFrame).
12
17
  */
13
18
  export declare function schedule(task: AnimationTask): () => void;
14
- /** Monotonic clock in milliseconds. */
19
+ /**
20
+ * Monotonic clock in milliseconds. Freezes while the tab is hidden and
21
+ * resumes where it left off, so animations never observe the hidden gap.
22
+ */
15
23
  export declare function now(): number;
package/dist/engine.js CHANGED
@@ -4,14 +4,31 @@
4
4
  * A single requestAnimationFrame loop drives every active animation in the
5
5
  * app, so hundreds of springs and tweens cost exactly one rAF tick per frame.
6
6
  * Tasks return `false` when finished and are removed automatically.
7
+ *
8
+ * Battery saving: when the tab becomes hidden the loop stops and the clock
9
+ * freezes, so no animation burns CPU in the background. When the tab is
10
+ * visible again the loop resumes and the clock continues where it left off,
11
+ * so in-flight animations continue seamlessly instead of jumping forward.
7
12
  */
8
13
  const tasks = new Set();
9
14
  let rafId = 0;
10
- function tick(now) {
15
+ // Milliseconds of hidden time subtracted from the raw clock, so now()
16
+ // freezes while the tab is hidden and resumes seamlessly on return.
17
+ let pausedMs = 0;
18
+ // Raw timestamp of the moment the tab was hidden, or null while visible.
19
+ let hiddenAt = null;
20
+ let visibilityHooked = false;
21
+ function rawNow() {
22
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
23
+ }
24
+ function tick() {
25
+ // Tasks always see the pause-adjusted clock, never the raw rAF stamp,
26
+ // so a hidden-then-visible gap never shows up as a time jump.
27
+ const t = now();
11
28
  for (const task of tasks) {
12
29
  let alive = false;
13
30
  try {
14
- alive = task(now);
31
+ alive = task(t);
15
32
  }
16
33
  catch {
17
34
  alive = false;
@@ -21,6 +38,39 @@ function tick(now) {
21
38
  }
22
39
  rafId = tasks.size > 0 ? requestAnimationFrame(tick) : 0;
23
40
  }
41
+ function onVisibilityChange() {
42
+ if (typeof document === "undefined")
43
+ return;
44
+ if (document.hidden) {
45
+ if (hiddenAt === null) {
46
+ hiddenAt = rawNow();
47
+ if (rafId) {
48
+ if (typeof cancelAnimationFrame !== "undefined") {
49
+ cancelAnimationFrame(rafId);
50
+ }
51
+ rafId = 0;
52
+ }
53
+ }
54
+ }
55
+ else if (hiddenAt !== null) {
56
+ pausedMs += rawNow() - hiddenAt;
57
+ hiddenAt = null;
58
+ if (tasks.size > 0 &&
59
+ !rafId &&
60
+ typeof requestAnimationFrame !== "undefined") {
61
+ rafId = requestAnimationFrame(tick);
62
+ }
63
+ }
64
+ }
65
+ function hookVisibility() {
66
+ if (visibilityHooked)
67
+ return;
68
+ visibilityHooked = true;
69
+ if (typeof document !== "undefined" &&
70
+ typeof document.addEventListener === "function") {
71
+ document.addEventListener("visibilitychange", onVisibilityChange);
72
+ }
73
+ }
24
74
  /**
25
75
  * Register a task on the shared clock. Returns a cancel function.
26
76
  * Safe to call during SSR (no-op without requestAnimationFrame).
@@ -28,14 +78,19 @@ function tick(now) {
28
78
  export function schedule(task) {
29
79
  if (typeof requestAnimationFrame === "undefined")
30
80
  return () => { };
81
+ hookVisibility();
31
82
  tasks.add(task);
32
- if (!rafId)
83
+ const hidden = typeof document !== "undefined" && document.hidden === true;
84
+ if (!rafId && !hidden)
33
85
  rafId = requestAnimationFrame(tick);
34
86
  return () => {
35
87
  tasks.delete(task);
36
88
  };
37
89
  }
38
- /** Monotonic clock in milliseconds. */
90
+ /**
91
+ * Monotonic clock in milliseconds. Freezes while the tab is hidden and
92
+ * resumes where it left off, so animations never observe the hidden gap.
93
+ */
39
94
  export function now() {
40
- return typeof performance !== "undefined" ? performance.now() : Date.now();
95
+ return (hiddenAt ?? rawNow()) - pausedMs;
41
96
  }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export { drift, type DriftProps } from "./directive.js";
11
11
  export { createScrollProgress, type ScrollTarget } from "./scroll.js";
12
12
  export { createInView, type InViewOptions } from "./inview.js";
13
13
  export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion.js";
14
+ export { isLowPowerMode, useLowPowerMode, type LowPowerOptions, } from "./power.js";
14
15
  export { createStagger } from "./stagger.js";
15
16
  export { createHorizontalScroll, type HorizontalScrollOptions, type HorizontalScrollResult, } from "./horizontal.js";
16
17
  export { createScrub, type ScrubKeyframe, type ScrubOptions } from "./scrub.js";
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { drift } from "./directive.js";
11
11
  export { createScrollProgress } from "./scroll.js";
12
12
  export { createInView } from "./inview.js";
13
13
  export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion.js";
14
+ export { isLowPowerMode, useLowPowerMode, } from "./power.js";
14
15
  export { createStagger } from "./stagger.js";
15
16
  export { createHorizontalScroll, } from "./horizontal.js";
16
17
  export { createScrub } from "./scrub.js";
@@ -0,0 +1,44 @@
1
+ import { type Accessor } from "solid-js";
2
+ export interface LowPowerOptions {
3
+ /** deviceMemory in GB at or below which the device counts as low-end. Default 4. */
4
+ maxDeviceMemory?: number;
5
+ /** hardwareConcurrency at or below which the device counts as low-end. Default 4. */
6
+ maxHardwareConcurrency?: number;
7
+ }
8
+ /**
9
+ * Non-reactive check: should the app conserve power and motion right now?
10
+ *
11
+ * True when any of these hold: the OS prefers reduced motion, the user
12
+ * asked to reduce data usage, or the device looks low-end (small
13
+ * deviceMemory or few CPU cores, both Chrome-only signals that degrade
14
+ * to "not low-end" where unsupported).
15
+ *
16
+ * SSR-safe: always `false` on the server. Pair with the reduced-motion
17
+ * behavior of the primitives: when this is true, prefer shorter or
18
+ * zero durations and skip decorative motion.
19
+ *
20
+ * ```ts
21
+ * if (isLowPowerMode()) {
22
+ * // skip the ambient background animation
23
+ * }
24
+ * ```
25
+ */
26
+ export declare function isLowPowerMode(options?: LowPowerOptions): boolean;
27
+ /**
28
+ * Reactive low-power signal for mobile-first degradation.
29
+ *
30
+ * Combines `prefers-reduced-motion`, `prefers-reduced-data`, and low-end
31
+ * device signals into one accessor. The media queries update live if the
32
+ * OS preference changes while the app is running; the device signals are
33
+ * sampled once. SSR-safe: `false` on the server.
34
+ *
35
+ * ```tsx
36
+ * import { isLowPowerMode, useLowPowerMode } from "solid-drift"
37
+ *
38
+ * const lowPower = useLowPowerMode()
39
+ * // Degrade gracefully: shorter, cheaper motion on weak devices.
40
+ * const duration = () => (lowPower() ? 0 : 400)
41
+ * const confettiCount = () => (lowPower() ? 20 : 150)
42
+ * ```
43
+ */
44
+ export declare function useLowPowerMode(options?: LowPowerOptions): Accessor<boolean>;
package/dist/power.js ADDED
@@ -0,0 +1,83 @@
1
+ import { createSignal, onCleanup } from "solid-js";
2
+ const MOTION_QUERY = "(prefers-reduced-motion: reduce)";
3
+ const DATA_QUERY = "(prefers-reduced-data: reduce)";
4
+ function mediaMatches(query) {
5
+ return (typeof window !== "undefined" &&
6
+ typeof window.matchMedia === "function" &&
7
+ window.matchMedia(query).matches);
8
+ }
9
+ function isLowEndDevice(options) {
10
+ if (typeof navigator === "undefined")
11
+ return false;
12
+ const { maxDeviceMemory = 4, maxHardwareConcurrency = 4 } = options;
13
+ const memory = typeof navigator.deviceMemory ===
14
+ "number"
15
+ ? navigator.deviceMemory
16
+ : Infinity;
17
+ const cores = typeof navigator.hardwareConcurrency === "number"
18
+ ? navigator.hardwareConcurrency
19
+ : Infinity;
20
+ return memory <= maxDeviceMemory || cores <= maxHardwareConcurrency;
21
+ }
22
+ function checkLowPower(options) {
23
+ return (mediaMatches(MOTION_QUERY) ||
24
+ mediaMatches(DATA_QUERY) ||
25
+ isLowEndDevice(options));
26
+ }
27
+ /**
28
+ * Non-reactive check: should the app conserve power and motion right now?
29
+ *
30
+ * True when any of these hold: the OS prefers reduced motion, the user
31
+ * asked to reduce data usage, or the device looks low-end (small
32
+ * deviceMemory or few CPU cores, both Chrome-only signals that degrade
33
+ * to "not low-end" where unsupported).
34
+ *
35
+ * SSR-safe: always `false` on the server. Pair with the reduced-motion
36
+ * behavior of the primitives: when this is true, prefer shorter or
37
+ * zero durations and skip decorative motion.
38
+ *
39
+ * ```ts
40
+ * if (isLowPowerMode()) {
41
+ * // skip the ambient background animation
42
+ * }
43
+ * ```
44
+ */
45
+ export function isLowPowerMode(options = {}) {
46
+ return checkLowPower(options);
47
+ }
48
+ /**
49
+ * Reactive low-power signal for mobile-first degradation.
50
+ *
51
+ * Combines `prefers-reduced-motion`, `prefers-reduced-data`, and low-end
52
+ * device signals into one accessor. The media queries update live if the
53
+ * OS preference changes while the app is running; the device signals are
54
+ * sampled once. SSR-safe: `false` on the server.
55
+ *
56
+ * ```tsx
57
+ * import { isLowPowerMode, useLowPowerMode } from "solid-drift"
58
+ *
59
+ * const lowPower = useLowPowerMode()
60
+ * // Degrade gracefully: shorter, cheaper motion on weak devices.
61
+ * const duration = () => (lowPower() ? 0 : 400)
62
+ * const confettiCount = () => (lowPower() ? 20 : 150)
63
+ * ```
64
+ */
65
+ export function useLowPowerMode(options = {}) {
66
+ const lowEnd = isLowEndDevice(options);
67
+ const [lowPower, setLowPower] = createSignal(checkLowPower(options));
68
+ if (typeof window !== "undefined" &&
69
+ typeof window.matchMedia === "function") {
70
+ const motionMq = window.matchMedia(MOTION_QUERY);
71
+ const dataMq = window.matchMedia(DATA_QUERY);
72
+ const onChange = () => {
73
+ setLowPower(lowEnd || motionMq.matches || dataMq.matches);
74
+ };
75
+ motionMq.addEventListener("change", onChange);
76
+ dataMq.addEventListener("change", onChange);
77
+ onCleanup(() => {
78
+ motionMq.removeEventListener("change", onChange);
79
+ dataMq.removeEventListener("change", onChange);
80
+ });
81
+ }
82
+ return lowPower;
83
+ }
package/package.json CHANGED
@@ -43,5 +43,5 @@
43
43
  },
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "0.12.0"
46
+ "version": "0.13.0"
47
47
  }