@papernote/ui 1.10.12 → 1.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/components/AchievementBadge.d.ts +37 -0
  2. package/dist/components/AchievementBadge.d.ts.map +1 -0
  3. package/dist/components/AchievementUnlock.d.ts +31 -0
  4. package/dist/components/AchievementUnlock.d.ts.map +1 -0
  5. package/dist/components/ActivityFeed.d.ts +42 -0
  6. package/dist/components/ActivityFeed.d.ts.map +1 -0
  7. package/dist/components/CollaboratorAvatars.d.ts +33 -0
  8. package/dist/components/CollaboratorAvatars.d.ts.map +1 -0
  9. package/dist/components/InviteCard.d.ts +33 -0
  10. package/dist/components/InviteCard.d.ts.map +1 -0
  11. package/dist/components/MotivationalMessage.d.ts +31 -0
  12. package/dist/components/MotivationalMessage.d.ts.map +1 -0
  13. package/dist/components/PermissionBadge.d.ts +25 -0
  14. package/dist/components/PermissionBadge.d.ts.map +1 -0
  15. package/dist/components/ProgressCelebration.d.ts +30 -0
  16. package/dist/components/ProgressCelebration.d.ts.map +1 -0
  17. package/dist/components/SharedBadge.d.ts +28 -0
  18. package/dist/components/SharedBadge.d.ts.map +1 -0
  19. package/dist/components/StreakBadge.d.ts +27 -0
  20. package/dist/components/StreakBadge.d.ts.map +1 -0
  21. package/dist/components/SuccessCheck.d.ts +27 -0
  22. package/dist/components/SuccessCheck.d.ts.map +1 -0
  23. package/dist/components/index.d.ts +24 -0
  24. package/dist/components/index.d.ts.map +1 -1
  25. package/dist/hooks/useDelighters.d.ts +55 -0
  26. package/dist/hooks/useDelighters.d.ts.map +1 -0
  27. package/dist/index.d.ts +382 -2
  28. package/dist/index.esm.js +1385 -486
  29. package/dist/index.esm.js.map +1 -1
  30. package/dist/index.js +1395 -484
  31. package/dist/index.js.map +1 -1
  32. package/dist/styles.css +201 -0
  33. package/package.json +1 -1
  34. package/src/components/AchievementBadge.stories.tsx +290 -0
  35. package/src/components/AchievementBadge.tsx +196 -0
  36. package/src/components/AchievementUnlock.stories.tsx +345 -0
  37. package/src/components/AchievementUnlock.tsx +157 -0
  38. package/src/components/ActivityFeed.stories.tsx +236 -0
  39. package/src/components/ActivityFeed.tsx +160 -0
  40. package/src/components/Celebration.stories.tsx +3 -3
  41. package/src/components/CollaboratorAvatars.stories.tsx +215 -0
  42. package/src/components/CollaboratorAvatars.tsx +175 -0
  43. package/src/components/InviteCard.stories.tsx +174 -0
  44. package/src/components/InviteCard.tsx +209 -0
  45. package/src/components/MotivationalMessage.stories.tsx +258 -0
  46. package/src/components/MotivationalMessage.tsx +120 -0
  47. package/src/components/PermissionBadge.stories.tsx +208 -0
  48. package/src/components/PermissionBadge.tsx +204 -0
  49. package/src/components/ProgressCelebration.stories.tsx +321 -0
  50. package/src/components/ProgressCelebration.tsx +143 -0
  51. package/src/components/SharedBadge.stories.tsx +210 -0
  52. package/src/components/SharedBadge.tsx +111 -0
  53. package/src/components/StreakBadge.stories.tsx +222 -0
  54. package/src/components/StreakBadge.tsx +132 -0
  55. package/src/components/SuccessCheck.stories.tsx +233 -0
  56. package/src/components/SuccessCheck.tsx +214 -0
  57. package/src/components/index.ts +38 -0
  58. package/src/hooks/useDelighters.ts +133 -0
@@ -0,0 +1,233 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import { SuccessCheck } from './SuccessCheck';
4
+ import { Button } from './Button';
5
+ import { Stack } from './Stack';
6
+ import { Text } from './Text';
7
+
8
+ const meta: Meta<typeof SuccessCheck> = {
9
+ title: 'Feedback/SuccessCheck',
10
+ component: SuccessCheck,
11
+ parameters: {
12
+ layout: 'centered',
13
+ docs: {
14
+ description: {
15
+ component: 'An animated checkmark for completed actions. Features SVG stroke animation with configurable size, color, and delay. Respects prefers-reduced-motion for accessibility.',
16
+ },
17
+ },
18
+ },
19
+ tags: ['autodocs'],
20
+ argTypes: {
21
+ size: {
22
+ control: 'select',
23
+ options: ['sm', 'md', 'lg'],
24
+ description: 'Size of the checkmark',
25
+ },
26
+ color: {
27
+ control: 'color',
28
+ description: 'Color of the checkmark',
29
+ },
30
+ delay: {
31
+ control: { type: 'number', min: 0, max: 2000, step: 100 },
32
+ description: 'Delay in ms before animation starts',
33
+ },
34
+ enabled: {
35
+ control: 'boolean',
36
+ description: 'Whether animations are enabled',
37
+ },
38
+ },
39
+ };
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof SuccessCheck>;
43
+
44
+ // Basic example
45
+ export const Default: Story = {
46
+ args: {
47
+ size: 'md',
48
+ },
49
+ };
50
+
51
+ // All sizes
52
+ export const Sizes: Story = {
53
+ render: () => (
54
+ <Stack direction="horizontal" gap="lg" align="center">
55
+ <Stack align="center" gap="sm">
56
+ <SuccessCheck size="sm" />
57
+ <Text size="sm" className="text-ink-500">Small</Text>
58
+ </Stack>
59
+ <Stack align="center" gap="sm">
60
+ <SuccessCheck size="md" />
61
+ <Text size="sm" className="text-ink-500">Medium</Text>
62
+ </Stack>
63
+ <Stack align="center" gap="sm">
64
+ <SuccessCheck size="lg" />
65
+ <Text size="sm" className="text-ink-500">Large</Text>
66
+ </Stack>
67
+ </Stack>
68
+ ),
69
+ };
70
+
71
+ // Custom colors
72
+ export const CustomColors: Story = {
73
+ render: () => (
74
+ <Stack direction="horizontal" gap="lg" align="center">
75
+ <Stack align="center" gap="sm">
76
+ <SuccessCheck color="#10b981" />
77
+ <Text size="sm" className="text-ink-500">Success</Text>
78
+ </Stack>
79
+ <Stack align="center" gap="sm">
80
+ <SuccessCheck color="#3b82f6" />
81
+ <Text size="sm" className="text-ink-500">Blue</Text>
82
+ </Stack>
83
+ <Stack align="center" gap="sm">
84
+ <SuccessCheck color="#8b5cf6" />
85
+ <Text size="sm" className="text-ink-500">Purple</Text>
86
+ </Stack>
87
+ <Stack align="center" gap="sm">
88
+ <SuccessCheck color="#f59e0b" />
89
+ <Text size="sm" className="text-ink-500">Amber</Text>
90
+ </Stack>
91
+ </Stack>
92
+ ),
93
+ };
94
+
95
+ // With delay
96
+ export const WithDelay: Story = {
97
+ render: () => (
98
+ <Stack direction="horizontal" gap="lg" align="center">
99
+ <Stack align="center" gap="sm">
100
+ <SuccessCheck delay={0} />
101
+ <Text size="sm" className="text-ink-500">No delay</Text>
102
+ </Stack>
103
+ <Stack align="center" gap="sm">
104
+ <SuccessCheck delay={500} />
105
+ <Text size="sm" className="text-ink-500">500ms</Text>
106
+ </Stack>
107
+ <Stack align="center" gap="sm">
108
+ <SuccessCheck delay={1000} />
109
+ <Text size="sm" className="text-ink-500">1000ms</Text>
110
+ </Stack>
111
+ </Stack>
112
+ ),
113
+ };
114
+
115
+ // Interactive demo with trigger
116
+ export const Interactive: Story = {
117
+ render: function InteractiveDemo() {
118
+ const [key, setKey] = useState(0);
119
+ const [completed, setCompleted] = useState(false);
120
+
121
+ const triggerAnimation = () => {
122
+ setCompleted(false);
123
+ setKey(prev => prev + 1);
124
+ };
125
+
126
+ return (
127
+ <Stack align="center" gap="lg">
128
+ <div className="w-24 h-24 flex items-center justify-center bg-paper-100 rounded-xl">
129
+ <SuccessCheck
130
+ key={key}
131
+ size="lg"
132
+ onComplete={() => setCompleted(true)}
133
+ />
134
+ </div>
135
+ <Stack align="center" gap="sm">
136
+ <Button onClick={triggerAnimation} variant="primary">
137
+ Replay Animation
138
+ </Button>
139
+ <Text size="sm" className="text-ink-500">
140
+ {completed ? 'Animation complete!' : 'Animating...'}
141
+ </Text>
142
+ </Stack>
143
+ </Stack>
144
+ );
145
+ },
146
+ };
147
+
148
+ // Static (no animation)
149
+ export const Static: Story = {
150
+ args: {
151
+ enabled: false,
152
+ size: 'lg',
153
+ },
154
+ parameters: {
155
+ docs: {
156
+ description: {
157
+ story: 'When `enabled` is false, the checkmark appears immediately without animation. This also happens automatically when the user prefers reduced motion.',
158
+ },
159
+ },
160
+ },
161
+ };
162
+
163
+ // In context - Task completion
164
+ export const TaskCompletion: Story = {
165
+ render: function TaskCompletionDemo() {
166
+ const [isComplete, setIsComplete] = useState(false);
167
+
168
+ return (
169
+ <div className="p-6 bg-white rounded-xl shadow-card border border-paper-200 w-80">
170
+ <Stack gap="md">
171
+ <Stack direction="horizontal" justify="between" align="center">
172
+ <Text weight="semibold">Complete your profile</Text>
173
+ {isComplete && <SuccessCheck size="sm" />}
174
+ </Stack>
175
+
176
+ <Stack gap="xs">
177
+ <Stack direction="horizontal" gap="sm" align="center">
178
+ <SuccessCheck size="sm" />
179
+ <Text size="sm" className="text-ink-600">Add profile photo</Text>
180
+ </Stack>
181
+ <Stack direction="horizontal" gap="sm" align="center">
182
+ <SuccessCheck size="sm" />
183
+ <Text size="sm" className="text-ink-600">Verify email address</Text>
184
+ </Stack>
185
+ <Stack direction="horizontal" gap="sm" align="center">
186
+ {isComplete ? (
187
+ <SuccessCheck size="sm" />
188
+ ) : (
189
+ <div className="w-6 h-6 rounded-full border-2 border-paper-300" />
190
+ )}
191
+ <Text size="sm" className={isComplete ? 'text-ink-600' : 'text-ink-400'}>
192
+ Set up payment method
193
+ </Text>
194
+ </Stack>
195
+ </Stack>
196
+
197
+ {!isComplete && (
198
+ <Button
199
+ onClick={() => setIsComplete(true)}
200
+ variant="primary"
201
+ size="sm"
202
+ className="w-full"
203
+ >
204
+ Mark Complete
205
+ </Button>
206
+ )}
207
+ </Stack>
208
+ </div>
209
+ );
210
+ },
211
+ };
212
+
213
+ // Staggered animation
214
+ export const Staggered: Story = {
215
+ render: function StaggeredDemo() {
216
+ const [key, setKey] = useState(0);
217
+
218
+ return (
219
+ <Stack align="center" gap="lg">
220
+ <Stack direction="horizontal" gap="md" align="center">
221
+ <SuccessCheck key={`${key}-1`} delay={0} />
222
+ <SuccessCheck key={`${key}-2`} delay={200} />
223
+ <SuccessCheck key={`${key}-3`} delay={400} />
224
+ <SuccessCheck key={`${key}-4`} delay={600} />
225
+ <SuccessCheck key={`${key}-5`} delay={800} />
226
+ </Stack>
227
+ <Button onClick={() => setKey(k => k + 1)} variant="secondary">
228
+ Replay Staggered
229
+ </Button>
230
+ </Stack>
231
+ );
232
+ },
233
+ };
@@ -0,0 +1,214 @@
1
+ import { useEffect, useState, useRef } from 'react';
2
+
3
+ export interface SuccessCheckProps {
4
+ /** Size of the checkmark */
5
+ size?: 'sm' | 'md' | 'lg';
6
+ /** Color of the checkmark (defaults to success green) */
7
+ color?: string;
8
+ /** Delay in ms before animation starts */
9
+ delay?: number;
10
+ /** Callback when animation completes */
11
+ onComplete?: () => void;
12
+ /** Additional CSS classes */
13
+ className?: string;
14
+ /** Whether the animation is enabled (respects prefers-reduced-motion) */
15
+ enabled?: boolean;
16
+ }
17
+
18
+ const sizeStyles = {
19
+ sm: { size: 24, strokeWidth: 3 },
20
+ md: { size: 32, strokeWidth: 3 },
21
+ lg: { size: 48, strokeWidth: 4 },
22
+ };
23
+
24
+ /**
25
+ * SuccessCheck - An animated checkmark for completed actions.
26
+ *
27
+ * Features:
28
+ * - SVG checkmark with stroke animation
29
+ * - Configurable size and color
30
+ * - Optional delay before animation
31
+ * - Respects prefers-reduced-motion
32
+ * - Callback on animation complete
33
+ */
34
+ export function SuccessCheck({
35
+ size = 'md',
36
+ color,
37
+ delay = 0,
38
+ onComplete,
39
+ className = '',
40
+ enabled = true,
41
+ }: SuccessCheckProps) {
42
+ const [isAnimating, setIsAnimating] = useState(false);
43
+ const [showCheck, setShowCheck] = useState(false);
44
+ const timeoutRef = useRef<number | null>(null);
45
+ const animationTimeoutRef = useRef<number | null>(null);
46
+
47
+ const { size: dimensions, strokeWidth } = sizeStyles[size];
48
+ const checkColor = color || '#10b981'; // success-500
49
+
50
+ // Check for reduced motion preference
51
+ const prefersReducedMotion =
52
+ typeof window !== 'undefined' &&
53
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
54
+
55
+ useEffect(() => {
56
+ if (!enabled) {
57
+ setShowCheck(true);
58
+ return;
59
+ }
60
+
61
+ // Start animation after delay
62
+ timeoutRef.current = window.setTimeout(() => {
63
+ setShowCheck(true);
64
+ setIsAnimating(true);
65
+
66
+ // Animation duration is 0.6s for the draw + 0.3s for the scale
67
+ const animationDuration = prefersReducedMotion ? 0 : 900;
68
+
69
+ animationTimeoutRef.current = window.setTimeout(() => {
70
+ setIsAnimating(false);
71
+ onComplete?.();
72
+ }, animationDuration);
73
+ }, delay);
74
+
75
+ return () => {
76
+ if (timeoutRef.current) {
77
+ clearTimeout(timeoutRef.current);
78
+ }
79
+ if (animationTimeoutRef.current) {
80
+ clearTimeout(animationTimeoutRef.current);
81
+ }
82
+ };
83
+ }, [delay, enabled, onComplete, prefersReducedMotion]);
84
+
85
+ if (!showCheck) {
86
+ return (
87
+ <div
88
+ className={`inline-flex items-center justify-center ${className}`}
89
+ style={{ width: dimensions, height: dimensions }}
90
+ role="status"
91
+ aria-label="Loading"
92
+ />
93
+ );
94
+ }
95
+
96
+ // For reduced motion, show static checkmark
97
+ if (prefersReducedMotion || !enabled) {
98
+ return (
99
+ <div
100
+ className={`inline-flex items-center justify-center ${className}`}
101
+ style={{ width: dimensions, height: dimensions }}
102
+ role="status"
103
+ aria-label="Success"
104
+ >
105
+ <svg
106
+ width={dimensions}
107
+ height={dimensions}
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ xmlns="http://www.w3.org/2000/svg"
111
+ >
112
+ <circle
113
+ cx="12"
114
+ cy="12"
115
+ r="10"
116
+ stroke={checkColor}
117
+ strokeWidth={strokeWidth * 0.8}
118
+ fill="none"
119
+ opacity={0.2}
120
+ />
121
+ <path
122
+ d="M7 12.5L10.5 16L17 9"
123
+ stroke={checkColor}
124
+ strokeWidth={strokeWidth}
125
+ strokeLinecap="round"
126
+ strokeLinejoin="round"
127
+ />
128
+ </svg>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ // Animated version
134
+ // The checkmark path length for the animation
135
+ const pathLength = 20; // Approximate length of the check path
136
+
137
+ return (
138
+ <div
139
+ className={`inline-flex items-center justify-center ${className}`}
140
+ style={{ width: dimensions, height: dimensions }}
141
+ role="status"
142
+ aria-label="Success"
143
+ >
144
+ <svg
145
+ width={dimensions}
146
+ height={dimensions}
147
+ viewBox="0 0 24 24"
148
+ fill="none"
149
+ xmlns="http://www.w3.org/2000/svg"
150
+ className={isAnimating ? 'animate-success-check' : ''}
151
+ >
152
+ {/* Background circle */}
153
+ <circle
154
+ cx="12"
155
+ cy="12"
156
+ r="10"
157
+ stroke={checkColor}
158
+ strokeWidth={strokeWidth * 0.8}
159
+ fill="none"
160
+ opacity={0.2}
161
+ className="origin-center"
162
+ style={{
163
+ animation: isAnimating ? 'successCircle 0.4s ease-out forwards' : 'none',
164
+ }}
165
+ />
166
+
167
+ {/* Checkmark path with stroke animation */}
168
+ <path
169
+ d="M7 12.5L10.5 16L17 9"
170
+ stroke={checkColor}
171
+ strokeWidth={strokeWidth}
172
+ strokeLinecap="round"
173
+ strokeLinejoin="round"
174
+ style={{
175
+ strokeDasharray: pathLength,
176
+ strokeDashoffset: isAnimating ? 0 : pathLength,
177
+ animation: isAnimating
178
+ ? `successDraw 0.4s 0.2s ease-out forwards`
179
+ : 'none',
180
+ }}
181
+ />
182
+ </svg>
183
+
184
+ {/* Inject keyframes for the animations */}
185
+ <style>{`
186
+ @keyframes successDraw {
187
+ 0% {
188
+ stroke-dashoffset: ${pathLength};
189
+ }
190
+ 100% {
191
+ stroke-dashoffset: 0;
192
+ }
193
+ }
194
+
195
+ @keyframes successCircle {
196
+ 0% {
197
+ transform: scale(0);
198
+ opacity: 0;
199
+ }
200
+ 50% {
201
+ transform: scale(1.1);
202
+ opacity: 0.3;
203
+ }
204
+ 100% {
205
+ transform: scale(1);
206
+ opacity: 0.2;
207
+ }
208
+ }
209
+ `}</style>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ export default SuccessCheck;
@@ -69,6 +69,44 @@ export { Celebration, useCelebration } from './Celebration';
69
69
  export type { CelebrationProps } from './Celebration';
70
70
  export type { CardProps } from './Card';
71
71
 
72
+ // Delighter Components (PAP-12)
73
+ export { SuccessCheck } from './SuccessCheck';
74
+ export type { SuccessCheckProps } from './SuccessCheck';
75
+
76
+ export { MotivationalMessage } from './MotivationalMessage';
77
+ export type { MotivationalMessageProps } from './MotivationalMessage';
78
+
79
+ export { StreakBadge } from './StreakBadge';
80
+ export type { StreakBadgeProps } from './StreakBadge';
81
+
82
+ export { AchievementBadge } from './AchievementBadge';
83
+ export type { AchievementBadgeProps, AchievementBadgeData } from './AchievementBadge';
84
+
85
+ export { ProgressCelebration } from './ProgressCelebration';
86
+ export type { ProgressCelebrationProps } from './ProgressCelebration';
87
+
88
+ export { AchievementUnlock } from './AchievementUnlock';
89
+ export type { AchievementUnlockProps } from './AchievementUnlock';
90
+
91
+ export { useDelighters } from '../hooks/useDelighters';
92
+ export type { UseDelightersReturn, CelebrationType, CelebrationOptions } from '../hooks/useDelighters';
93
+
94
+ // Collaboration Components (PAP-11)
95
+ export { CollaboratorAvatars } from './CollaboratorAvatars';
96
+ export type { CollaboratorAvatarsProps, Collaborator } from './CollaboratorAvatars';
97
+
98
+ export { PermissionBadge } from './PermissionBadge';
99
+ export type { PermissionBadgeProps, PermissionLevel } from './PermissionBadge';
100
+
101
+ export { SharedBadge } from './SharedBadge';
102
+ export type { SharedBadgeProps } from './SharedBadge';
103
+
104
+ export { ActivityFeed } from './ActivityFeed';
105
+ export type { ActivityFeedProps, ActivityItem } from './ActivityFeed';
106
+
107
+ export { InviteCard } from './InviteCard';
108
+ export type { InviteCardProps, PendingInvite } from './InviteCard';
109
+
72
110
  export { default as Stack } from './Stack';
73
111
  export type { StackProps } from './Stack';
74
112
 
@@ -0,0 +1,133 @@
1
+ import { useState, useCallback, useEffect } from 'react';
2
+ import { useCelebration } from '../components/Celebration';
3
+ import type { AchievementBadgeData } from '../components/AchievementBadge';
4
+
5
+ export type CelebrationType = 'confetti' | 'fireworks' | 'stars';
6
+
7
+ export interface CelebrationOptions {
8
+ /** Duration of the celebration in ms */
9
+ duration?: number;
10
+ /** Number of particles */
11
+ particleCount?: number;
12
+ /** Custom colors for the celebration */
13
+ colors?: string[];
14
+ /** Origin point (x, y in 0-1 range) */
15
+ origin?: { x: number; y: number };
16
+ }
17
+
18
+ export interface UseDelightersReturn {
19
+ /** Trigger a celebration animation */
20
+ celebrate: (type: CelebrationType, options?: CelebrationOptions) => void;
21
+ /** Show an achievement unlock (returns a function to trigger the achievement modal) */
22
+ showAchievement: (badge: AchievementBadgeData) => void;
23
+ /** Whether delighters are globally enabled */
24
+ enabled: boolean;
25
+ /** Set the global enabled state */
26
+ setEnabled: (enabled: boolean) => void;
27
+ /** The currently pending achievement (for rendering AchievementUnlock) */
28
+ pendingAchievement: AchievementBadgeData | null;
29
+ /** Clear the pending achievement */
30
+ clearAchievement: () => void;
31
+ }
32
+
33
+ const STORAGE_KEY = 'notebook-ui-delighters-enabled';
34
+
35
+ /**
36
+ * useDelighters - Global control hook for celebration effects.
37
+ *
38
+ * Features:
39
+ * - Wraps useCelebration for consistent celebration API
40
+ * - Global enabled/disabled state with localStorage persistence
41
+ * - Respects prefers-reduced-motion automatically
42
+ * - Provides showAchievement for achievement unlock modals
43
+ *
44
+ * Usage:
45
+ * ```tsx
46
+ * const { celebrate, showAchievement, enabled, setEnabled } = useDelighters();
47
+ *
48
+ * // Trigger a celebration
49
+ * celebrate('confetti', { particleCount: 100 });
50
+ *
51
+ * // Show an achievement
52
+ * showAchievement({ icon: <Trophy />, name: 'Budget Master', description: '...' });
53
+ *
54
+ * // Toggle celebrations globally
55
+ * setEnabled(false);
56
+ * ```
57
+ */
58
+ export function useDelighters(): UseDelightersReturn {
59
+ const { celebrate: baseCelebrate } = useCelebration();
60
+ const [pendingAchievement, setPendingAchievement] = useState<AchievementBadgeData | null>(null);
61
+
62
+ // Initialize enabled state from localStorage
63
+ const [enabled, setEnabledState] = useState<boolean>(() => {
64
+ if (typeof window === 'undefined') return true;
65
+ const stored = localStorage.getItem(STORAGE_KEY);
66
+ return stored === null ? true : stored === 'true';
67
+ });
68
+
69
+ // Check for reduced motion preference
70
+ const prefersReducedMotion =
71
+ typeof window !== 'undefined' &&
72
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
73
+
74
+ // Persist enabled state to localStorage
75
+ const setEnabled = useCallback((newEnabled: boolean) => {
76
+ setEnabledState(newEnabled);
77
+ if (typeof window !== 'undefined') {
78
+ localStorage.setItem(STORAGE_KEY, String(newEnabled));
79
+ }
80
+ }, []);
81
+
82
+ // Sync with system preference changes
83
+ useEffect(() => {
84
+ if (typeof window === 'undefined') return;
85
+
86
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
87
+ const handleChange = (e: MediaQueryListEvent) => {
88
+ if (e.matches) {
89
+ // System now prefers reduced motion, but don't change user's preference
90
+ // Just let the celebrate function respect it
91
+ }
92
+ };
93
+
94
+ mediaQuery.addEventListener('change', handleChange);
95
+ return () => mediaQuery.removeEventListener('change', handleChange);
96
+ }, []);
97
+
98
+ const celebrate = useCallback((type: CelebrationType, options?: CelebrationOptions) => {
99
+ // Skip if disabled or prefers reduced motion
100
+ if (!enabled || prefersReducedMotion) return;
101
+
102
+ baseCelebrate({
103
+ type,
104
+ duration: options?.duration,
105
+ particleCount: options?.particleCount,
106
+ colors: options?.colors,
107
+ origin: options?.origin,
108
+ });
109
+ }, [enabled, prefersReducedMotion, baseCelebrate]);
110
+
111
+ const showAchievement = useCallback((badge: AchievementBadgeData) => {
112
+ // Always show achievement (unless disabled), even with reduced motion
113
+ // The modal itself will handle reduced motion for its animations
114
+ if (!enabled) return;
115
+
116
+ setPendingAchievement(badge);
117
+ }, [enabled]);
118
+
119
+ const clearAchievement = useCallback(() => {
120
+ setPendingAchievement(null);
121
+ }, []);
122
+
123
+ return {
124
+ celebrate,
125
+ showAchievement,
126
+ enabled,
127
+ setEnabled,
128
+ pendingAchievement,
129
+ clearAchievement,
130
+ };
131
+ }
132
+
133
+ export default useDelighters;