@alia.onl/sdk 10.1.1 → 11.0.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.
- package/CHANGELOG.md +53 -0
- package/README.md +24 -0
- package/package.json +4 -6
- package/src/components/AliaAvatar.tsx +66 -0
- package/src/components/AliaChatContent.tsx +235 -112
- package/src/components/AliaChatHeader.tsx +118 -0
- package/src/components/AliaChatMessageList.tsx +171 -260
- package/src/components/AliaChatScreen.tsx +23 -24
- package/src/components/AliaChatSheet.tsx +70 -316
- package/src/components/AliaWelcomeMessage.tsx +47 -50
- package/src/components/AmbientField.tsx +577 -0
- package/src/components/IdentityMark.tsx +10 -2
- package/src/components/Markdown.tsx +116 -40
- package/src/components/PlanPreviewCard.tsx +71 -55
- package/src/components/ThinkingBlock.tsx +47 -0
- package/src/components/VoiceModeIcon.tsx +10 -0
- package/src/components/voice/VoiceControls.tsx +22 -73
- package/src/components/voice/VoiceSession.tsx +2 -1
- package/src/hooks/useAliaChat.ts +22 -15
- package/src/hooks/useAmbientWave.ts +35 -5
- package/src/hooks/useSoundEffects.ts +14 -11
- package/src/hooks/useSpeechToText.ts +9 -2
- package/src/hooks/useTTS.ts +163 -156
- package/src/hooks/useVoiceRoom.ts +228 -170
- package/src/index.ts +18 -6
- package/src/lib/catalogue.ts +17 -4
- package/src/lib/chat-stream.ts +25 -7
- package/src/lib/chat-transport.ts +2 -1
- package/src/lib/citations.ts +63 -0
- package/src/lib/content-utils.ts +3 -2
- package/src/lib/image-data-url.ts +15 -0
- package/src/lib/keyboard.tsx +1 -1
- package/src/lib/labels.tsx +81 -0
- package/src/lib/speech-recognition.native.ts +15 -4
- package/src/lib/speech-recognition.ts +27 -7
- package/src/lib/task-labels.ts +67 -0
- package/src/lib/tool-call-status.ts +28 -0
- package/src/lib/tool-registry.ts +14 -14
- package/src/lib/voice-text.ts +12 -6
- package/src/lib/voice-turn.ts +4 -1
- package/src/lib/work-log-copy.ts +61 -0
- package/src/lib/work-log.ts +378 -0
- package/src/tools.ts +11 -0
- package/src/types.ts +5 -2
- package/src/voice.ts +12 -4
- package/src/components/Reasoning.tsx +0 -152
- package/src/components/ResearchProgressCard.tsx +0 -159
- package/src/components/ThinkingIndicator.tsx +0 -98
- package/src/components/ui/button.tsx +0 -104
- package/src/components/ui/chat-text-input.tsx +0 -184
- package/src/components/ui/dropdown-menu.tsx +0 -2
- package/src/components/ui/dropdown-menu.web.tsx +0 -383
- package/src/components/ui/prompt-input/actions.tsx +0 -28
- package/src/components/ui/prompt-input/add-menu.tsx +0 -83
- package/src/components/ui/prompt-input/attachments.tsx +0 -181
- package/src/components/ui/prompt-input/autocomplete.tsx +0 -168
- package/src/components/ui/prompt-input/context.tsx +0 -71
- package/src/components/ui/prompt-input/mic-button.tsx +0 -53
- package/src/components/ui/prompt-input/prompt-input.tsx +0 -279
- package/src/components/ui/prompt-input/submit-button.tsx +0 -50
- package/src/components/ui/prompt-input/textarea.tsx +0 -80
- package/src/components/ui/text.tsx +0 -29
- package/src/components/voice/AudioWaveVisualizer.tsx +0 -444
- package/src/components/voice/VoiceOverlay.tsx +0 -75
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { AppState, Platform, View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
cancelAnimation,
|
|
5
|
+
Easing,
|
|
6
|
+
useAnimatedReaction,
|
|
7
|
+
useAnimatedStyle,
|
|
8
|
+
useReducedMotion,
|
|
9
|
+
useSharedValue,
|
|
10
|
+
withDelay,
|
|
11
|
+
withRepeat,
|
|
12
|
+
withSequence,
|
|
13
|
+
withTiming,
|
|
14
|
+
type SharedValue,
|
|
15
|
+
} from 'react-native-reanimated';
|
|
16
|
+
import Svg, { Circle, Defs, RadialGradient, Rect, Stop } from 'react-native-svg';
|
|
17
|
+
import type { AgentState } from '../types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cross-platform blur. React Native 0.86 types `filter` on `ViewStyle`, but the
|
|
21
|
+
* two platforms take different shapes: a CSS string on web, the filter-function
|
|
22
|
+
* array on native.
|
|
23
|
+
*/
|
|
24
|
+
function blurStyle(radius: number): ViewStyle {
|
|
25
|
+
return Platform.OS === 'web' ? { filter: `blur(${radius}px)` } : { filter: [{ blur: radius }] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The blobs enter over 2.4s. */
|
|
29
|
+
const ENTER_DURATION = 2400;
|
|
30
|
+
const ENTER_EASE = Easing.bezier(0.22, 0.7, 0.24, 1);
|
|
31
|
+
/** The enter keyframes reach full opacity at 60% of the run. */
|
|
32
|
+
const ENTER_FADE_DURATION = ENTER_DURATION * 0.6;
|
|
33
|
+
/** Pointer parallax settle. */
|
|
34
|
+
const PARALLAX_DURATION = 550;
|
|
35
|
+
const PARALLAX_EASE = Easing.bezier(0.2, 0.7, 0.2, 1);
|
|
36
|
+
/** Per-blob opacity — dark mode needs less to read the same. */
|
|
37
|
+
const BLOB_OPACITY_LIGHT = 0.55;
|
|
38
|
+
const BLOB_OPACITY_DARK = 0.4;
|
|
39
|
+
/** One blur over the whole field, not per blob. */
|
|
40
|
+
const FIELD_BLUR = 70;
|
|
41
|
+
/** How long the intensity target takes to ramp, matching the voice overlay. */
|
|
42
|
+
const INTENSITY_RAMP = 500;
|
|
43
|
+
/** Exit: the field sinks and dims while whatever is behind it rises. */
|
|
44
|
+
const EXIT_OPACITY_DURATION = 800;
|
|
45
|
+
const EXIT_TRANSFORM_DURATION = 900;
|
|
46
|
+
const EXIT_EASE = Easing.bezier(0.2, 0.7, 0.2, 1);
|
|
47
|
+
/**
|
|
48
|
+
* A fast beat layered on top of the slow float, scaled by live amplitude.
|
|
49
|
+
*
|
|
50
|
+
* Removed once, for looking like a second canned animation, and put back: it is
|
|
51
|
+
* part of how the field reads while DICTATING, which is the behaviour that was
|
|
52
|
+
* asked for. Being scaled by the level is what makes it legitimate — at silence
|
|
53
|
+
* the whole term is zero and the blob does not move at all.
|
|
54
|
+
*/
|
|
55
|
+
const PULSE_DURATION = 900;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A field shorter than a phone screen (the chat's sheet) is not drawn at its own scale: the
|
|
59
|
+
* blobs are sized in `vmax` of the field, which would be its small side, and laid out across
|
|
60
|
+
* its width, which would pile them on the centre. It takes the blob size of a phone screen,
|
|
61
|
+
* and its blobs are spread wider (`SHORT_FIELD_SPREAD`), cropped by its edges — the blue, warm
|
|
62
|
+
* and green of the intro side by side, not one smudge in the middle.
|
|
63
|
+
*/
|
|
64
|
+
const MIN_FIELD_REFERENCE = 800;
|
|
65
|
+
const SHORT_FIELD_SPREAD = 1.4;
|
|
66
|
+
/** The spread leaves the composition leaning left; this much of the width puts it back. */
|
|
67
|
+
const SHORT_FIELD_SHIFT = 0.17;
|
|
68
|
+
|
|
69
|
+
/** One keyframe stop of a float loop: [translateX in vw, translateY in vh, scale]. */
|
|
70
|
+
type FloatStop = readonly [number, number, number];
|
|
71
|
+
|
|
72
|
+
type FlourishConfig = {
|
|
73
|
+
/** Diameter, in vmax. */
|
|
74
|
+
w: number;
|
|
75
|
+
/** Where the blob enters from, in vw / vh. */
|
|
76
|
+
sx: number;
|
|
77
|
+
sy: number;
|
|
78
|
+
/** Where it comes to rest, in vw / vh. */
|
|
79
|
+
rx: number;
|
|
80
|
+
ry: number;
|
|
81
|
+
/** Entrance delay, in ms. */
|
|
82
|
+
delay: number;
|
|
83
|
+
/** Parallax reach at the pointer's extremes, in px. */
|
|
84
|
+
px: number;
|
|
85
|
+
py: number;
|
|
86
|
+
/** Float loop period, in ms. */
|
|
87
|
+
fdur: number;
|
|
88
|
+
/** How strongly this blob answers the live amplitude. */
|
|
89
|
+
amp: number;
|
|
90
|
+
/** The float keyframes at 33% and 66%; both ends of the loop are the origin. */
|
|
91
|
+
float: readonly [FloatStop, FloatStop];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The three blobs are centred and then pushed down by `ry` (48–57vh), which is
|
|
96
|
+
* what lays them along the bottom edge.
|
|
97
|
+
*/
|
|
98
|
+
const FLOURISHES: readonly FlourishConfig[] = [
|
|
99
|
+
{
|
|
100
|
+
w: 51,
|
|
101
|
+
sx: -52,
|
|
102
|
+
sy: 70,
|
|
103
|
+
rx: -22,
|
|
104
|
+
ry: 50,
|
|
105
|
+
delay: 850,
|
|
106
|
+
px: 120,
|
|
107
|
+
py: 72,
|
|
108
|
+
fdur: 8000,
|
|
109
|
+
amp: 1,
|
|
110
|
+
float: [
|
|
111
|
+
[4, 2, 1.08],
|
|
112
|
+
[2, -3, 0.96],
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
w: 47,
|
|
117
|
+
sx: 52,
|
|
118
|
+
sy: 72,
|
|
119
|
+
rx: 20,
|
|
120
|
+
ry: 48,
|
|
121
|
+
delay: 1030,
|
|
122
|
+
px: -144,
|
|
123
|
+
py: 88,
|
|
124
|
+
fdur: 11000,
|
|
125
|
+
amp: 1.2,
|
|
126
|
+
float: [
|
|
127
|
+
[-4, 3, 0.94],
|
|
128
|
+
[-2, -2, 1.06],
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
w: 49,
|
|
133
|
+
sx: 12,
|
|
134
|
+
sy: 80,
|
|
135
|
+
rx: -2,
|
|
136
|
+
ry: 57,
|
|
137
|
+
delay: 1190,
|
|
138
|
+
px: 104,
|
|
139
|
+
py: -76,
|
|
140
|
+
fdur: 9500,
|
|
141
|
+
amp: 0.9,
|
|
142
|
+
float: [
|
|
143
|
+
[-3, -3, 1.06],
|
|
144
|
+
[2, 2, 0.95],
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One hue per blob, per agent state. Idle and listening carry the field's own
|
|
151
|
+
* triad; thinking and speaking keep the amber and indigo the voice visualiser
|
|
152
|
+
* already established, so a call still reads as a call.
|
|
153
|
+
*
|
|
154
|
+
* These are literal hex values rather than theme tokens because the palette IS
|
|
155
|
+
* the effect — a single themed hue collapses into a flat wash.
|
|
156
|
+
*/
|
|
157
|
+
const PALETTES: Record<AgentState, readonly [string, string, string]> = {
|
|
158
|
+
idle: ['#8b5ae0', '#b8c24a', '#f26a21'],
|
|
159
|
+
listening: ['#8b5ae0', '#b8c24a', '#f26a21'],
|
|
160
|
+
thinking: ['#92400e', '#eab308', '#fbbf24'],
|
|
161
|
+
speaking: ['#1e2870', '#6366f1', '#9333ea'],
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** The warm ellipse low in the frame that keeps the bottom edge from going cold. */
|
|
165
|
+
const REST_COLOR = '#c7a377';
|
|
166
|
+
const REST_OPACITY_LIGHT = 0.122;
|
|
167
|
+
const REST_OPACITY_DARK = 0.078;
|
|
168
|
+
|
|
169
|
+
/** Smoothstep, so each float segment eases in and out the way CSS does. */
|
|
170
|
+
function smooth(t: number) {
|
|
171
|
+
'worklet';
|
|
172
|
+
return t * t * (3 - 2 * t);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One ambient blob, in three nested layers: an outer box that enters and comes
|
|
177
|
+
* to rest, a parallax layer that answers the pointer, and an inner circle that
|
|
178
|
+
* carries the endless float and the live amplitude. Keeping them nested means
|
|
179
|
+
* no transform has to know about the others.
|
|
180
|
+
*/
|
|
181
|
+
function Flourish({
|
|
182
|
+
config,
|
|
183
|
+
index,
|
|
184
|
+
color,
|
|
185
|
+
vw,
|
|
186
|
+
vh,
|
|
187
|
+
vmax,
|
|
188
|
+
fieldWidth,
|
|
189
|
+
fieldHeight,
|
|
190
|
+
blobOpacity,
|
|
191
|
+
blend,
|
|
192
|
+
entrance,
|
|
193
|
+
running,
|
|
194
|
+
waveAmplitude,
|
|
195
|
+
pointerX,
|
|
196
|
+
pointerY,
|
|
197
|
+
}: {
|
|
198
|
+
config: FlourishConfig;
|
|
199
|
+
index: number;
|
|
200
|
+
color: string;
|
|
201
|
+
vw: number;
|
|
202
|
+
vh: number;
|
|
203
|
+
vmax: number;
|
|
204
|
+
fieldWidth: number;
|
|
205
|
+
fieldHeight: number;
|
|
206
|
+
blobOpacity: number;
|
|
207
|
+
blend: 'multiply' | 'screen';
|
|
208
|
+
entrance: boolean;
|
|
209
|
+
/** Whether the endless loops tick. See `paused` on {@link AmbientFieldProps}. */
|
|
210
|
+
running: boolean;
|
|
211
|
+
waveAmplitude: SharedValue<number>;
|
|
212
|
+
pointerX: SharedValue<number>;
|
|
213
|
+
pointerY: SharedValue<number>;
|
|
214
|
+
}) {
|
|
215
|
+
// Without an entrance the blob is simply already at rest.
|
|
216
|
+
const enter = useSharedValue(entrance ? 0 : 1);
|
|
217
|
+
const fade = useSharedValue(entrance ? 0 : 1);
|
|
218
|
+
const phase = useSharedValue(0);
|
|
219
|
+
const pulse = useSharedValue(0);
|
|
220
|
+
|
|
221
|
+
// Starting an animation on mount is imperative by nature: there is no
|
|
222
|
+
// derived-state or event-handler form of "run once, then loop forever".
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!entrance) return;
|
|
225
|
+
enter.value = withDelay(
|
|
226
|
+
config.delay,
|
|
227
|
+
withTiming(1, { duration: ENTER_DURATION, easing: ENTER_EASE }),
|
|
228
|
+
);
|
|
229
|
+
fade.value = withDelay(
|
|
230
|
+
config.delay,
|
|
231
|
+
withTiming(1, { duration: ENTER_FADE_DURATION, easing: ENTER_EASE }),
|
|
232
|
+
);
|
|
233
|
+
}, [enter, fade, entrance, config.delay]);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The two endless loops: started once, stopped while the field is not on
|
|
237
|
+
* show, and resumed where they stopped — the float runs out the rest of its
|
|
238
|
+
* lap and the beat the rest of its swing before looping again, so a field
|
|
239
|
+
* coming back into view does not jump. The entrance is finite and is left
|
|
240
|
+
* to finish.
|
|
241
|
+
*/
|
|
242
|
+
const loopsStarted = useRef(false);
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (!running) {
|
|
245
|
+
cancelAnimation(phase);
|
|
246
|
+
cancelAnimation(pulse);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const lap = withRepeat(
|
|
250
|
+
withTiming(1, { duration: config.fdur, easing: Easing.linear }),
|
|
251
|
+
-1,
|
|
252
|
+
false,
|
|
253
|
+
);
|
|
254
|
+
const beat = (to: number) =>
|
|
255
|
+
withRepeat(
|
|
256
|
+
withTiming(to, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.sin) }),
|
|
257
|
+
-1,
|
|
258
|
+
true,
|
|
259
|
+
);
|
|
260
|
+
if (!loopsStarted.current) {
|
|
261
|
+
loopsStarted.current = true;
|
|
262
|
+
phase.value = withDelay(entrance ? config.delay + ENTER_DURATION : 0, lap);
|
|
263
|
+
pulse.value = beat(1);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const at = phase.value;
|
|
267
|
+
// 1 and 0 are the same pose (the origin), so the lap restarts seamlessly.
|
|
268
|
+
phase.value = withSequence(
|
|
269
|
+
withTiming(1, { duration: (1 - at) * config.fdur, easing: Easing.linear }),
|
|
270
|
+
withTiming(0, { duration: 0 }),
|
|
271
|
+
lap,
|
|
272
|
+
);
|
|
273
|
+
pulse.value = withSequence(
|
|
274
|
+
withTiming(1, {
|
|
275
|
+
duration: (1 - pulse.value) * PULSE_DURATION,
|
|
276
|
+
easing: Easing.inOut(Easing.sin),
|
|
277
|
+
}),
|
|
278
|
+
beat(0),
|
|
279
|
+
);
|
|
280
|
+
}, [running, phase, pulse, entrance, config.delay, config.fdur]);
|
|
281
|
+
|
|
282
|
+
const size = config.w * vmax;
|
|
283
|
+
|
|
284
|
+
const enterStyle = useAnimatedStyle(() => {
|
|
285
|
+
const t = enter.value;
|
|
286
|
+
const amp = waveAmplitude.value;
|
|
287
|
+
return {
|
|
288
|
+
// Live speech brightens the field on top of its resting opacity.
|
|
289
|
+
opacity: Math.min(1, fade.value * blobOpacity * (1 + amp * 0.5)),
|
|
290
|
+
transform: [
|
|
291
|
+
{ translateX: (config.sx + (config.rx - config.sx) * t) * vw },
|
|
292
|
+
{ translateY: (config.sy + (config.ry - config.sy) * t) * vh },
|
|
293
|
+
{ scale: 0.55 + 0.45 * t },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const parallaxStyle = useAnimatedStyle(() => ({
|
|
299
|
+
transform: [
|
|
300
|
+
{ translateX: pointerX.value * config.px },
|
|
301
|
+
{ translateY: pointerY.value * config.py },
|
|
302
|
+
],
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
const floatStyle = useAnimatedStyle(() => {
|
|
306
|
+
const [a, b] = config.float;
|
|
307
|
+
const p = phase.value;
|
|
308
|
+
// Four stops — origin, a, b, origin — each segment eased like ease-in-out.
|
|
309
|
+
let from: FloatStop = [0, 0, 1];
|
|
310
|
+
let to: FloatStop = a;
|
|
311
|
+
let local = p * 3;
|
|
312
|
+
if (p >= 2 / 3) {
|
|
313
|
+
from = b;
|
|
314
|
+
to = [0, 0, 1];
|
|
315
|
+
local = (p - 2 / 3) * 3;
|
|
316
|
+
} else if (p >= 1 / 3) {
|
|
317
|
+
from = a;
|
|
318
|
+
to = b;
|
|
319
|
+
local = (p - 1 / 3) * 3;
|
|
320
|
+
}
|
|
321
|
+
const e = smooth(local);
|
|
322
|
+
const amp = waveAmplitude.value;
|
|
323
|
+
// Amplitude swells the blob and adds a faster beat than the slow float.
|
|
324
|
+
const ampScale = 1 + amp * (0.3 * config.amp + 0.3 * (pulse.value - 0.5));
|
|
325
|
+
return {
|
|
326
|
+
transform: [
|
|
327
|
+
{ translateX: (from[0] + (to[0] - from[0]) * e) * vw },
|
|
328
|
+
{ translateY: (from[1] + (to[1] - from[1]) * e) * vh },
|
|
329
|
+
{ scale: (from[2] + (to[2] - from[2]) * e) * ampScale },
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const gradientId = `ambient-flourish-${index}`;
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<Animated.View
|
|
338
|
+
style={[
|
|
339
|
+
{
|
|
340
|
+
position: 'absolute',
|
|
341
|
+
left: fieldWidth / 2 - size / 2,
|
|
342
|
+
top: fieldHeight / 2 - size / 2,
|
|
343
|
+
width: size,
|
|
344
|
+
height: size,
|
|
345
|
+
},
|
|
346
|
+
enterStyle,
|
|
347
|
+
]}
|
|
348
|
+
>
|
|
349
|
+
<Animated.View style={[{ width: '100%', height: '100%' }, parallaxStyle]}>
|
|
350
|
+
<Animated.View
|
|
351
|
+
// Blending is what stops three overlapping blobs reading as grey mud.
|
|
352
|
+
style={[{ width: '100%', height: '100%', mixBlendMode: blend }, floatStyle]}
|
|
353
|
+
>
|
|
354
|
+
<Svg width={size} height={size}>
|
|
355
|
+
<Defs>
|
|
356
|
+
{/* An off-centre origin, fading to nothing well inside the circle. */}
|
|
357
|
+
<RadialGradient id={gradientId} cx="35%" cy="35%" r="64%">
|
|
358
|
+
<Stop offset="0%" stopColor={color} stopOpacity={1} />
|
|
359
|
+
<Stop offset="100%" stopColor={color} stopOpacity={0} />
|
|
360
|
+
</RadialGradient>
|
|
361
|
+
</Defs>
|
|
362
|
+
<Circle cx={size / 2} cy={size / 2} r={size / 2} fill={`url(#${gradientId})`} />
|
|
363
|
+
</Svg>
|
|
364
|
+
</Animated.View>
|
|
365
|
+
</Animated.View>
|
|
366
|
+
</Animated.View>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface AmbientFieldProps {
|
|
371
|
+
/**
|
|
372
|
+
* Combined amplitude from `useAmbientWave` — swells and brightens the blobs.
|
|
373
|
+
* Omit where there is no audio (the intro) and the field simply rests.
|
|
374
|
+
*/
|
|
375
|
+
waveAmplitude?: SharedValue<number>;
|
|
376
|
+
/** Drives the palette. Defaults to the resting triad. */
|
|
377
|
+
agentState?: AgentState;
|
|
378
|
+
/** Overall opacity target; ramps over 500ms when it changes. */
|
|
379
|
+
intensity?: number;
|
|
380
|
+
isDarkMode?: boolean;
|
|
381
|
+
/** Play the staggered 2.4s entrance. Off by default — only the intro wants it. */
|
|
382
|
+
entrance?: boolean;
|
|
383
|
+
/** Pointer parallax, normalised to -1..1. Omit for no parallax. */
|
|
384
|
+
pointerX?: SharedValue<number>;
|
|
385
|
+
/** @see pointerX */
|
|
386
|
+
pointerY?: SharedValue<number>;
|
|
387
|
+
/** Sink and dim the field while whatever sits behind it rises into view. */
|
|
388
|
+
exiting?: boolean;
|
|
389
|
+
/**
|
|
390
|
+
* Stop the endless loops: the screen holding the field is not the one on
|
|
391
|
+
* show (a route pushed over it keeps it mounted). The field also stops by
|
|
392
|
+
* itself while the app is in the background or the browser tab is hidden.
|
|
393
|
+
*/
|
|
394
|
+
paused?: boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The ambient background shared by the welcome intro and the chat: three
|
|
399
|
+
* blurred, drifting colour fields over a warm floor glow.
|
|
400
|
+
*
|
|
401
|
+
* It fills its parent, so the caller decides where it sits and what it sits
|
|
402
|
+
* behind. Every animation is driven through shared values and
|
|
403
|
+
* `useAnimatedReaction` rather than mapper-started styles, because
|
|
404
|
+
* mapper-started animations do not tick on reanimated-web.
|
|
405
|
+
*
|
|
406
|
+
* It is decoration: out of the accessibility tree on every platform, and it
|
|
407
|
+
* never takes a touch.
|
|
408
|
+
*
|
|
409
|
+
* **Reduced motion.** Every animation here uses Reanimated's default
|
|
410
|
+
* `ReduceMotion.System`, which with the setting on resolves a timing to its end
|
|
411
|
+
* at once, ends a `withRepeat` after one instant lap and does not start a
|
|
412
|
+
* reversing one at all — so the entrance lands at rest, the float settles at
|
|
413
|
+
* its origin and the beat never runs, with no frame work. What that does not
|
|
414
|
+
* cover is the pointer parallax, which is a direct response rather than an
|
|
415
|
+
* animation: it jumps instead of gliding, so it is switched off here. The
|
|
416
|
+
* response to live audio stays — it is how a call shows it is listening and
|
|
417
|
+
* speaking, not an ornament.
|
|
418
|
+
*
|
|
419
|
+
* **Reduced transparency** has nothing to act on: the field is the backmost
|
|
420
|
+
* layer, nothing is seen THROUGH it, and the translucent surfaces over it are
|
|
421
|
+
* Bloom's, which follow that setting themselves.
|
|
422
|
+
*/
|
|
423
|
+
export function AmbientField({
|
|
424
|
+
waveAmplitude,
|
|
425
|
+
agentState = 'idle',
|
|
426
|
+
intensity = 1,
|
|
427
|
+
isDarkMode = false,
|
|
428
|
+
entrance = false,
|
|
429
|
+
pointerX,
|
|
430
|
+
pointerY,
|
|
431
|
+
exiting = false,
|
|
432
|
+
paused = false,
|
|
433
|
+
}: AmbientFieldProps) {
|
|
434
|
+
const [field, setField] = useState({ width: 0, height: 0 });
|
|
435
|
+
const reduceMotion = useReducedMotion();
|
|
436
|
+
|
|
437
|
+
// On web, AppState follows the page's visibility.
|
|
438
|
+
const [inForeground, setInForeground] = useState(AppState.currentState !== 'background');
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
const subscription = AppState.addEventListener('change', (state) => {
|
|
441
|
+
setInForeground(state !== 'background');
|
|
442
|
+
});
|
|
443
|
+
return () => subscription.remove();
|
|
444
|
+
}, []);
|
|
445
|
+
const running = !paused && inForeground;
|
|
446
|
+
|
|
447
|
+
// Fallbacks so the hook count never depends on which optional shared values
|
|
448
|
+
// the caller passed.
|
|
449
|
+
const silence = useSharedValue(0);
|
|
450
|
+
const idlePointerX = useSharedValue(0);
|
|
451
|
+
const idlePointerY = useSharedValue(0);
|
|
452
|
+
const amplitude = waveAmplitude ?? silence;
|
|
453
|
+
const px = (reduceMotion ? undefined : pointerX) ?? idlePointerX;
|
|
454
|
+
const py = (reduceMotion ? undefined : pointerY) ?? idlePointerY;
|
|
455
|
+
|
|
456
|
+
const intensityRamp = useSharedValue(intensity);
|
|
457
|
+
useAnimatedReaction(
|
|
458
|
+
() => intensity,
|
|
459
|
+
(target, previous) => {
|
|
460
|
+
if (target === previous) return;
|
|
461
|
+
intensityRamp.value = withTiming(target, { duration: INTENSITY_RAMP });
|
|
462
|
+
},
|
|
463
|
+
[intensity],
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const exitOpacity = useSharedValue(0);
|
|
467
|
+
const exitTransform = useSharedValue(0);
|
|
468
|
+
useAnimatedReaction(
|
|
469
|
+
() => exiting,
|
|
470
|
+
(target, previous) => {
|
|
471
|
+
if (target === previous) return;
|
|
472
|
+
const to = target ? 1 : 0;
|
|
473
|
+
exitOpacity.value = withTiming(to, {
|
|
474
|
+
duration: EXIT_OPACITY_DURATION,
|
|
475
|
+
easing: EXIT_EASE,
|
|
476
|
+
});
|
|
477
|
+
exitTransform.value = withTiming(to, {
|
|
478
|
+
duration: EXIT_TRANSFORM_DURATION,
|
|
479
|
+
easing: EXIT_EASE,
|
|
480
|
+
});
|
|
481
|
+
},
|
|
482
|
+
[exiting],
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const handleLayout = (event: LayoutChangeEvent) => {
|
|
486
|
+
const { width, height } = event.nativeEvent.layout;
|
|
487
|
+
setField({ width, height });
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const short = field.height > 0 && field.height < MIN_FIELD_REFERENCE;
|
|
491
|
+
const vw = (field.width / 100) * (short ? SHORT_FIELD_SPREAD : 1);
|
|
492
|
+
const vh = field.height / 100;
|
|
493
|
+
const vmax = Math.max(field.width, field.height, short ? MIN_FIELD_REFERENCE : 0) / 100;
|
|
494
|
+
const shiftX = short ? field.width * SHORT_FIELD_SHIFT : 0;
|
|
495
|
+
|
|
496
|
+
const fieldStyle = useAnimatedStyle(() => ({
|
|
497
|
+
// On the way out the field keeps 45% of its opacity, sinking as it goes.
|
|
498
|
+
opacity: intensityRamp.value * (1 - exitOpacity.value * 0.55),
|
|
499
|
+
transform: [
|
|
500
|
+
{ translateY: exitTransform.value * 12 * vh },
|
|
501
|
+
{ scale: 1 + exitTransform.value * 0.1 },
|
|
502
|
+
],
|
|
503
|
+
}));
|
|
504
|
+
|
|
505
|
+
const restStyle = useAnimatedStyle(() => ({
|
|
506
|
+
opacity: intensityRamp.value * (1 - exitOpacity.value * 0.5),
|
|
507
|
+
}));
|
|
508
|
+
|
|
509
|
+
const palette = PALETTES[agentState];
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
<View
|
|
513
|
+
className="absolute inset-0"
|
|
514
|
+
pointerEvents="none"
|
|
515
|
+
// One prop for every platform: react-native-web writes `aria-hidden`, and
|
|
516
|
+
// React Native turns it into `accessibilityElementsHidden` (iOS) and
|
|
517
|
+
// `importantForAccessibility="no-hide-descendants"` (Android).
|
|
518
|
+
aria-hidden
|
|
519
|
+
onLayout={handleLayout}
|
|
520
|
+
>
|
|
521
|
+
{field.width > 0 ? (
|
|
522
|
+
<>
|
|
523
|
+
<Animated.View style={[{ position: 'absolute', inset: 0 }, restStyle]}>
|
|
524
|
+
<Svg width={field.width} height={field.height}>
|
|
525
|
+
<Defs>
|
|
526
|
+
<RadialGradient id="ambient-rest" cx="50%" cy="92%" rx="57%" ry="37%">
|
|
527
|
+
<Stop
|
|
528
|
+
offset="0%"
|
|
529
|
+
stopColor={REST_COLOR}
|
|
530
|
+
stopOpacity={isDarkMode ? REST_OPACITY_DARK : REST_OPACITY_LIGHT}
|
|
531
|
+
/>
|
|
532
|
+
<Stop offset="70%" stopColor={REST_COLOR} stopOpacity={0} />
|
|
533
|
+
</RadialGradient>
|
|
534
|
+
</Defs>
|
|
535
|
+
<Rect
|
|
536
|
+
x={0}
|
|
537
|
+
y={0}
|
|
538
|
+
width={field.width}
|
|
539
|
+
height={field.height}
|
|
540
|
+
fill="url(#ambient-rest)"
|
|
541
|
+
/>
|
|
542
|
+
</Svg>
|
|
543
|
+
</Animated.View>
|
|
544
|
+
|
|
545
|
+
<Animated.View
|
|
546
|
+
style={[{ position: 'absolute', inset: 0 }, blurStyle(FIELD_BLUR), fieldStyle]}
|
|
547
|
+
>
|
|
548
|
+
<View style={{ position: 'absolute', inset: 0, transform: [{ translateX: shiftX }] }}>
|
|
549
|
+
{FLOURISHES.map((config, index) => (
|
|
550
|
+
<Flourish
|
|
551
|
+
key={config.w}
|
|
552
|
+
config={config}
|
|
553
|
+
index={index}
|
|
554
|
+
color={palette[index]}
|
|
555
|
+
vw={vw}
|
|
556
|
+
vh={vh}
|
|
557
|
+
vmax={vmax}
|
|
558
|
+
fieldWidth={field.width}
|
|
559
|
+
fieldHeight={field.height}
|
|
560
|
+
blobOpacity={isDarkMode ? BLOB_OPACITY_DARK : BLOB_OPACITY_LIGHT}
|
|
561
|
+
blend={isDarkMode ? 'screen' : 'multiply'}
|
|
562
|
+
entrance={entrance}
|
|
563
|
+
running={running}
|
|
564
|
+
waveAmplitude={amplitude}
|
|
565
|
+
pointerX={px}
|
|
566
|
+
pointerY={py}
|
|
567
|
+
/>
|
|
568
|
+
))}
|
|
569
|
+
</View>
|
|
570
|
+
</Animated.View>
|
|
571
|
+
</>
|
|
572
|
+
) : null}
|
|
573
|
+
</View>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export { PARALLAX_DURATION, PARALLAX_EASE };
|
|
@@ -149,7 +149,10 @@ export function IdentityMark({
|
|
|
149
149
|
(current, previous) => {
|
|
150
150
|
if (current === previous || current === 0) return;
|
|
151
151
|
rotation.value = 0;
|
|
152
|
-
rotation.value = withTiming(360, {
|
|
152
|
+
rotation.value = withTiming(360, {
|
|
153
|
+
duration: 500,
|
|
154
|
+
easing: Easing.bezier(0.34, 1.3, 0.64, 1),
|
|
155
|
+
});
|
|
153
156
|
},
|
|
154
157
|
[spinCount],
|
|
155
158
|
);
|
|
@@ -168,7 +171,12 @@ export function IdentityMark({
|
|
|
168
171
|
onPress?.();
|
|
169
172
|
};
|
|
170
173
|
|
|
171
|
-
const box = {
|
|
174
|
+
const box = {
|
|
175
|
+
width: size,
|
|
176
|
+
height: size,
|
|
177
|
+
alignItems: 'center',
|
|
178
|
+
justifyContent: 'center',
|
|
179
|
+
} as const;
|
|
172
180
|
const glyph = (
|
|
173
181
|
<Animated.View style={animatedStyle}>
|
|
174
182
|
<Svg width={size} height={size} viewBox={VIEWBOX}>
|