@ateam-ai-mobile/plugin-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ import { useEffect } from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import Animated, {
4
+ useSharedValue,
5
+ useAnimatedProps,
6
+ withRepeat,
7
+ withTiming,
8
+ withSequence,
9
+ withDelay,
10
+ Easing,
11
+ cancelAnimation,
12
+ } from 'react-native-reanimated';
13
+ import Svg, { Circle, Defs, RadialGradient, Stop, Text as SvgText } from 'react-native-svg';
14
+
15
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
16
+
17
+ export type AdaOrbState = 'idle' | 'listening' | 'thinking' | 'speaking';
18
+
19
+ interface AdaOrbProps {
20
+ size?: number;
21
+ state?: AdaOrbState;
22
+ /** Show the "iA" wordmark inside the orb. Suppressed below 60px because
23
+ * glyphs become unreadable at avatar/chevron sizes — render as a plain
24
+ * orb there. Default true. */
25
+ inscription?: boolean;
26
+ }
27
+
28
+ export default function AdaOrb({ size = 120, state = 'idle', inscription = true }: AdaOrbProps) {
29
+ const showInscription = inscription && size >= 60;
30
+ const center = size / 2;
31
+ const coreR = size * 0.32;
32
+ const innerHaloR = size * 0.42;
33
+ const outerHaloR = size * 0.5;
34
+
35
+ // Base breath layers
36
+ const coreScale = useSharedValue(1);
37
+ const innerScale = useSharedValue(1);
38
+ const outerScale = useSharedValue(1);
39
+ const outerOpacity = useSharedValue(0.55);
40
+
41
+ // Listening: continuous ripple cycle (one shared value, phase-shifted reads)
42
+ const rippleCycle = useSharedValue(0);
43
+
44
+ // Thinking: 3 orbiting sparks at different speeds + directions
45
+ const orbit1 = useSharedValue(0); // fast inner
46
+ const orbit2 = useSharedValue(0); // medium mid
47
+ const orbit3 = useSharedValue(0); // slow outer (counter)
48
+
49
+ // Speaking: secondary fast double-beat overlay
50
+ const beat = useSharedValue(1);
51
+
52
+ useEffect(() => {
53
+ cancelAnimation(coreScale);
54
+ cancelAnimation(innerScale);
55
+ cancelAnimation(outerScale);
56
+ cancelAnimation(outerOpacity);
57
+ cancelAnimation(rippleCycle);
58
+ cancelAnimation(orbit1);
59
+ cancelAnimation(orbit2);
60
+ cancelAnimation(orbit3);
61
+ cancelAnimation(beat);
62
+
63
+ // Reset state-specific drivers
64
+ rippleCycle.value = 0;
65
+ orbit1.value = 0;
66
+ orbit2.value = 0;
67
+ orbit3.value = 0;
68
+ beat.value = 1;
69
+
70
+ let breathMs = 3600;
71
+ let breathAmount = 0.025;
72
+
73
+ if (state === 'listening') {
74
+ breathMs = 2000;
75
+ breathAmount = 0.05;
76
+ // Continuous ripple loop — one cycle = one ripple lifetime
77
+ rippleCycle.value = withRepeat(
78
+ withTiming(1, { duration: 2000, easing: Easing.out(Easing.quad) }),
79
+ -1,
80
+ false,
81
+ );
82
+ } else if (state === 'thinking') {
83
+ breathMs = 2400;
84
+ breathAmount = 0.04;
85
+ // Three sparks orbiting at different speeds + directions — feels like processing
86
+ orbit1.value = withRepeat(
87
+ withTiming(1, { duration: 1100, easing: Easing.linear }),
88
+ -1,
89
+ false,
90
+ );
91
+ orbit2.value = withRepeat(
92
+ withTiming(1, { duration: 1900, easing: Easing.linear }),
93
+ -1,
94
+ false,
95
+ );
96
+ orbit3.value = withRepeat(
97
+ withTiming(-1, { duration: 2700, easing: Easing.linear }),
98
+ -1,
99
+ false,
100
+ );
101
+ } else if (state === 'speaking') {
102
+ breathMs = 700;
103
+ breathAmount = 0.13;
104
+ // Double-beat overlay — quick punch + secondary kick
105
+ beat.value = withRepeat(
106
+ withSequence(
107
+ withTiming(1.08, { duration: 90, easing: Easing.out(Easing.quad) }),
108
+ withTiming(1.0, { duration: 130, easing: Easing.in(Easing.quad) }),
109
+ withTiming(1.05, { duration: 80, easing: Easing.out(Easing.quad) }),
110
+ withTiming(1.0, { duration: 200, easing: Easing.in(Easing.quad) }),
111
+ withTiming(1.0, { duration: 280 }),
112
+ ),
113
+ -1,
114
+ false,
115
+ );
116
+ }
117
+
118
+ // Base breath — different easing per state
119
+ const breathEasing =
120
+ state === 'speaking' ? Easing.out(Easing.cubic) : Easing.inOut(Easing.sin);
121
+
122
+ coreScale.value = withRepeat(
123
+ withSequence(
124
+ withTiming(1 + breathAmount, { duration: breathMs, easing: breathEasing }),
125
+ withTiming(1, { duration: breathMs, easing: breathEasing }),
126
+ ),
127
+ -1,
128
+ false,
129
+ );
130
+ innerScale.value = withRepeat(
131
+ withSequence(
132
+ withTiming(1 + breathAmount * 1.4, { duration: breathMs * 1.15, easing: breathEasing }),
133
+ withTiming(1, { duration: breathMs * 1.15, easing: breathEasing }),
134
+ ),
135
+ -1,
136
+ false,
137
+ );
138
+ outerScale.value = withRepeat(
139
+ withSequence(
140
+ withTiming(1 + breathAmount * 1.8, { duration: breathMs * 1.3, easing: breathEasing }),
141
+ withTiming(1, { duration: breathMs * 1.3, easing: breathEasing }),
142
+ ),
143
+ -1,
144
+ false,
145
+ );
146
+ outerOpacity.value = withRepeat(
147
+ withSequence(
148
+ withTiming(state === 'idle' ? 0.55 : 0.8, { duration: breathMs * 1.3, easing: breathEasing }),
149
+ withTiming(0.35, { duration: breathMs * 1.3, easing: breathEasing }),
150
+ ),
151
+ -1,
152
+ false,
153
+ );
154
+ }, [state]);
155
+
156
+ // Animated props for base layers — multiplied by `beat` so speaking double-pulse stacks
157
+ const coreProps = useAnimatedProps(() => ({
158
+ r: coreR * coreScale.value * beat.value,
159
+ }));
160
+ const innerProps = useAnimatedProps(() => ({
161
+ r: innerHaloR * innerScale.value * beat.value,
162
+ }));
163
+ const outerProps = useAnimatedProps(() => ({
164
+ r: outerHaloR * outerScale.value,
165
+ opacity: outerOpacity.value,
166
+ }));
167
+
168
+ // Listening ripples — three phase-shifted reads of rippleCycle
169
+ const ripple1Props = useAnimatedProps(() => {
170
+ const t = rippleCycle.value;
171
+ return {
172
+ r: outerHaloR * (1 + t * 0.55),
173
+ opacity: state === 'listening' ? 0.55 * (1 - t) : 0,
174
+ };
175
+ });
176
+ const ripple2Props = useAnimatedProps(() => {
177
+ const t = (rippleCycle.value + 0.34) % 1;
178
+ return {
179
+ r: outerHaloR * (1 + t * 0.55),
180
+ opacity: state === 'listening' ? 0.45 * (1 - t) : 0,
181
+ };
182
+ });
183
+ const ripple3Props = useAnimatedProps(() => {
184
+ const t = (rippleCycle.value + 0.67) % 1;
185
+ return {
186
+ r: outerHaloR * (1 + t * 0.55),
187
+ opacity: state === 'listening' ? 0.35 * (1 - t) : 0,
188
+ };
189
+ });
190
+
191
+ // Thinking sparks — three at different radii and angular velocities
192
+ const spark1Props = useAnimatedProps(() => {
193
+ const angle = orbit1.value * Math.PI * 2 - Math.PI / 2;
194
+ const r = coreR * 0.55;
195
+ return {
196
+ cx: center + Math.cos(angle) * r,
197
+ cy: center + Math.sin(angle) * r,
198
+ opacity: state === 'thinking' ? 1 : 0,
199
+ };
200
+ });
201
+ const spark2Props = useAnimatedProps(() => {
202
+ const angle = orbit2.value * Math.PI * 2 + Math.PI * 0.6;
203
+ const r = coreR * 0.85;
204
+ return {
205
+ cx: center + Math.cos(angle) * r,
206
+ cy: center + Math.sin(angle) * r,
207
+ opacity: state === 'thinking' ? 0.9 : 0,
208
+ };
209
+ });
210
+ const spark3Props = useAnimatedProps(() => {
211
+ const angle = orbit3.value * Math.PI * 2 + Math.PI * 1.3;
212
+ const r = coreR * 1.05;
213
+ return {
214
+ cx: center + Math.cos(angle) * r,
215
+ cy: center + Math.sin(angle) * r,
216
+ opacity: state === 'thinking' ? 0.7 : 0,
217
+ };
218
+ });
219
+
220
+ return (
221
+ <View style={[styles.wrap, { width: size, height: size }]}>
222
+ <Svg width={size} height={size}>
223
+ <Defs>
224
+ <RadialGradient id="adaCore" cx="36%" cy="30%" r="70%">
225
+ <Stop offset="0%" stopColor="#FFE0B0" stopOpacity="1" />
226
+ <Stop offset="28%" stopColor="#FFA85A" stopOpacity="1" />
227
+ <Stop offset="65%" stopColor="#FF7A28" stopOpacity="1" />
228
+ <Stop offset="100%" stopColor="#B8410A" stopOpacity="1" />
229
+ </RadialGradient>
230
+ <RadialGradient id="adaInner" cx="50%" cy="50%" r="50%">
231
+ <Stop offset="0%" stopColor="#FF8A3C" stopOpacity="0.55" />
232
+ <Stop offset="100%" stopColor="#FF7028" stopOpacity="0" />
233
+ </RadialGradient>
234
+ <RadialGradient id="adaOuter" cx="50%" cy="50%" r="50%">
235
+ <Stop offset="0%" stopColor="#FF7A28" stopOpacity="0.4" />
236
+ <Stop offset="100%" stopColor="#FF6018" stopOpacity="0" />
237
+ </RadialGradient>
238
+ <RadialGradient id="adaSpark" cx="50%" cy="50%" r="50%">
239
+ <Stop offset="0%" stopColor="#FFFAEB" stopOpacity="1" />
240
+ <Stop offset="60%" stopColor="#FFD095" stopOpacity="0.9" />
241
+ <Stop offset="100%" stopColor="#FFA85A" stopOpacity="0" />
242
+ </RadialGradient>
243
+ </Defs>
244
+
245
+ {/* Listening ripples — drawn behind orb body */}
246
+ <AnimatedCircle
247
+ cx={center}
248
+ cy={center}
249
+ stroke="#FF7A28"
250
+ strokeWidth={1.5}
251
+ fill="none"
252
+ animatedProps={ripple1Props}
253
+ />
254
+ <AnimatedCircle
255
+ cx={center}
256
+ cy={center}
257
+ stroke="#FF7A28"
258
+ strokeWidth={1.5}
259
+ fill="none"
260
+ animatedProps={ripple2Props}
261
+ />
262
+ <AnimatedCircle
263
+ cx={center}
264
+ cy={center}
265
+ stroke="#FF7A28"
266
+ strokeWidth={1.5}
267
+ fill="none"
268
+ animatedProps={ripple3Props}
269
+ />
270
+
271
+ {/* Base orb body */}
272
+ <AnimatedCircle cx={center} cy={center} fill="url(#adaOuter)" animatedProps={outerProps} />
273
+ <AnimatedCircle cx={center} cy={center} fill="url(#adaInner)" animatedProps={innerProps} />
274
+ <AnimatedCircle cx={center} cy={center} fill="url(#adaCore)" animatedProps={coreProps} />
275
+
276
+ {/* Thinking sparks — three orbiting particles drawn on top */}
277
+ <AnimatedCircle
278
+ r={Math.max(3, size * 0.05)}
279
+ fill="url(#adaSpark)"
280
+ animatedProps={spark1Props}
281
+ />
282
+ <AnimatedCircle
283
+ r={Math.max(2.5, size * 0.04)}
284
+ fill="url(#adaSpark)"
285
+ animatedProps={spark2Props}
286
+ />
287
+ <AnimatedCircle
288
+ r={Math.max(2, size * 0.032)}
289
+ fill="url(#adaSpark)"
290
+ animatedProps={spark3Props}
291
+ />
292
+
293
+ {/* "iA" wordmark — matches the app icon. Suppressed at small sizes
294
+ where the glyphs would be unreadable (see showInscription). */}
295
+ {showInscription && (
296
+ <SvgText
297
+ x={center}
298
+ y={center + size * 0.11}
299
+ fontSize={size * 0.34}
300
+ fontWeight="600"
301
+ fill="#FFF5E0"
302
+ textAnchor="middle"
303
+ letterSpacing={-size * 0.005}
304
+ >
305
+ iA
306
+ </SvgText>
307
+ )}
308
+ </Svg>
309
+ </View>
310
+ );
311
+ }
312
+
313
+ const styles = StyleSheet.create({
314
+ wrap: {
315
+ alignItems: 'center',
316
+ justifyContent: 'center',
317
+ },
318
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * SurfaceCard — a tappable tile shape for plugins that surface as cards
3
+ * (empty-state suggestions, dashboards, daily-brief items).
4
+ *
5
+ * Plugins declare icon + title + subtitle + onPress; the host provides
6
+ * the warm Ada chrome (paper-soft bg, soft border, arrow chevron, haptic).
7
+ *
8
+ * Optional `children` render below the title block for richer previews
9
+ * (e.g. small ring widget, last-meal photo, current weather).
10
+ *
11
+ * Usage:
12
+ * <SurfaceCard
13
+ * icon="🥗"
14
+ * title="Today's plate"
15
+ * subtitle="2,100 kcal · 140g protein"
16
+ * onPress={() => sendPrompt('Tell me about my nutrition today')}
17
+ * />
18
+ */
19
+ import { type ReactNode } from 'react';
20
+ import {
21
+ View,
22
+ Text,
23
+ Pressable,
24
+ StyleSheet,
25
+ Platform,
26
+ } from 'react-native';
27
+ import { getHaptics } from '../host';
28
+ import { ada, borderRadius } from '@ateam-ai-mobile/core';
29
+
30
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
31
+
32
+ export interface SurfaceCardProps {
33
+ /** Emoji or short string icon shown on the left */
34
+ icon?: string;
35
+ /** Card headline (e.g. "Today's plate") */
36
+ title: string;
37
+ /** Optional secondary line below title */
38
+ subtitle?: string;
39
+ /** Tap handler. Fires haptic before invoking. */
40
+ onPress?: () => void;
41
+ /** Long-press handler. Fires a heavier haptic. Used by user widgets for
42
+ * the Delete / (later) Hide / Rename context menu. */
43
+ onLongPress?: () => void;
44
+ /** Whether to show the ›-style trailing arrow (default: true if onPress) */
45
+ showArrow?: boolean;
46
+ /** Optional preview content rendered below the title block */
47
+ children?: ReactNode;
48
+ }
49
+
50
+ export default function SurfaceCard({
51
+ icon,
52
+ title,
53
+ subtitle,
54
+ onPress,
55
+ onLongPress,
56
+ showArrow,
57
+ children,
58
+ }: SurfaceCardProps) {
59
+ const arrow = showArrow ?? !!onPress;
60
+ const handlePress = () => {
61
+ if (!onPress) return;
62
+ getHaptics()?.impact('light');
63
+ onPress();
64
+ };
65
+ const handleLongPress = () => {
66
+ if (!onLongPress) return;
67
+ getHaptics()?.impact('medium');
68
+ onLongPress();
69
+ };
70
+
71
+ return (
72
+ <Pressable
73
+ accessibilityRole="button"
74
+ accessibilityLabel={[icon, title, subtitle].filter(Boolean).join(', ')}
75
+ accessibilityHint={onLongPress ? 'Long-press for more options' : undefined}
76
+ style={styles.card}
77
+ onPress={onPress ? handlePress : undefined}
78
+ onLongPress={onLongPress ? handleLongPress : undefined}
79
+ disabled={!onPress && !onLongPress}
80
+ >
81
+ <View style={styles.row}>
82
+ {icon ? <Text style={styles.icon}>{icon}</Text> : null}
83
+ <View style={{ flex: 1 }}>
84
+ <Text style={styles.title}>{title}</Text>
85
+ {subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
86
+ </View>
87
+ {arrow ? <Text style={styles.arrow}>{'\u2192'}</Text> : null}
88
+ </View>
89
+ {children ? <View style={styles.body}>{children}</View> : null}
90
+ </Pressable>
91
+ );
92
+ }
93
+
94
+ const styles = StyleSheet.create({
95
+ card: {
96
+ backgroundColor: ada.paperSoft,
97
+ borderWidth: 1,
98
+ borderColor: ada.border,
99
+ borderRadius: borderRadius.xl,
100
+ paddingHorizontal: 16,
101
+ paddingVertical: 14,
102
+ },
103
+ row: {
104
+ flexDirection: 'row',
105
+ alignItems: 'center',
106
+ gap: 14,
107
+ },
108
+ icon: {
109
+ fontSize: 22,
110
+ },
111
+ title: {
112
+ fontSize: 15,
113
+ fontWeight: '600',
114
+ color: ada.ink,
115
+ letterSpacing: -0.2,
116
+ fontFamily: fontFam,
117
+ },
118
+ subtitle: {
119
+ fontSize: 13,
120
+ fontWeight: '400',
121
+ color: ada.inkMuted,
122
+ marginTop: 2,
123
+ letterSpacing: -0.1,
124
+ fontFamily: fontFam,
125
+ },
126
+ arrow: {
127
+ fontSize: 18,
128
+ color: ada.inkMuted,
129
+ fontWeight: '400',
130
+ },
131
+ body: {
132
+ marginTop: 10,
133
+ paddingTop: 10,
134
+ borderTopWidth: 1,
135
+ borderTopColor: ada.border,
136
+ },
137
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * SurfaceDrawer — generic bottom-sheet container for plugins.
3
+ *
4
+ * Plugins render their content; SurfaceDrawer provides the Ada chrome:
5
+ * drag handle, header (orb + title + subtitle + optional privacy pill),
6
+ * close button, motion, backdrop dismiss.
7
+ *
8
+ * Usage:
9
+ * <SurfaceDrawer
10
+ * visible={open}
11
+ * onClose={() => setOpen(false)}
12
+ * title="What I remember about you"
13
+ * subtitle="Only you can see this"
14
+ * privacy
15
+ * >
16
+ * <MyPluginContent />
17
+ * </SurfaceDrawer>
18
+ */
19
+ import { useEffect, useRef, type ReactNode } from 'react';
20
+ import {
21
+ View,
22
+ Text,
23
+ Pressable,
24
+ StyleSheet,
25
+ Animated,
26
+ Dimensions,
27
+ Platform,
28
+ } from 'react-native';
29
+ import Svg, { Path } from 'react-native-svg';
30
+ import { ada } from '@ateam-ai-mobile/core';
31
+ import AdaOrb from './AdaOrb';
32
+
33
+ const SCREEN_H = Dimensions.get('window').height;
34
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
35
+
36
+ export interface SurfaceDrawerProps {
37
+ visible: boolean;
38
+ onClose: () => void;
39
+ title: string;
40
+ subtitle?: string;
41
+ /** Show the lock + "Only you can see this" privacy pill under the title */
42
+ privacy?: boolean;
43
+ /** Show the small Ada orb in the header (default: true) */
44
+ showOrb?: boolean;
45
+ /** Sheet height ratio of screen (default: 0.85) */
46
+ heightRatio?: number;
47
+ children: ReactNode;
48
+ }
49
+
50
+ export default function SurfaceDrawer({
51
+ visible,
52
+ onClose,
53
+ title,
54
+ subtitle,
55
+ privacy,
56
+ showOrb = true,
57
+ heightRatio = 0.85,
58
+ children,
59
+ }: SurfaceDrawerProps) {
60
+ const sheetH = Math.round(SCREEN_H * heightRatio);
61
+ const fadeAnim = useRef(new Animated.Value(0)).current;
62
+ const slideAnim = useRef(new Animated.Value(sheetH)).current;
63
+
64
+ useEffect(() => {
65
+ if (visible) {
66
+ Animated.parallel([
67
+ Animated.timing(fadeAnim, { toValue: 1, duration: 220, useNativeDriver: true }),
68
+ Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 80, friction: 12 }),
69
+ ]).start();
70
+ } else {
71
+ fadeAnim.setValue(0);
72
+ slideAnim.setValue(sheetH);
73
+ }
74
+ }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
75
+
76
+ const close = () => {
77
+ Animated.parallel([
78
+ Animated.timing(fadeAnim, { toValue: 0, duration: 160, useNativeDriver: true }),
79
+ Animated.timing(slideAnim, { toValue: sheetH, duration: 200, useNativeDriver: true }),
80
+ ]).start(() => onClose());
81
+ };
82
+
83
+ if (!visible) return null;
84
+
85
+ return (
86
+ <Animated.View style={[styles.overlay, { opacity: fadeAnim }]} pointerEvents="auto">
87
+ <Pressable style={StyleSheet.absoluteFill} onPress={close} />
88
+ <Animated.View
89
+ style={[
90
+ styles.sheet,
91
+ { height: sheetH, transform: [{ translateY: slideAnim }] },
92
+ ]}
93
+ >
94
+ {/* Drag handle */}
95
+ <View style={styles.handle} />
96
+
97
+ {/* Header */}
98
+ <View style={styles.header}>
99
+ {showOrb ? (
100
+ <View style={styles.headerOrb}>
101
+ <AdaOrb size={36} state="idle" />
102
+ </View>
103
+ ) : null}
104
+ <View style={{ flex: 1 }}>
105
+ <Text style={styles.title}>{title}</Text>
106
+ {subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
107
+ {privacy ? (
108
+ <View style={styles.privacyPill}>
109
+ <Svg width={11} height={11} viewBox="0 0 24 24" fill="none" stroke={ada.inkSoft} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
110
+ <Path d="M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2z" />
111
+ <Path d="M7 11V7a5 5 0 0110 0v4" />
112
+ </Svg>
113
+ <Text style={styles.privacyText}>Only you can see this</Text>
114
+ </View>
115
+ ) : null}
116
+ </View>
117
+ <Pressable
118
+ accessibilityRole="button"
119
+ accessibilityLabel="Close"
120
+ style={styles.closeBtn}
121
+ onPress={close}
122
+ hitSlop={12}
123
+ >
124
+ <Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={ada.inkSoft} strokeWidth={2.2} strokeLinecap="round">
125
+ <Path d="M18 6L6 18M6 6l12 12" />
126
+ </Svg>
127
+ </Pressable>
128
+ </View>
129
+
130
+ {/* Body — caller renders content */}
131
+ <View style={styles.body}>{children}</View>
132
+ </Animated.View>
133
+ </Animated.View>
134
+ );
135
+ }
136
+
137
+ const styles = StyleSheet.create({
138
+ overlay: {
139
+ position: 'absolute',
140
+ top: 0, left: 0, right: 0, bottom: 0,
141
+ backgroundColor: 'rgba(26, 20, 16, 0.35)',
142
+ justifyContent: 'flex-end',
143
+ zIndex: 999,
144
+ elevation: 999,
145
+ },
146
+ sheet: {
147
+ backgroundColor: ada.paper,
148
+ borderTopLeftRadius: 28,
149
+ borderTopRightRadius: 28,
150
+ overflow: 'hidden',
151
+ },
152
+ handle: {
153
+ width: 40,
154
+ height: 4,
155
+ borderRadius: 2,
156
+ backgroundColor: ada.inkMuted,
157
+ opacity: 0.3,
158
+ alignSelf: 'center',
159
+ marginTop: 10,
160
+ marginBottom: 6,
161
+ },
162
+ header: {
163
+ flexDirection: 'row',
164
+ alignItems: 'center',
165
+ paddingHorizontal: 22,
166
+ paddingVertical: 16,
167
+ gap: 12,
168
+ borderBottomWidth: 1,
169
+ borderBottomColor: ada.border,
170
+ },
171
+ headerOrb: {
172
+ width: 36,
173
+ height: 36,
174
+ alignItems: 'center',
175
+ justifyContent: 'center',
176
+ },
177
+ title: {
178
+ fontSize: 17,
179
+ fontWeight: '600',
180
+ color: ada.ink,
181
+ letterSpacing: -0.2,
182
+ fontFamily: fontFam,
183
+ },
184
+ subtitle: {
185
+ fontSize: 13,
186
+ fontWeight: '400',
187
+ color: ada.inkSoft,
188
+ marginTop: 2,
189
+ letterSpacing: -0.1,
190
+ fontFamily: fontFam,
191
+ },
192
+ privacyPill: {
193
+ flexDirection: 'row',
194
+ alignItems: 'center',
195
+ gap: 5,
196
+ marginTop: 4,
197
+ },
198
+ privacyText: {
199
+ fontSize: 11,
200
+ fontWeight: '500',
201
+ color: ada.inkSoft,
202
+ letterSpacing: 0.2,
203
+ fontFamily: fontFam,
204
+ },
205
+ closeBtn: {
206
+ width: 32,
207
+ height: 32,
208
+ borderRadius: 16,
209
+ backgroundColor: ada.paperSoft,
210
+ justifyContent: 'center',
211
+ alignItems: 'center',
212
+ },
213
+ body: {
214
+ flex: 1,
215
+ },
216
+ });