@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 +12 -0
- package/package.json +1 -1
- package/src/components/AmbientBackground.tsx +42 -22
- package/src/components/AnimatedCounter.tsx +7 -4
- package/src/components/CommandPalette.tsx +9 -4
- package/src/components/Dialog.tsx +33 -21
- package/src/components/DropdownMenu.tsx +4 -1
- package/src/components/DynamicForm/DynamicOptionsField.tsx +10 -4
- package/src/components/LoadingSpinner.tsx +5 -1
- package/src/components/NavItem.tsx +6 -5
- package/src/components/PerformanceProvider.tsx +88 -0
- package/src/components/Select.tsx +20 -15
- package/src/components/SubscribeButton.tsx +9 -4
- package/src/components/TerminalFeed.tsx +8 -3
- package/src/components/Toast.tsx +3 -1
- package/src/index.ts +1 -0
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,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 -
|
|
11
|
-
* Features an "Inverse Glow Grid" where aurora effects shine through
|
|
12
|
-
*
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
58
|
-
<div
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
className
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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>
|
package/src/components/Toast.tsx
CHANGED
|
@@ -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";
|