react-native-ai-animations 0.0.0-stage → 0.8.1

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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -2
  3. package/lib/module/brand/index.js +4 -0
  4. package/lib/module/brand/index.js.map +1 -0
  5. package/lib/module/brand/spinning-mark.js +96 -0
  6. package/lib/module/brand/spinning-mark.js.map +1 -0
  7. package/lib/module/chat/effort-bar.js +853 -0
  8. package/lib/module/chat/effort-bar.js.map +1 -0
  9. package/lib/module/chat/index.js +8 -0
  10. package/lib/module/chat/index.js.map +1 -0
  11. package/lib/module/chat/question-card.js +720 -0
  12. package/lib/module/chat/question-card.js.map +1 -0
  13. package/lib/module/chat/reasoning.js +38 -0
  14. package/lib/module/chat/reasoning.js.map +1 -0
  15. package/lib/module/chat/status-row.js +140 -0
  16. package/lib/module/chat/status-row.js.map +1 -0
  17. package/lib/module/chat/thinking-block.js +255 -0
  18. package/lib/module/chat/thinking-block.js.map +1 -0
  19. package/lib/module/index.js +5 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/internal/color.js +15 -0
  22. package/lib/module/internal/color.js.map +1 -0
  23. package/lib/module/internal/use-elapsed.js +20 -0
  24. package/lib/module/internal/use-elapsed.js.map +1 -0
  25. package/lib/module/package.json +1 -0
  26. package/lib/module/skia/dot-orb.js +256 -0
  27. package/lib/module/skia/dot-orb.js.map +1 -0
  28. package/lib/module/skia/index.js +8 -0
  29. package/lib/module/skia/index.js.map +1 -0
  30. package/lib/module/skia/liquid-orb.js +303 -0
  31. package/lib/module/skia/liquid-orb.js.map +1 -0
  32. package/lib/module/skia/matrix-orb.js +272 -0
  33. package/lib/module/skia/matrix-orb.js.map +1 -0
  34. package/lib/module/skia/media-generating.js +524 -0
  35. package/lib/module/skia/media-generating.js.map +1 -0
  36. package/lib/module/skia/voice-wave.js +278 -0
  37. package/lib/module/skia/voice-wave.js.map +1 -0
  38. package/lib/module/text/index.js +6 -0
  39. package/lib/module/text/index.js.map +1 -0
  40. package/lib/module/text/shimmer-text.js +122 -0
  41. package/lib/module/text/shimmer-text.js.map +1 -0
  42. package/lib/module/text/spoken-text.js +85 -0
  43. package/lib/module/text/spoken-text.js.map +1 -0
  44. package/lib/module/text/streaming-text.js +135 -0
  45. package/lib/module/text/streaming-text.js.map +1 -0
  46. package/lib/module/theme/index.js +5 -0
  47. package/lib/module/theme/index.js.map +1 -0
  48. package/lib/module/theme/palette.js +45 -0
  49. package/lib/module/theme/palette.js.map +1 -0
  50. package/lib/module/theme/provider.js +65 -0
  51. package/lib/module/theme/provider.js.map +1 -0
  52. package/lib/typescript/package.json +1 -0
  53. package/lib/typescript/src/brand/index.d.ts +2 -0
  54. package/lib/typescript/src/brand/index.d.ts.map +1 -0
  55. package/lib/typescript/src/brand/spinning-mark.d.ts +21 -0
  56. package/lib/typescript/src/brand/spinning-mark.d.ts.map +1 -0
  57. package/lib/typescript/src/chat/effort-bar.d.ts +42 -0
  58. package/lib/typescript/src/chat/effort-bar.d.ts.map +1 -0
  59. package/lib/typescript/src/chat/index.d.ts +6 -0
  60. package/lib/typescript/src/chat/index.d.ts.map +1 -0
  61. package/lib/typescript/src/chat/question-card.d.ts +73 -0
  62. package/lib/typescript/src/chat/question-card.d.ts.map +1 -0
  63. package/lib/typescript/src/chat/reasoning.d.ts +20 -0
  64. package/lib/typescript/src/chat/reasoning.d.ts.map +1 -0
  65. package/lib/typescript/src/chat/status-row.d.ts +20 -0
  66. package/lib/typescript/src/chat/status-row.d.ts.map +1 -0
  67. package/lib/typescript/src/chat/thinking-block.d.ts +35 -0
  68. package/lib/typescript/src/chat/thinking-block.d.ts.map +1 -0
  69. package/lib/typescript/src/index.d.ts +3 -0
  70. package/lib/typescript/src/index.d.ts.map +1 -0
  71. package/lib/typescript/src/internal/color.d.ts +2 -0
  72. package/lib/typescript/src/internal/color.d.ts.map +1 -0
  73. package/lib/typescript/src/internal/use-elapsed.d.ts +3 -0
  74. package/lib/typescript/src/internal/use-elapsed.d.ts.map +1 -0
  75. package/lib/typescript/src/skia/dot-orb.d.ts +16 -0
  76. package/lib/typescript/src/skia/dot-orb.d.ts.map +1 -0
  77. package/lib/typescript/src/skia/index.d.ts +6 -0
  78. package/lib/typescript/src/skia/index.d.ts.map +1 -0
  79. package/lib/typescript/src/skia/liquid-orb.d.ts +20 -0
  80. package/lib/typescript/src/skia/liquid-orb.d.ts.map +1 -0
  81. package/lib/typescript/src/skia/matrix-orb.d.ts +12 -0
  82. package/lib/typescript/src/skia/matrix-orb.d.ts.map +1 -0
  83. package/lib/typescript/src/skia/media-generating.d.ts +59 -0
  84. package/lib/typescript/src/skia/media-generating.d.ts.map +1 -0
  85. package/lib/typescript/src/skia/voice-wave.d.ts +17 -0
  86. package/lib/typescript/src/skia/voice-wave.d.ts.map +1 -0
  87. package/lib/typescript/src/text/index.d.ts +4 -0
  88. package/lib/typescript/src/text/index.d.ts.map +1 -0
  89. package/lib/typescript/src/text/shimmer-text.d.ts +13 -0
  90. package/lib/typescript/src/text/shimmer-text.d.ts.map +1 -0
  91. package/lib/typescript/src/text/spoken-text.d.ts +15 -0
  92. package/lib/typescript/src/text/spoken-text.d.ts.map +1 -0
  93. package/lib/typescript/src/text/streaming-text.d.ts +16 -0
  94. package/lib/typescript/src/text/streaming-text.d.ts.map +1 -0
  95. package/lib/typescript/src/theme/index.d.ts +3 -0
  96. package/lib/typescript/src/theme/index.d.ts.map +1 -0
  97. package/lib/typescript/src/theme/palette.d.ts +33 -0
  98. package/lib/typescript/src/theme/palette.d.ts.map +1 -0
  99. package/lib/typescript/src/theme/provider.d.ts +13 -0
  100. package/lib/typescript/src/theme/provider.d.ts.map +1 -0
  101. package/package.json +133 -4
  102. package/src/brand/index.ts +5 -0
  103. package/src/brand/spinning-mark.tsx +118 -0
  104. package/src/chat/effort-bar.tsx +748 -0
  105. package/src/chat/index.ts +45 -0
  106. package/src/chat/question-card.tsx +649 -0
  107. package/src/chat/reasoning.ts +30 -0
  108. package/src/chat/status-row.tsx +127 -0
  109. package/src/chat/thinking-block.tsx +250 -0
  110. package/src/index.ts +2 -0
  111. package/src/internal/color.ts +18 -0
  112. package/src/internal/use-elapsed.ts +21 -0
  113. package/src/skia/dot-orb.tsx +248 -0
  114. package/src/skia/index.ts +34 -0
  115. package/src/skia/liquid-orb.tsx +274 -0
  116. package/src/skia/matrix-orb.tsx +337 -0
  117. package/src/skia/media-generating.tsx +389 -0
  118. package/src/skia/voice-wave.tsx +215 -0
  119. package/src/text/index.ts +3 -0
  120. package/src/text/shimmer-text.tsx +140 -0
  121. package/src/text/spoken-text.tsx +82 -0
  122. package/src/text/streaming-text.tsx +144 -0
  123. package/src/theme/index.ts +11 -0
  124. package/src/theme/palette.ts +62 -0
  125. package/src/theme/provider.tsx +54 -0
@@ -0,0 +1,30 @@
1
+ export type ReasoningEffort = "low" | "medium" | "high";
2
+
3
+ export type ReasoningLevel = {
4
+ label: string;
5
+ value: ReasoningEffort;
6
+ budget: number;
7
+ description: string;
8
+ };
9
+
10
+ export const REASONING_EFFORTS: ReasoningLevel[] = [
11
+ { label: "Low", value: "low", budget: 0.2, description: "About 20% of max tokens go to reasoning. Fastest replies." },
12
+ { label: "Medium", value: "medium", budget: 0.5, description: "About half of max tokens go to reasoning. The default." },
13
+ { label: "High", value: "high", budget: 0.8, description: "About 80% of max tokens go to reasoning. Slowest, most thorough." },
14
+ ];
15
+
16
+ export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "medium";
17
+
18
+ export function reasoningRequest(effort: ReasoningEffort, options?: { exclude?: boolean }) {
19
+ return { reasoning: { effort, ...(options?.exclude ? { exclude: true } : {}) } };
20
+ }
21
+
22
+ export function reasoningBudget(effort: ReasoningEffort, maxTokens: number) {
23
+ const level = REASONING_EFFORTS.find((item) => item.value === effort) ?? REASONING_EFFORTS[1];
24
+ return Math.max(Math.min(Math.floor(maxTokens * level.budget), 128000), 1024);
25
+ }
26
+
27
+ export function reasoningIndex(effort: ReasoningEffort) {
28
+ const index = REASONING_EFFORTS.findIndex((item) => item.value === effort);
29
+ return index < 0 ? 1 : index;
30
+ }
@@ -0,0 +1,127 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
3
+ import Animated, {
4
+ cancelAnimation,
5
+ Easing,
6
+ FadeInDown,
7
+ FadeOutUp,
8
+ useAnimatedStyle,
9
+ useReducedMotion,
10
+ useSharedValue,
11
+ withRepeat,
12
+ withSequence,
13
+ withTiming,
14
+ } from "react-native-reanimated";
15
+ import { formatElapsed, useElapsed } from "../internal/use-elapsed";
16
+ import { ShimmerText } from "../text/shimmer-text";
17
+ import { type MotionPalette } from "../theme/palette";
18
+ import { useMotionPalette } from "../theme/provider";
19
+
20
+ const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 14, lineHeight: 20, fontWeight: "500" };
21
+
22
+ export type StatusRowIconMotion = "pulse" | "spin" | "none";
23
+
24
+ export type StatusRowProps = {
25
+ label: string;
26
+ icon?: ReactNode;
27
+ iconMotion?: StatusRowIconMotion;
28
+ iconSize?: number;
29
+ active?: boolean;
30
+ startedAt?: number;
31
+ showTimer?: boolean;
32
+ formatTimer?: (seconds: number) => string;
33
+ textStyle?: StyleProp<TextStyle>;
34
+ timerStyle?: StyleProp<TextStyle>;
35
+ colors?: Partial<MotionPalette>;
36
+ style?: StyleProp<ViewStyle>;
37
+ };
38
+
39
+ export function StatusRow({
40
+ label,
41
+ icon,
42
+ iconMotion = "pulse",
43
+ iconSize = 18,
44
+ active = true,
45
+ startedAt,
46
+ showTimer = true,
47
+ formatTimer = formatElapsed,
48
+ textStyle = DEFAULT_TEXT_STYLE,
49
+ timerStyle,
50
+ colors,
51
+ style,
52
+ }: StatusRowProps) {
53
+ const palette = useMotionPalette(colors);
54
+ const reduceMotion = useReducedMotion();
55
+ const seconds = useElapsed(startedAt, active);
56
+
57
+ return (
58
+ <View
59
+ accessible
60
+ accessibilityRole="progressbar"
61
+ accessibilityLabel={label}
62
+ accessibilityState={{ busy: active }}
63
+ style={[{ flexDirection: "row", alignItems: "center", gap: 8 }, style]}
64
+ >
65
+ <StatusIcon size={iconSize} motion={active && !reduceMotion ? iconMotion : "none"}>
66
+ {icon ?? <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: palette.accent }} />}
67
+ </StatusIcon>
68
+ <View style={{ flexShrink: 1, overflow: "hidden" }}>
69
+ <Animated.View
70
+ key={label}
71
+ entering={reduceMotion ? undefined : FadeInDown.duration(240)}
72
+ exiting={reduceMotion ? undefined : FadeOutUp.duration(180)}
73
+ >
74
+ {active ? (
75
+ <ShimmerText
76
+ text={label}
77
+ activeColor={palette.text}
78
+ mutedColor={palette.textTertiary}
79
+ textStyle={textStyle}
80
+ />
81
+ ) : (
82
+ <Text style={[textStyle, { color: palette.textSecondary }]}>{label}</Text>
83
+ )}
84
+ </Animated.View>
85
+ </View>
86
+ {showTimer && startedAt !== undefined && (
87
+ <Text style={[textStyle, { color: palette.textTertiary, fontVariant: ["tabular-nums"] }, timerStyle]}>
88
+ {formatTimer(seconds)}
89
+ </Text>
90
+ )}
91
+ </View>
92
+ );
93
+ }
94
+
95
+ function StatusIcon({ size, motion, children }: { size: number; motion: StatusRowIconMotion; children: ReactNode }) {
96
+ const progress = useSharedValue(0);
97
+
98
+ useEffect(() => {
99
+ cancelAnimation(progress);
100
+ progress.value = 0;
101
+ if (motion === "pulse") {
102
+ const curve = Easing.inOut(Easing.sin);
103
+ progress.value = withRepeat(
104
+ withSequence(withTiming(1, { duration: 700, easing: curve }), withTiming(0, { duration: 700, easing: curve })),
105
+ -1,
106
+ false,
107
+ );
108
+ } else if (motion === "spin") {
109
+ progress.value = withRepeat(withTiming(1, { duration: 1200, easing: Easing.linear }), -1, false);
110
+ }
111
+ return () => cancelAnimation(progress);
112
+ }, [motion, progress]);
113
+
114
+ const animatedStyle = useAnimatedStyle(() => {
115
+ if (motion === "pulse") {
116
+ return { opacity: 0.45 + progress.value * 0.55, transform: [{ scale: 0.88 + progress.value * 0.12 }] };
117
+ }
118
+ if (motion === "spin") return { opacity: 1, transform: [{ rotate: `${progress.value * 360}deg` }] };
119
+ return { opacity: 1, transform: [] };
120
+ });
121
+
122
+ return (
123
+ <Animated.View style={[{ width: size, height: size, alignItems: "center", justifyContent: "center" }, animatedStyle]}>
124
+ {children}
125
+ </Animated.View>
126
+ );
127
+ }
@@ -0,0 +1,250 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import {
3
+ type LayoutChangeEvent,
4
+ Pressable,
5
+ Text,
6
+ View,
7
+ type StyleProp,
8
+ type TextStyle,
9
+ type ViewStyle,
10
+ } from "react-native";
11
+ import Animated, {
12
+ cancelAnimation,
13
+ Easing,
14
+ FadeIn,
15
+ FadeInDown,
16
+ useAnimatedStyle,
17
+ useReducedMotion,
18
+ useSharedValue,
19
+ withRepeat,
20
+ withSequence,
21
+ withSpring,
22
+ withTiming,
23
+ } from "react-native-reanimated";
24
+ import { useElapsed } from "../internal/use-elapsed";
25
+ import { ShimmerText } from "../text/shimmer-text";
26
+ import { type MotionPalette } from "../theme/palette";
27
+ import { useMotionPalette } from "../theme/provider";
28
+
29
+ const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 14, lineHeight: 20, fontWeight: "500" };
30
+ const DEFAULT_DETAIL_STYLE: TextStyle = { fontSize: 14, lineHeight: 21 };
31
+ const OPEN_SPRING = { damping: 22, stiffness: 210, mass: 0.9 };
32
+ const DOT = 7;
33
+
34
+ export type ThinkingStepStatus = "done" | "active" | "pending";
35
+
36
+ export type ThinkingStep = {
37
+ key?: string;
38
+ title: string;
39
+ detail?: string;
40
+ status?: ThinkingStepStatus;
41
+ };
42
+
43
+ export type ThinkingBlockLabels = {
44
+ thinking: string;
45
+ done: (seconds: number) => string;
46
+ };
47
+
48
+ export type ThinkingBlockProps = {
49
+ thinking: boolean;
50
+ startedAt?: number;
51
+ seconds?: number;
52
+ title?: string;
53
+ labels?: Partial<ThinkingBlockLabels>;
54
+ icon?: ReactNode;
55
+ chevron?: ReactNode;
56
+ steps?: ThinkingStep[];
57
+ renderStep?: (step: ThinkingStep, index: number) => ReactNode;
58
+ children?: ReactNode;
59
+ open?: boolean;
60
+ defaultOpen?: boolean;
61
+ onOpenChange?: (open: boolean) => void;
62
+ textStyle?: StyleProp<TextStyle>;
63
+ detailStyle?: StyleProp<TextStyle>;
64
+ colors?: Partial<MotionPalette>;
65
+ style?: StyleProp<ViewStyle>;
66
+ };
67
+
68
+ const DEFAULT_LABELS: ThinkingBlockLabels = {
69
+ thinking: "Thinking",
70
+ done: (seconds) => (seconds < 1 ? "Thought for a moment" : `Thought for ${seconds}s`),
71
+ };
72
+
73
+ export function ThinkingBlock({
74
+ thinking,
75
+ startedAt,
76
+ seconds,
77
+ title,
78
+ labels,
79
+ icon,
80
+ chevron,
81
+ steps,
82
+ renderStep,
83
+ children,
84
+ open: controlledOpen,
85
+ defaultOpen = false,
86
+ onOpenChange,
87
+ textStyle = DEFAULT_TEXT_STYLE,
88
+ detailStyle = DEFAULT_DETAIL_STYLE,
89
+ colors,
90
+ style,
91
+ }: ThinkingBlockProps) {
92
+ const palette = useMotionPalette(colors);
93
+ const reduceMotion = useReducedMotion();
94
+ const copy = { ...DEFAULT_LABELS, ...labels };
95
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
96
+ const open = controlledOpen ?? uncontrolledOpen;
97
+ const elapsed = useElapsed(startedAt, thinking);
98
+ const frozen = useRef(0);
99
+ if (thinking) frozen.current = elapsed;
100
+ const totalSeconds = seconds ?? frozen.current;
101
+ const hasBody = !!children || (steps?.length ?? 0) > 0;
102
+
103
+ const [contentHeight, setContentHeight] = useState(0);
104
+ const height = useSharedValue(0);
105
+ const turn = useSharedValue(open ? 1 : 0);
106
+
107
+ useEffect(() => {
108
+ const target = open ? contentHeight : 0;
109
+ height.value = reduceMotion ? target : withSpring(target, OPEN_SPRING);
110
+ turn.value = reduceMotion ? (open ? 1 : 0) : withTiming(open ? 1 : 0, { duration: 220 });
111
+ }, [contentHeight, height, open, reduceMotion, turn]);
112
+
113
+ const toggle = () => {
114
+ if (!hasBody) return;
115
+ const next = !open;
116
+ if (controlledOpen === undefined) setUncontrolledOpen(next);
117
+ onOpenChange?.(next);
118
+ };
119
+
120
+ const bodyStyle = useAnimatedStyle(() => ({ height: height.value, opacity: Math.min(1, height.value / 24) }));
121
+ const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.value * 90}deg` }] }));
122
+
123
+ const handleContentLayout = (event: LayoutChangeEvent) => {
124
+ const next = Math.ceil(event.nativeEvent.layout.height);
125
+ if (next !== contentHeight) setContentHeight(next);
126
+ };
127
+
128
+ const heading = title ?? (thinking ? copy.thinking : copy.done(totalSeconds));
129
+
130
+ return (
131
+ <View style={style}>
132
+ <Pressable
133
+ onPress={toggle}
134
+ disabled={!hasBody}
135
+ accessibilityRole="button"
136
+ accessibilityLabel={heading}
137
+ accessibilityState={{ expanded: open, busy: thinking }}
138
+ hitSlop={8}
139
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start" }}
140
+ >
141
+ {icon !== undefined && <View style={{ alignItems: "center", justifyContent: "center" }}>{icon}</View>}
142
+ <Animated.View key={thinking ? "thinking" : "done"} entering={reduceMotion ? undefined : FadeIn.duration(220)}>
143
+ {thinking && !reduceMotion ? (
144
+ <ShimmerText
145
+ text={thinking && startedAt !== undefined ? `${heading} ${elapsed}s` : heading}
146
+ activeColor={palette.text}
147
+ mutedColor={palette.textTertiary}
148
+ textStyle={textStyle}
149
+ />
150
+ ) : (
151
+ <Text style={[textStyle, { color: palette.textSecondary }]}>{heading}</Text>
152
+ )}
153
+ </Animated.View>
154
+ {hasBody && (
155
+ <Animated.View style={chevronStyle}>
156
+ {chevron ?? <Text style={[textStyle, { color: palette.textTertiary }]}>›</Text>}
157
+ </Animated.View>
158
+ )}
159
+ </Pressable>
160
+ {hasBody && (
161
+ <Animated.View style={[{ overflow: "hidden" }, bodyStyle]}>
162
+ <View
163
+ onLayout={handleContentLayout}
164
+ style={{ position: "absolute", left: 0, right: 0, top: 0, paddingTop: 10 }}
165
+ >
166
+ {steps && steps.length > 0 && (
167
+ <View>
168
+ {steps.map((step, index) => (
169
+ <Animated.View
170
+ key={step.key ?? `${index}`}
171
+ entering={reduceMotion ? undefined : FadeInDown.duration(260).delay(index * 40)}
172
+ >
173
+ {renderStep ? (
174
+ renderStep(step, index)
175
+ ) : (
176
+ <TimelineStep
177
+ step={step}
178
+ last={index === steps.length - 1}
179
+ palette={palette}
180
+ textStyle={textStyle}
181
+ detailStyle={detailStyle}
182
+ animate={!reduceMotion}
183
+ />
184
+ )}
185
+ </Animated.View>
186
+ ))}
187
+ </View>
188
+ )}
189
+ {children}
190
+ </View>
191
+ </Animated.View>
192
+ )}
193
+ </View>
194
+ );
195
+ }
196
+
197
+ type TimelineStepProps = {
198
+ step: ThinkingStep;
199
+ last: boolean;
200
+ palette: MotionPalette;
201
+ textStyle: StyleProp<TextStyle>;
202
+ detailStyle: StyleProp<TextStyle>;
203
+ animate: boolean;
204
+ };
205
+
206
+ function TimelineStep({ step, last, palette, textStyle, detailStyle, animate }: TimelineStepProps) {
207
+ const status = step.status ?? "done";
208
+ return (
209
+ <View style={{ flexDirection: "row", gap: 12 }}>
210
+ <View style={{ width: DOT, alignItems: "center" }}>
211
+ <TimelineDot status={status} palette={palette} animate={animate} />
212
+ {!last && <View style={{ flex: 1, width: 1, marginVertical: 4, backgroundColor: palette.raised }} />}
213
+ </View>
214
+ <View style={{ flex: 1, paddingBottom: last ? 0 : 14, marginTop: -6 }}>
215
+ <Text style={[textStyle, { color: status === "pending" ? palette.textTertiary : palette.text }]}>{step.title}</Text>
216
+ {step.detail ? (
217
+ <Text style={[detailStyle, { color: palette.textSecondary, marginTop: 2 }]}>{step.detail}</Text>
218
+ ) : null}
219
+ </View>
220
+ </View>
221
+ );
222
+ }
223
+
224
+ function TimelineDot({ status, palette, animate }: { status: ThinkingStepStatus; palette: MotionPalette; animate: boolean }) {
225
+ const pulse = useSharedValue(1);
226
+
227
+ useEffect(() => {
228
+ if (status !== "active" || !animate) {
229
+ cancelAnimation(pulse);
230
+ pulse.value = 1;
231
+ return;
232
+ }
233
+ const curve = Easing.inOut(Easing.sin);
234
+ pulse.value = withRepeat(
235
+ withSequence(withTiming(0.35, { duration: 650, easing: curve }), withTiming(1, { duration: 650, easing: curve })),
236
+ -1,
237
+ false,
238
+ );
239
+ return () => cancelAnimation(pulse);
240
+ }, [animate, pulse, status]);
241
+
242
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: pulse.value }));
243
+ const color = status === "pending" ? palette.raised : status === "active" ? palette.accent : palette.textTertiary;
244
+
245
+ return (
246
+ <Animated.View
247
+ style={[{ width: DOT, height: DOT, borderRadius: DOT / 2, backgroundColor: color, marginTop: 1 }, animatedStyle]}
248
+ />
249
+ );
250
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./theme";
2
+ export * from "./text";
@@ -0,0 +1,18 @@
1
+ export function withAlpha(color: string, alpha: number): string {
2
+ if (color.startsWith("rgb")) {
3
+ const parts = color.replace(/rgba?\(|\)/g, "").split(",").map((part) => part.trim());
4
+ return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
5
+ }
6
+ const hex = color.replace("#", "");
7
+ const full =
8
+ hex.length === 3
9
+ ? hex
10
+ .split("")
11
+ .map((char) => char + char)
12
+ .join("")
13
+ : hex.slice(0, 6);
14
+ const r = parseInt(full.slice(0, 2), 16);
15
+ const g = parseInt(full.slice(2, 4), 16);
16
+ const b = parseInt(full.slice(4, 6), 16);
17
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
18
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useElapsed(startedAt: number | undefined, running: boolean, interval = 1000) {
4
+ const [now, setNow] = useState(() => Date.now());
5
+
6
+ useEffect(() => {
7
+ if (!running || startedAt === undefined) return;
8
+ setNow(Date.now());
9
+ const id = setInterval(() => setNow(Date.now()), interval);
10
+ return () => clearInterval(id);
11
+ }, [interval, running, startedAt]);
12
+
13
+ if (startedAt === undefined) return 0;
14
+ return Math.max(0, Math.floor((now - startedAt) / 1000));
15
+ }
16
+
17
+ export function formatElapsed(seconds: number) {
18
+ const minutes = Math.floor(seconds / 60);
19
+ const rest = seconds % 60;
20
+ return minutes > 0 ? `${minutes}:${String(rest).padStart(2, "0")}` : `${rest}s`;
21
+ }