@graineai/inapp-react-native 0.35.0 → 0.37.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.
@@ -1,11 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { Image, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
3
+ import { Animated, Image, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
4
4
  import { useKeyboardLift } from "./keyboard.js";
5
5
  import { useDraggablePlacement } from "./placement.js";
6
6
  import { useGraineAgent } from "./index.js";
7
7
  import { GraineLoader } from "./loader.js";
8
8
  import { resolveNativeAppearance } from "./appearance.js";
9
+ import { useDesign } from "./use-design.js";
10
+ import { letterSpacing, lineHeight, onAccentFor, shadowStyle, softFor, surfaceFill } from "./design-math.js";
11
+ import { normaliseHex, readableOn } from "../theme.js";
9
12
  const DARK = {
10
13
  surface: "#17161A",
11
14
  border: "rgba(255,255,255,0.09)",
@@ -31,14 +34,17 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
31
34
  accent: "#318CE7",
32
35
  heading: "Assistant",
33
36
  });
34
- const accent = accentProp ?? dashboard.accent;
37
+ const look = useDesign(scheme);
38
+ const accent = accentProp ?? (look.classic ? dashboard.accent : look.tokens.colors.accent);
35
39
  const name = nameProp ?? dashboard.heading;
36
40
  const avatarUrl = avatarProp ?? dashboard.logoUrl ?? undefined;
37
41
  const deviceScheme = useColorScheme();
38
42
  const styles = useMemo(() => {
43
+ if (!look.classic)
44
+ return makeDesignStyles(look, accent, themeProp);
39
45
  const base = (scheme === "auto" ? deviceScheme === "light" : scheme === "light") ? LIGHT : DARK;
40
46
  return makeStyles(themeProp ? { ...base, ...themeProp } : base);
41
- }, [scheme, deviceScheme, themeProp]);
47
+ }, [scheme, deviceScheme, themeProp, look, accent]);
42
48
  const [expanded, setExpanded] = useState(surface === "chat");
43
49
  const keyboardLift = useKeyboardLift();
44
50
  const baseBottom = launcherInset.bottom ?? bottomInset;
@@ -51,6 +57,21 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
51
57
  const [captions, setCaptions] = useState(captionsOn);
52
58
  const [draft, setDraft] = useState("");
53
59
  const scrollRef = useRef(null);
60
+ const panelIn = useRef(new Animated.Value(1)).current;
61
+ const lookRef = useRef(look);
62
+ lookRef.current = look;
63
+ const still = look.classic || look.durationMs <= 0;
64
+ useEffect(() => {
65
+ if (!expanded || still) {
66
+ panelIn.setValue(1);
67
+ return;
68
+ }
69
+ panelIn.setValue(0);
70
+ const { durationMs, easing } = lookRef.current;
71
+ const a = Animated.timing(panelIn, { toValue: 1, duration: durationMs, easing, useNativeDriver: true });
72
+ a.start();
73
+ return () => a.stop();
74
+ }, [expanded, still, panelIn]);
54
75
  const last = messages[messages.length - 1];
55
76
  const waiting = connecting;
56
77
  const waitingMoment = "connecting";
@@ -89,13 +110,16 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
89
110
  right: launcherInset.right ?? 12,
90
111
  left: launcherInset.left ?? 12,
91
112
  },
92
- ], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: [styles.transcript, keyboardLift ? styles.transcriptLifted : null], contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: isChat
113
+ ], children: [expanded && (_jsxs(PanelFrame, { animated: !look.classic, style: look.classic ? styles.panel : [styles.panel, {
114
+ opacity: panelIn,
115
+ transform: [{ translateY: panelIn.interpolate({ inputRange: [0, 1], outputRange: [8, 0] }) }],
116
+ }], children: [look.classic ? null : (_jsx(View, { style: styles.header, children: _jsx(Text, { numberOfLines: 1, style: styles.headerTitle, children: name }) })), _jsxs(ScrollView, { ref: scrollRef, style: [styles.transcript, keyboardLift ? styles.transcriptLifted : null], contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: isChat
93
117
  ? "I can see what you\u2019re working on \u2014 type a question, or I\u2019ll step in if you get stuck."
94
118
  : "I can see what you\u2019re working on \u2014 ask me anything, or I\u2019ll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
95
119
  styles.bubble,
96
- m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
120
+ m.role === "agent" ? styles.agentBubble : [styles.customerBubble, look.classic ? { backgroundColor: accent } : null],
97
121
  ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(View, { children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && !isChat && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => { if (!drag.dragging)
98
- setExpanded((v) => !v); }, style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), waiting ? (_jsx(GraineLoader, { moment: waitingMoment, size: 15 })) : (_jsx(Text, { style: styles.status, children: status }))] }), canSwitch ? (_jsx(Pressable, { onPress: () => setSurface(isChat ? "voice" : "chat"), hitSlop: 8, accessibilityRole: "button", accessibilityLabel: isChat ? "Switch to a call" : "Switch to chat", style: styles.round, children: _jsx(Text, { style: styles.glyph, children: isChat ? "📞" : "💬" }) })) : null, waiting ? null : (_jsxs(_Fragment, { children: [isChat ? null : (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && styles.glyphOnAccent], children: muted ? "🔇" : "🎙" }) })] })), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] }))] })] }));
122
+ setExpanded((v) => !v); }, style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), waiting ? (_jsx(GraineLoader, { moment: waitingMoment, size: 15 })) : (_jsx(Text, { style: styles.status, children: status }))] }), canSwitch ? (_jsx(Pressable, { onPress: () => setSurface(isChat ? "voice" : "chat"), hitSlop: 8, accessibilityRole: "button", accessibilityLabel: isChat ? "Switch to a call" : "Switch to chat", style: styles.round, children: _jsx(Text, { style: styles.glyph, children: isChat ? "📞" : "💬" }) })) : null, waiting ? null : (_jsxs(_Fragment, { children: [isChat ? null : (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, !look.classic && styles.mic, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, !look.classic && styles.glyphOnAccent, muted && (look.classic ? styles.glyphOnAccent : styles.glyphOnDanger)], children: muted ? "🔇" : "🎙" }) })] })), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] }))] })] }));
99
123
  }
100
124
  export const GraineLauncher = GraineAgentBar;
101
125
  const makeStyles = (t) => StyleSheet.create({
@@ -148,3 +172,112 @@ const makeStyles = (t) => StyleSheet.create({
148
172
  },
149
173
  chevron: { color: t.text, fontSize: 16, lineHeight: 18 },
150
174
  });
175
+ function PanelFrame({ animated, style, children }) {
176
+ return animated ? _jsx(Animated.View, { style: style, children: children }) : _jsx(View, { style: style, children: children });
177
+ }
178
+ function makeDesignStyles(look, accent, over) {
179
+ const t = look.tokens;
180
+ const c = t.colors;
181
+ const f = t.font;
182
+ const r = t.shape.radius;
183
+ const bw = t.shape.borderWidth;
184
+ const pad = t.spacing.cardPadding;
185
+ const surface = over?.surface ?? surfaceFill(c.surface, t);
186
+ const border = over?.border ?? c.border;
187
+ const text = over?.text ?? c.text;
188
+ const sub = over?.sub ?? c.textMuted;
189
+ const chip = over?.chip ?? softFor(accent, t);
190
+ const onAccent = over?.onAccent ?? onAccentFor(accent, t);
191
+ const danger = over?.danger ?? c.danger;
192
+ const bodyFont = look.font(f.body);
193
+ const headingFont = look.font(f.heading);
194
+ const body = {
195
+ fontSize: f.scale.body,
196
+ lineHeight: lineHeight(f.scale.body, f.lineHeight.body),
197
+ fontWeight: String(f.weights.body),
198
+ letterSpacing: letterSpacing(f.scale.body, f.letterSpacing.body),
199
+ fontFamily: bodyFont,
200
+ };
201
+ const mode = t.bubbles === "outline" || t.bubbles === "minimal" ? t.bubbles : "filled";
202
+ const followsAccent = normaliseHex(c.userBubble) === normaliseHex(c.accent);
203
+ const ownAccent = normaliseHex(accent) === normaliseHex(c.accent);
204
+ const userBubble = followsAccent ? accent : c.userBubble;
205
+ const userText = followsAccent
206
+ ? (over?.onAccent ?? (ownAccent ? c.userBubbleText : readableOn(accent, [c.userBubbleText, "#FFFFFF", "#101318"])))
207
+ : c.userBubbleText;
208
+ const agentBubble = mode === "filled" ? { backgroundColor: over?.chip ?? c.agentBubble }
209
+ : mode === "outline" ? { backgroundColor: "transparent", borderWidth: bw, borderColor: border }
210
+ : { backgroundColor: "transparent", paddingHorizontal: 0, paddingVertical: 2, borderRadius: 0 };
211
+ const customerBubble = mode === "filled" ? { backgroundColor: userBubble }
212
+ : mode === "outline" ? { backgroundColor: "transparent", borderWidth: bw, borderColor: accent }
213
+ : { backgroundColor: softFor(accent, t) };
214
+ return StyleSheet.create({
215
+ root: { position: "absolute", left: 12, right: 12, zIndex: 900 },
216
+ panel: {
217
+ backgroundColor: c.background, borderRadius: r.xl, borderWidth: bw, borderColor: border,
218
+ marginBottom: 8, overflow: "hidden",
219
+ },
220
+ header: {
221
+ backgroundColor: surface, paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.75),
222
+ borderBottomWidth: bw, borderBottomColor: border,
223
+ },
224
+ headerTitle: {
225
+ color: text, fontFamily: headingFont, fontSize: f.scale.title, fontWeight: String(f.weights.title),
226
+ letterSpacing: letterSpacing(f.scale.title, f.letterSpacing.title), lineHeight: lineHeight(f.scale.title, f.lineHeight.title),
227
+ },
228
+ transcript: { maxHeight: 240 },
229
+ transcriptLifted: { maxHeight: 150 },
230
+ transcriptInner: { padding: pad, gap: Math.max(4, Math.round(t.spacing.gap * 0.8)) },
231
+ empty: { color: sub, ...body },
232
+ bubble: {
233
+ maxWidth: "88%", paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.75), borderRadius: r.lg,
234
+ },
235
+ agentBubble: { alignSelf: "flex-start", borderBottomLeftRadius: r.sm, ...agentBubble },
236
+ customerBubble: { alignSelf: "flex-end", borderBottomRightRadius: r.sm, ...customerBubble },
237
+ agentText: { color: mode === "filled" ? (over?.text ?? c.agentBubbleText) : text, ...body },
238
+ customerText: { color: mode === "filled" ? userText : text, ...body },
239
+ composer: {
240
+ flexDirection: "row", alignItems: "center", gap: 8,
241
+ paddingHorizontal: Math.round((pad * 6) / 7), paddingBottom: Math.round((pad * 6) / 7),
242
+ },
243
+ input: {
244
+ flex: 1, backgroundColor: c.surfaceRaised, borderWidth: bw, borderColor: border, borderRadius: r.xl,
245
+ paddingHorizontal: 14, paddingVertical: Platform.OS === "ios" ? 10 : 7, color: text,
246
+ fontSize: f.scale.body, fontFamily: bodyFont,
247
+ },
248
+ send: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
249
+ sendLabel: { color: onAccent, fontSize: 17, lineHeight: 20 },
250
+ bar: {
251
+ flexDirection: "row", alignItems: "center", gap: 11,
252
+ backgroundColor: surface, borderRadius: r.xl, borderWidth: bw, borderColor: border,
253
+ paddingHorizontal: 8, paddingVertical: 8,
254
+ ...shadowStyle(t.elevation.floating),
255
+ },
256
+ avatar: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
257
+ avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: chip },
258
+ captionWrap: {
259
+ backgroundColor: surface, borderRadius: r.lg, borderWidth: bw, borderColor: border,
260
+ paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.8), marginBottom: 8,
261
+ ...shadowStyle(t.elevation.card),
262
+ },
263
+ caption: { color: text, ...body, fontWeight: String(f.weights.strong) },
264
+ captionLive: { opacity: 0.8 },
265
+ glyph: { color: text, fontSize: f.scale.caption + 1, fontWeight: String(f.weights.strong) },
266
+ glyphOnAccent: { color: onAccent },
267
+ glyphOnDanger: { color: "#FFFFFF" },
268
+ placeholder: { color: sub },
269
+ mic: { backgroundColor: accent },
270
+ muted: { backgroundColor: danger },
271
+ avatarGlyph: { color: onAccent, fontSize: 17, lineHeight: 20 },
272
+ name: {
273
+ color: text, fontSize: f.scale.body, fontWeight: String(f.weights.title), fontFamily: headingFont,
274
+ letterSpacing: letterSpacing(f.scale.body, f.letterSpacing.title),
275
+ },
276
+ status: { color: sub, fontSize: f.scale.caption, marginTop: 1, fontFamily: bodyFont },
277
+ round: {
278
+ width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center",
279
+ backgroundColor: chip,
280
+ },
281
+ chevron: { color: text, fontSize: 16, lineHeight: 18 },
282
+ });
283
+ }
@@ -0,0 +1,14 @@
1
+ import { type MotionEasing, type ResolvedDesign, type ThemeTokens } from "../theme.js";
2
+ export declare function useReduceMotion(): boolean;
3
+ export declare function easingOf(easing: MotionEasing | string, bounce: number): (t: number) => number;
4
+ export interface DesignLook {
5
+ design: ResolvedDesign;
6
+ tokens: ThemeTokens;
7
+ classic: boolean;
8
+ font: (family: string) => string | undefined;
9
+ durationMs: number;
10
+ reduceMotion: boolean;
11
+ easing: (t: number) => number;
12
+ }
13
+ export declare function useDesign(scheme?: "light" | "dark" | "auto"): DesignLook;
14
+ export declare function useGraineTheme(): ThemeTokens;
@@ -0,0 +1,72 @@
1
+ import { useContext, useEffect, useMemo, useState } from "react";
2
+ import { AccessibilityInfo, Easing, useColorScheme } from "react-native";
3
+ import { Ctx } from "./index.js";
4
+ import { sessionDesign } from "../design.js";
5
+ import { pickTokens } from "../theme.js";
6
+ import { fontFamilyFor, isClassicLook } from "./design-math.js";
7
+ const DEFAULT_DESIGN = sessionDesign(null);
8
+ const classicSeen = new WeakMap();
9
+ function classic(design) {
10
+ let v = classicSeen.get(design);
11
+ if (v === undefined) {
12
+ v = isClassicLook(design);
13
+ classicSeen.set(design, v);
14
+ }
15
+ return v;
16
+ }
17
+ export function useReduceMotion() {
18
+ const [reduce, setReduce] = useState(false);
19
+ useEffect(() => {
20
+ let live = true;
21
+ const info = AccessibilityInfo;
22
+ try {
23
+ info?.isReduceMotionEnabled?.()?.then?.((v) => { if (live)
24
+ setReduce(!!v); }, () => { });
25
+ }
26
+ catch { }
27
+ let sub = null;
28
+ try {
29
+ sub = info?.addEventListener?.("reduceMotionChanged", (v) => setReduce(!!v));
30
+ }
31
+ catch {
32
+ sub = null;
33
+ }
34
+ return () => { live = false; try {
35
+ sub?.remove?.();
36
+ }
37
+ catch { } };
38
+ }, []);
39
+ return reduce;
40
+ }
41
+ export function easingOf(easing, bounce) {
42
+ if (easing === "linear" || easing === "none")
43
+ return Easing.linear;
44
+ if (easing === "emphasized")
45
+ return Easing.bezier(0.05, 0.7, 0.1, 1);
46
+ if (easing === "spring")
47
+ return Easing.out(Easing.back(Math.max(0.5, bounce * 4)));
48
+ return Easing.bezier(0.2, 0, 0, 1);
49
+ }
50
+ export function useDesign(scheme) {
51
+ const ctx = useContext(Ctx);
52
+ const device = useColorScheme();
53
+ const reduce = useReduceMotion();
54
+ const design = ctx?.design ?? DEFAULT_DESIGN;
55
+ const fonts = ctx?.fonts ?? null;
56
+ return useMemo(() => {
57
+ const tokens = scheme === "light" ? design.light : scheme === "dark" ? design.dark : pickTokens(design, device);
58
+ const durationMs = reduce || tokens.motion.easing === "none" ? 0 : Math.max(0, tokens.motion.durationMs);
59
+ return {
60
+ design,
61
+ tokens,
62
+ classic: classic(design),
63
+ font: (family) => fontFamilyFor(family, fonts),
64
+ durationMs,
65
+ reduceMotion: reduce,
66
+ easing: easingOf(tokens.motion.easing, tokens.motion.bounce),
67
+ };
68
+ }, [design, fonts, device, reduce, scheme]);
69
+ }
70
+ export function useGraineTheme() {
71
+ return useDesign().tokens;
72
+ }
@@ -21,6 +21,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
21
21
  const [connecting, setConnecting] = useState(false);
22
22
  const [muted, setMutedState] = useState(false);
23
23
  const [ready, setReady] = useState(false);
24
+ const readyRef = useRef(false);
25
+ readyRef.current = ready;
24
26
  const [captions, setCaptions] = useState([]);
25
27
  const [awaitingAgent, setAwaitingAgent] = useState(false);
26
28
  const [captionsOn, setCaptionsOn] = useState(true);
@@ -132,7 +134,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
132
134
  scheduleRetry("load-cap");
133
135
  return;
134
136
  }
135
- readyTimerRef.current = setTimeout(tick, Math.max(250, Math.min(limit - since, HARD_CAP_MS - total)));
137
+ const floor = Math.min(250, limit / 4);
138
+ readyTimerRef.current = setTimeout(tick, Math.max(floor, Math.min(limit - since, HARD_CAP_MS - total)));
136
139
  };
137
140
  readyTimerRef.current = setTimeout(tick, Math.min(STALL_TIMEOUT_MS, 1000));
138
141
  }, [scheduleRetry, STALL_TIMEOUT_MS, BOOT_TIMEOUT_MS, HARD_CAP_MS]);
@@ -462,6 +465,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
462
465
  pending = null;
463
466
  }
464
467
  pushContext();
468
+ if (readyRef.current && !startedRef.current && !connectedRef.current)
469
+ post({ type: "graine:warm" });
465
470
  return;
466
471
  }
467
472
  if (next === "background" && wasActive && !pending) {