@checkstack/ui 1.3.1 → 1.3.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @checkstack/ui
2
2
 
3
+ ## 1.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 594eecc: Implemented a manual "Low Power Mode" toggle in the user menu, allowing users to explicitly disable expensive visual effects. This replaces the previous automatic performance diagnostics with a more predictable, user-controlled system that persists to localStorage while still respecting OS-level "Reduced Motion" settings.
8
+
9
+ ## 1.3.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 0388000: Implemented a global performance-aware UI infrastructure that detects hardware capabilities (using heuristics and frame-budget benchmarks) to automatically disable expensive CSS animations, backdrop-blurs, and glassmorphism effects on low-power or non-hardware-accelerated devices.
14
+
3
15
  ## 1.3.1
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/ui",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "dependencies": {
@@ -1,5 +1,6 @@
1
- import React from "react";
1
+ import React, { useMemo } from "react";
2
2
  import { cn } from "../utils";
3
+ import { usePerformance } from "./PerformanceProvider";
3
4
 
4
5
  interface AmbientBackgroundProps {
5
6
  children: React.ReactNode;
@@ -7,58 +8,78 @@ interface AmbientBackgroundProps {
7
8
  }
8
9
 
9
10
  /**
10
- * AmbientBackground - High-performance background pattern
11
- * Features an "Inverse Glow Grid" where aurora effects shine through transparent grid lines.
12
- * Provides a premium "glowing grid" aesthetic with minimal performance impact.
11
+ * AmbientBackground - Premium background with performance-aware fallbacks.
12
+ * Features an "Inverse Glow Grid" where aurora effects shine through transparency.
13
+ * Automatically downgrades to a static grid on devices that struggle with CSS blurs.
13
14
  */
14
15
  export const AmbientBackground: React.FC<AmbientBackgroundProps> = ({
15
16
  children,
16
17
  className,
17
18
  }) => {
18
- return (
19
- <div
20
- className={cn(
21
- "relative min-h-screen bg-background overflow-hidden",
22
- className
23
- )}
24
- >
25
- {/* Performance optimized background layers */}
26
- <div className="pointer-events-none fixed inset-0 overflow-hidden">
27
- {/* Layer 1: Aurora Blobs (Bottom) - Hardware accelerated via transform/compositor */}
19
+ const { isLowPower } = usePerformance();
20
+
21
+ // Optimized Aurora Layers - only render if not in low power mode
22
+ const auroraBlobs = useMemo(() => {
23
+ if (isLowPower) return;
24
+ return (
25
+ <>
28
26
  <div
29
27
  className="aurora-blob absolute w-[50%] h-[50%] -top-[10%] -left-[10%]"
30
28
  style={{
31
- background: "radial-gradient(circle at center, hsl(var(--primary) / 0.8), transparent 60%)",
29
+ background:
30
+ "radial-gradient(circle at center, hsl(var(--primary) / 0.8), transparent 60%)",
32
31
  animation: "aurora-float-1 25s ease-in-out infinite",
33
32
  }}
34
33
  />
35
34
  <div
36
35
  className="aurora-blob absolute w-[40%] h-[40%] bottom-[10%] right-[10%]"
37
36
  style={{
38
- background: "radial-gradient(circle at center, hsl(var(--chart-2) / 0.7), transparent 60%)",
37
+ background:
38
+ "radial-gradient(circle at center, hsl(var(--chart-2) / 0.7), transparent 60%)",
39
39
  animation: "aurora-float-2 20s ease-in-out infinite",
40
40
  }}
41
41
  />
42
42
  <div
43
43
  className="aurora-blob absolute w-[35%] h-[35%] top-[30%] left-[40%]"
44
44
  style={{
45
- background: "radial-gradient(circle at center, hsl(var(--primary) / 0.6), transparent 60%)",
45
+ background:
46
+ "radial-gradient(circle at center, hsl(var(--primary) / 0.6), transparent 60%)",
46
47
  animation: "aurora-float-3 30s ease-in-out infinite",
47
48
  }}
48
49
  />
49
50
  <div
50
51
  className="aurora-blob absolute w-[45%] h-[45%] bottom-[20%] left-[10%]"
51
52
  style={{
52
- background: "radial-gradient(circle at center, hsl(var(--chart-1) / 0.5), transparent 60%)",
53
+ background:
54
+ "radial-gradient(circle at center, hsl(var(--chart-1) / 0.5), transparent 60%)",
53
55
  animation: "aurora-float-4 35s ease-in-out infinite",
54
56
  }}
55
57
  />
58
+ </>
59
+ );
60
+ }, [isLowPower]);
61
+
62
+ return (
63
+ <div
64
+ className={cn(
65
+ "relative min-h-screen bg-background overflow-hidden",
66
+ className,
67
+ )}
68
+ >
69
+ <div className="pointer-events-none fixed inset-0 overflow-hidden">
70
+ {/* Layer 1: Aurora Blobs (Bottom) */}
71
+ {auroraBlobs}
56
72
 
57
- {/* Layer 2: Inverse Grid Mask (Top) - Transparent lines on a solid background */}
58
- <div className="ambient-grid-inverse absolute inset-0 overflow-hidden" />
73
+ {/* Layer 2: Grid Mask - Switches mode based on performance capability */}
74
+ <div
75
+ className={cn(
76
+ isLowPower ? "ambient-grid" : "ambient-grid-inverse",
77
+ "absolute inset-0 overflow-hidden",
78
+ )}
79
+ />
59
80
  </div>
60
81
 
61
- {/* Layer 3: Edge vignette to fade out the background smoothly */}
82
+ {/* Layer 3: Edge vignette fade */}
62
83
  <div
63
84
  className="pointer-events-none fixed inset-0"
64
85
  style={{
@@ -69,7 +90,6 @@ export const AmbientBackground: React.FC<AmbientBackgroundProps> = ({
69
90
  }}
70
91
  />
71
92
 
72
- {/* Content */}
73
93
  <div className="relative z-10">{children}</div>
74
94
  </div>
75
95
  );
@@ -1,4 +1,5 @@
1
1
  import React, { useEffect, useState, useRef } from "react";
2
+ import { usePerformance } from "./PerformanceProvider";
2
3
 
3
4
  interface AnimatedCounterProps {
4
5
  value: number;
@@ -20,15 +21,17 @@ export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({
20
21
  const [displayValue, setDisplayValue] = useState(0);
21
22
  const displayValueRef = useRef(displayValue);
22
23
  displayValueRef.current = displayValue;
24
+
25
+ const { isLowPower } = usePerformance();
23
26
 
24
27
  useEffect(() => {
25
- // Skip animation if value is 0 or duration is 0
26
- if (value === 0 || duration === 0) {
28
+ // Skip animation if value is 0, duration is 0, or in low-power mode
29
+ if (value === 0 || duration === 0 || isLowPower) {
27
30
  setDisplayValue(value);
28
31
  return;
29
32
  }
30
33
 
31
- const startTime = performance.now();
34
+ const startTime = globalThis.performance.now();
32
35
  const startValue = displayValueRef.current;
33
36
  const diff = value - startValue;
34
37
 
@@ -48,7 +51,7 @@ export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({
48
51
  };
49
52
 
50
53
  requestAnimationFrame(animate);
51
- }, [value, duration]);
54
+ }, [value, duration, isLowPower]);
52
55
 
53
56
  return <span className={className}>{formatter(displayValue)}</span>;
54
57
  };
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
  import { cn } from "../utils";
3
3
  import { Command } from "lucide-react";
4
+ import { usePerformance } from "./PerformanceProvider";
4
5
 
5
6
  interface CommandPaletteProps {
6
7
  onClick?: () => void;
@@ -17,6 +18,8 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
17
18
  placeholder = "Search systems, incidents, or run commands...",
18
19
  className,
19
20
  }) => {
21
+ const { isLowPower } = usePerformance();
22
+
20
23
  return (
21
24
  <button
22
25
  onClick={onClick}
@@ -24,18 +27,20 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
24
27
  // Base styles
25
28
  "w-full flex items-center gap-3 px-4 py-3 rounded-xl",
26
29
  // Glassmorphism effect
27
- "bg-card/50 backdrop-blur-sm border border-border/50",
30
+ isLowPower ? "bg-card" : "bg-card/50 backdrop-blur-sm",
31
+ "border border-border/50",
28
32
  // Glow and shadow
29
33
  "shadow-lg shadow-primary/5 hover:shadow-xl hover:shadow-primary/10",
30
34
  // Hover state
31
35
  "hover:border-primary/30 hover:bg-card/70",
32
36
  // Transition
33
- "transition-all duration-300 ease-out",
37
+ "transition-all",
38
+ !isLowPower && "duration-300 ease-out",
34
39
  // Focus ring
35
40
  "focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background",
36
41
  // Cursor
37
42
  "cursor-text",
38
- className
43
+ className,
39
44
  )}
40
45
  >
41
46
  {/* Search icon */}
@@ -63,7 +68,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
63
68
  className={cn(
64
69
  "hidden sm:flex items-center gap-1 px-2 py-1 rounded-md",
65
70
  "bg-muted/50 border border-border/50",
66
- "text-xs text-muted-foreground font-mono"
71
+ "text-xs text-muted-foreground font-mono",
67
72
  )}
68
73
  >
69
74
  <Command className="w-3 h-3" />
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import * as DialogPrimitive from "@radix-ui/react-dialog";
3
3
  import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { cn } from "../utils";
5
+ import { usePerformance } from "./PerformanceProvider";
5
6
 
6
7
  const Dialog = DialogPrimitive.Root;
7
8
 
@@ -14,20 +15,24 @@ const DialogClose = DialogPrimitive.Close;
14
15
  const DialogOverlay = React.forwardRef<
15
16
  React.ElementRef<typeof DialogPrimitive.Overlay>,
16
17
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
17
- >(({ className, ...props }, ref) => (
18
- <DialogPrimitive.Overlay
19
- ref={ref}
20
- className={cn(
21
- "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
- className,
23
- )}
24
- {...props}
25
- />
26
- ));
18
+ >(({ className, ...props }, ref) => {
19
+ const { isLowPower } = usePerformance();
20
+ return (
21
+ <DialogPrimitive.Overlay
22
+ ref={ref}
23
+ className={cn(
24
+ "fixed inset-0 z-50 bg-black/50",
25
+ !isLowPower && "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
26
+ className,
27
+ )}
28
+ {...props}
29
+ />
30
+ );
31
+ });
27
32
  DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
28
33
 
29
34
  const dialogContentVariants = cva(
30
- "fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background text-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[85vh] overflow-y-auto overflow-x-visible",
35
+ "fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background text-foreground p-6 shadow-lg sm:rounded-lg max-h-[85vh] overflow-y-auto overflow-x-visible",
31
36
  {
32
37
  variants: {
33
38
  size: {
@@ -52,19 +57,26 @@ interface DialogContentProps
52
57
  const DialogContent = React.forwardRef<
53
58
  React.ElementRef<typeof DialogPrimitive.Content>,
54
59
  DialogContentProps
55
- >(({ className, children, size, ...props }, ref) => (
56
- <DialogPortal>
57
- <DialogOverlay />
58
- <DialogPrimitive.Content
59
- ref={ref}
60
- className={cn(dialogContentVariants({ size }), className)}
61
- {...props}
62
- >
60
+ >(({ className, children, size, ...props }, ref) => {
61
+ const { isLowPower } = usePerformance();
62
+ return (
63
+ <DialogPortal>
64
+ <DialogOverlay />
65
+ <DialogPrimitive.Content
66
+ ref={ref}
67
+ className={cn(
68
+ dialogContentVariants({ size }),
69
+ !isLowPower && "duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
70
+ className
71
+ )}
72
+ {...props}
73
+ >
63
74
  {/* Wrapper with negative margin and positive padding to allow focus rings to extend */}
64
75
  <div className="-mx-2 px-2">{children}</div>
65
76
  </DialogPrimitive.Content>
66
- </DialogPortal>
67
- ));
77
+ </DialogPortal>
78
+ );
79
+ });
68
80
  DialogContent.displayName = DialogPrimitive.Content.displayName;
69
81
 
70
82
  const DialogHeader = ({
@@ -1,5 +1,6 @@
1
1
  import React, { useRef, useEffect } from "react";
2
2
  import { cn } from "../utils";
3
+ import { usePerformance } from "./PerformanceProvider";
3
4
 
4
5
  export const DropdownMenu: React.FC<{ children: React.ReactNode }> = ({
5
6
  children,
@@ -25,6 +26,7 @@ export const DropdownMenuContent: React.FC<{
25
26
  onClose: () => void;
26
27
  className?: string;
27
28
  }> = ({ children, isOpen, onClose, className }) => {
29
+ const { isLowPower } = usePerformance();
28
30
  const contentRef = useRef<HTMLDivElement>(null);
29
31
 
30
32
  useEffect(() => {
@@ -51,7 +53,8 @@ export const DropdownMenuContent: React.FC<{
51
53
  <div
52
54
  ref={contentRef}
53
55
  className={cn(
54
- "absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-popover shadow-lg ring-1 ring-border focus:outline-none z-[100] animate-in fade-in zoom-in-95 duration-100",
56
+ "absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-popover shadow-lg ring-1 ring-border focus:outline-none z-[100]",
57
+ !isLowPower && "animate-in fade-in zoom-in-95 duration-100",
55
58
  className
56
59
  )}
57
60
  >
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { Loader2, ChevronDown } from "lucide-react";
3
+ import { cn } from "../../utils";
3
4
 
4
5
  import {
5
6
  Input,
@@ -9,6 +10,7 @@ import {
9
10
  SelectItem,
10
11
  SelectTrigger,
11
12
  SelectValue,
13
+ usePerformance,
12
14
  } from "../../index";
13
15
 
14
16
  import type { DynamicOptionsFieldProps, ResolverOption } from "./types";
@@ -33,6 +35,7 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
33
35
  optionsResolvers,
34
36
  onChange,
35
37
  }) => {
38
+ const { isLowPower } = usePerformance();
36
39
  const [options, setOptions] = React.useState<ResolverOption[]>([]);
37
40
  const [loading, setLoading] = React.useState(true);
38
41
  const [error, setError] = React.useState<string | undefined>();
@@ -72,9 +75,7 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
72
75
  })
73
76
  .catch((error_) => {
74
77
  if (!cancelled) {
75
- setError(
76
- extractErrorMessage(error_, "Failed to load options"),
77
- );
78
+ setError(extractErrorMessage(error_, "Failed to load options"));
78
79
  setLoading(false);
79
80
  }
80
81
  });
@@ -199,7 +200,12 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
199
200
  <div className="relative">
200
201
  {loading ? (
201
202
  <div className="flex items-center gap-2 h-10 px-3 border rounded-md bg-muted/50">
202
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
203
+ <Loader2
204
+ className={cn(
205
+ "h-4 w-4 text-muted-foreground",
206
+ !isLowPower && "animate-spin",
207
+ )}
208
+ />
203
209
  <span className="text-sm text-muted-foreground">
204
210
  Loading options...
205
211
  </span>
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { cn } from "../utils";
3
+ import { usePerformance } from "./PerformanceProvider";
3
4
 
4
5
  interface LoadingSpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
5
6
  size?: "sm" | "md" | "lg";
@@ -10,6 +11,8 @@ export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
10
11
  className,
11
12
  ...props
12
13
  }) => {
14
+ const { isLowPower } = usePerformance();
15
+
13
16
  const sizeClasses = {
14
17
  sm: "w-4 h-4 border-2",
15
18
  md: "w-8 h-8 border-4",
@@ -20,7 +23,8 @@ export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
20
23
  <div className={cn("flex justify-center py-12", className)} {...props}>
21
24
  <div
22
25
  className={cn(
23
- "border-indigo-200 border-t-indigo-500 rounded-full animate-spin",
26
+ "border-indigo-200 border-t-indigo-500 rounded-full",
27
+ !isLowPower && "animate-spin",
24
28
  sizeClasses[size]
25
29
  )}
26
30
  />
@@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
4
4
  import { cn } from "../utils";
5
5
  import { useApi, accessApiRef } from "@checkstack/frontend-api";
6
6
  import type { AccessRule } from "@checkstack/common";
7
+ import { usePerformance } from "./PerformanceProvider";
7
8
 
8
9
  export interface NavItemProps {
9
10
  to?: string;
@@ -25,6 +26,7 @@ export const NavItem: React.FC<NavItemProps> = ({
25
26
  }) => {
26
27
  const [isOpen, setIsOpen] = useState(false);
27
28
  const containerRef = useRef<HTMLDivElement>(null);
29
+ const { isLowPower } = usePerformance();
28
30
 
29
31
  // Always call hooks at top level
30
32
  const accessApi = useApi(accessApiRef);
@@ -66,10 +68,6 @@ export const NavItem: React.FC<NavItemProps> = ({
66
68
 
67
69
  // Dropdown / Parent Item
68
70
  if (children) {
69
- // Check if any child is active to highlight parent
70
- // This is naive; normally we'd check paths.
71
- // For now, let's just rely on click state or strict path matching if 'to' is present on parent.
72
-
73
71
  return (
74
72
  <div className="relative group" ref={containerRef}>
75
73
  <button
@@ -88,7 +86,10 @@ export const NavItem: React.FC<NavItemProps> = ({
88
86
  </button>
89
87
 
90
88
  {isOpen && (
91
- <div className="absolute left-0 mt-1 w-48 rounded-md shadow-lg bg-popover ring-1 ring-border z-50 animate-in fade-in zoom-in-95 duration-100">
89
+ <div className={cn(
90
+ "absolute left-0 mt-1 w-48 rounded-md shadow-lg bg-popover ring-1 ring-border z-50",
91
+ !isLowPower && "animate-in fade-in zoom-in-95 duration-100"
92
+ )}>
92
93
  <div className="py-1 flex flex-col p-1 gap-1">{children}</div>
93
94
  </div>
94
95
  )}
@@ -0,0 +1,88 @@
1
+ import React, { createContext, useContext, useEffect, useState } from "react";
2
+
3
+ const STORAGE_KEY = "checkstack-low-power";
4
+
5
+ interface PerformanceContextValue {
6
+ /**
7
+ * Whether the application should run in low-power mode.
8
+ * Derived from (manualLowPower || prefersReducedMotion).
9
+ */
10
+ isLowPower: boolean;
11
+ /** Whether the performance state has been initialized from storage */
12
+ isLoaded: boolean;
13
+ /** The current state of the manual toggle */
14
+ manualLowPower: boolean;
15
+ /** Toggle the manual low power setting */
16
+ toggleManualLowPower: () => void;
17
+ }
18
+
19
+ const PerformanceContext = createContext<PerformanceContextValue>({
20
+ isLowPower: false,
21
+ isLoaded: false,
22
+ manualLowPower: false,
23
+ toggleManualLowPower: () => {},
24
+ });
25
+
26
+ /**
27
+ * usePerformance - Hook to access the global hardware performance state.
28
+ * Use this to conditionally disable heavy visual effects.
29
+ */
30
+ export const usePerformance = () => useContext(PerformanceContext);
31
+
32
+ interface PerformanceProviderProps {
33
+ children: React.ReactNode;
34
+ }
35
+
36
+ /**
37
+ * PerformanceProvider - Centralizes management of the application's performance tier.
38
+ * Supports manual override (persisted to localStorage) and respects OS-level
39
+ * Reduced Motion preferences.
40
+ */
41
+ export const PerformanceProvider: React.FC<PerformanceProviderProps> = ({ children }) => {
42
+ const [manualLowPower, setManualLowPower] = useState<boolean>(false);
43
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(false);
44
+ const [isLoaded, setIsLoaded] = useState<boolean>(false);
45
+
46
+ useEffect(() => {
47
+ // 1. Initialize Manual Toggle from localStorage
48
+ const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
49
+ if (stored === "true") {
50
+ setManualLowPower(true);
51
+ }
52
+
53
+ // 2. Initialize Reduced Motion Detection
54
+ const mediaQuery = globalThis.matchMedia("(prefers-reduced-motion: reduce)");
55
+ setPrefersReducedMotion(mediaQuery.matches);
56
+
57
+ // 3. Listen for changes in OS-level settings
58
+ const listener = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
59
+ mediaQuery.addEventListener("change", listener);
60
+
61
+ setIsLoaded(true);
62
+
63
+ return () => mediaQuery.removeEventListener("change", listener);
64
+ }, []);
65
+
66
+ const toggleManualLowPower = () => {
67
+ setManualLowPower((prev) => {
68
+ const next = !prev;
69
+ globalThis.localStorage?.setItem(STORAGE_KEY, String(next));
70
+ return next;
71
+ });
72
+ };
73
+
74
+ const isLowPower = manualLowPower || prefersReducedMotion;
75
+
76
+ const value = {
77
+ isLowPower,
78
+ isLoaded,
79
+ manualLowPower,
80
+ toggleManualLowPower,
81
+ };
82
+
83
+ return (
84
+ <PerformanceContext.Provider value={value}>
85
+ {children}
86
+ </PerformanceContext.Provider>
87
+ );
88
+ };
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import * as SelectPrimitive from "@radix-ui/react-select";
3
3
  import { Check, ChevronDown, ChevronUp } from "lucide-react";
4
4
  import { cn } from "../utils";
5
+ import { usePerformance } from "./PerformanceProvider";
5
6
 
6
7
  const Select = SelectPrimitive.Root;
7
8
 
@@ -67,19 +68,22 @@ SelectScrollDownButton.displayName =
67
68
  const SelectContent = React.forwardRef<
68
69
  React.ElementRef<typeof SelectPrimitive.Content>,
69
70
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
70
- >(({ className, children, position = "popper", ...props }, ref) => (
71
- <SelectPrimitive.Portal>
72
- <SelectPrimitive.Content
73
- ref={ref}
74
- className={cn(
75
- "relative z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
76
- position === "popper" &&
77
- "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
78
- className
79
- )}
80
- position={position}
81
- {...props}
82
- >
71
+ >(({ className, children, position = "popper", ...props }, ref) => {
72
+ const { isLowPower } = usePerformance();
73
+ return (
74
+ <SelectPrimitive.Portal>
75
+ <SelectPrimitive.Content
76
+ ref={ref}
77
+ className={cn(
78
+ "relative z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md",
79
+ !isLowPower && "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
80
+ position === "popper" &&
81
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
82
+ className
83
+ )}
84
+ position={position}
85
+ {...props}
86
+ >
83
87
  <SelectScrollUpButton />
84
88
  <SelectPrimitive.Viewport
85
89
  className={cn(
@@ -92,8 +96,9 @@ const SelectContent = React.forwardRef<
92
96
  </SelectPrimitive.Viewport>
93
97
  <SelectScrollDownButton />
94
98
  </SelectPrimitive.Content>
95
- </SelectPrimitive.Portal>
96
- ));
99
+ </SelectPrimitive.Portal>
100
+ );
101
+ });
97
102
  SelectContent.displayName = SelectPrimitive.Content.displayName;
98
103
 
99
104
  const SelectLabel = React.forwardRef<
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
2
2
  import { Button } from "./Button";
3
3
  import { Bell } from "lucide-react";
4
4
  import { cn } from "../utils";
5
+ import { usePerformance } from "./PerformanceProvider";
5
6
 
6
7
  export interface SubscribeButtonProps {
7
8
  /**
@@ -43,15 +44,16 @@ export const SubscribeButton: React.FC<SubscribeButtonProps> = ({
43
44
  className,
44
45
  }) => {
45
46
  const [animating, setAnimating] = useState(false);
47
+ const { isLowPower } = usePerformance();
46
48
 
47
49
  // Trigger animation when subscription state changes
48
50
  useEffect(() => {
49
- if (!loading) {
51
+ if (!loading && !isLowPower) {
50
52
  setAnimating(true);
51
53
  const timer = setTimeout(() => setAnimating(false), 500);
52
54
  return () => clearTimeout(timer);
53
55
  }
54
- }, [isSubscribed, loading]);
56
+ }, [isSubscribed, loading, isLowPower]);
55
57
 
56
58
  const handleClick = () => {
57
59
  if (loading) return;
@@ -94,7 +96,10 @@ export const SubscribeButton: React.FC<SubscribeButtonProps> = ({
94
96
  <span
95
97
  role="status"
96
98
  aria-label="Loading"
97
- className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
99
+ className={cn(
100
+ "h-4 w-4 rounded-full border-2 border-current border-t-transparent",
101
+ !isLowPower && "animate-spin"
102
+ )}
98
103
  />
99
104
  ) : (
100
105
  <Bell
@@ -102,7 +107,7 @@ export const SubscribeButton: React.FC<SubscribeButtonProps> = ({
102
107
  className={cn(
103
108
  "h-4 w-4 transition-all duration-300",
104
109
  isSubscribed && "fill-current",
105
- animating && "animate-bell-ring"
110
+ animating && !isLowPower && "animate-bell-ring"
106
111
  )}
107
112
  />
108
113
  )}
@@ -1,5 +1,6 @@
1
1
  import React, { useRef, useState, useEffect } from "react";
2
2
  import { cn } from "../utils";
3
+ import { usePerformance } from "./PerformanceProvider";
3
4
 
4
5
  export interface TerminalEntry {
5
6
  id: string;
@@ -65,6 +66,7 @@ export const TerminalFeed: React.FC<TerminalFeedProps> = ({
65
66
  title = "terminal",
66
67
  className,
67
68
  }) => {
69
+ const { isLowPower } = usePerformance();
68
70
  const contentRef = useRef<HTMLDivElement>(null);
69
71
  const [isHovering, setIsHovering] = useState(false);
70
72
 
@@ -107,7 +109,7 @@ export const TerminalFeed: React.FC<TerminalFeedProps> = ({
107
109
  onMouseLeave={() => setIsHovering(false)}
108
110
  >
109
111
  {displayEntries.length === 0 ? (
110
- <div className="text-gray-500 animate-pulse">
112
+ <div className={cn("text-gray-500", !isLowPower && "animate-pulse")}>
111
113
  Waiting for events...
112
114
  </div>
113
115
  ) : (
@@ -141,10 +143,13 @@ export const TerminalFeed: React.FC<TerminalFeedProps> = ({
141
143
  })
142
144
  )}
143
145
 
144
- {/* Blinking cursor */}
146
+ {/* Blinking cursor - animation disabled in low power mode */}
145
147
  <div className="flex items-center gap-1 mt-2">
146
148
  <span className="text-emerald-400">$</span>
147
- <span className="w-2 h-4 bg-emerald-400 animate-pulse" />
149
+ <span className={cn(
150
+ "w-2 h-4 bg-emerald-400",
151
+ !isLowPower && "animate-pulse"
152
+ )} />
148
153
  </div>
149
154
  </div>
150
155
  </div>
@@ -2,6 +2,7 @@ import React, { useEffect } from "react";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
4
4
  import { cn } from "../utils";
5
+ import { usePerformance } from "./PerformanceProvider";
5
6
 
6
7
  const toastVariants = cva(
7
8
  "relative flex items-start gap-3 w-full max-w-md rounded-lg p-4 transition-all",
@@ -55,6 +56,7 @@ export const Toast: React.FC<ToastProps> = ({
55
56
  duration = 4000,
56
57
  onDismiss,
57
58
  }) => {
59
+ const { isLowPower } = usePerformance();
58
60
  const Icon = iconMap[variant || "default"];
59
61
  const iconColor = iconColorMap[variant || "default"];
60
62
  const [isHovered, setIsHovered] = React.useState(false);
@@ -97,7 +99,7 @@ export const Toast: React.FC<ToastProps> = ({
97
99
  <div
98
100
  className={cn(
99
101
  toastVariants({ variant }),
100
- "animate-in slide-in-from-right fade-in zoom-in-95 duration-300 hover:-translate-y-1 hover:shadow-2xl cursor-default"
102
+ !isLowPower && "animate-in slide-in-from-right fade-in zoom-in-95 duration-300 hover:-translate-y-1 hover:shadow-2xl cursor-default"
101
103
  )}
102
104
  role="alert"
103
105
  aria-live="polite"
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export * from "./components/ColorPicker";
49
49
  export * from "./components/AnimatedCounter";
50
50
  export * from "./components/CommandPalette";
51
51
  export * from "./components/TerminalFeed";
52
+ export * from "./components/PerformanceProvider";
52
53
  export * from "./components/AmbientBackground";
53
54
  export * from "./components/CodeEditor";
54
55
  export * from "./components/AnimatedNumber";