@graineai/inapp-react-native 0.36.0 → 0.38.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.
@@ -12,6 +12,10 @@ export { clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX } from "./placement-
12
12
  export { widgetSurface } from "../protocol.js";
13
13
  export { resolveSurface } from "../protocol.js";
14
14
  import { resolveSurface } from "../protocol.js";
15
+ import { sessionDesign } from "../design.js";
16
+ import { fontFamilyFor, sameValue } from "./design-math.js";
17
+ import { facesForDesign, faceKey } from "./fonts.js";
18
+ export { useGraineTheme } from "./use-design.js";
15
19
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
16
20
  export const PENDING_WIDGET_MS = 12000;
17
21
  export const AWAITING_REPLY_MS = 12000;
@@ -29,7 +33,7 @@ export function useGraineClient() {
29
33
  }
30
34
  return client;
31
35
  }
32
- export function GraineProvider({ children, autoConnect = true, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
36
+ export function GraineProvider({ children, autoConnect = true, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, fonts: fontsProp, loadFont, svg: svgProp, client: providedClient, ...options }) {
33
37
  const clientRef = useRef(null);
34
38
  if (!clientRef.current) {
35
39
  clientRef.current =
@@ -61,6 +65,41 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
61
65
  return () => { listenersRef.current.delete(fn); };
62
66
  }, []);
63
67
  const [appearance, setAppearance] = useState(null);
68
+ const [design, setDesign] = useState(() => sessionDesign(client.config));
69
+ const adoptDesign = useCallback((cfg) => {
70
+ if (!cfg)
71
+ return;
72
+ const next = sessionDesign(cfg);
73
+ setDesign((cur) => (sameValue(cur, next) ? cur : next));
74
+ }, []);
75
+ const fontsKey = fontsProp ? JSON.stringify(fontsProp) : "";
76
+ const fonts = useMemo(() => (fontsKey ? JSON.parse(fontsKey) : null), [fontsKey]);
77
+ const [faces, setFaces] = useState({});
78
+ const svgKit = useMemo(() => (svgProp?.Svg && svgProp?.Path ? { Svg: svgProp.Svg, Path: svgProp.Path } : null), [svgProp?.Svg, svgProp?.Path]);
79
+ const askedRef = useRef(new Set());
80
+ const loadFontRef = useRef(loadFont);
81
+ loadFontRef.current = loadFont;
82
+ const hasLoader = typeof loadFont === "function";
83
+ useEffect(() => {
84
+ const load = loadFontRef.current;
85
+ if (typeof load !== "function")
86
+ return;
87
+ let live = true;
88
+ for (const face of facesForDesign(design, client.opts.baseUrl)) {
89
+ const key = faceKey(face.family, face.weight);
90
+ if (fontFamilyFor(face.family, fonts))
91
+ continue;
92
+ if (askedRef.current.has(key))
93
+ continue;
94
+ askedRef.current.add(key);
95
+ Promise.resolve()
96
+ .then(() => load({ [face.name]: face.url }))
97
+ .then(() => { if (live)
98
+ setFaces((cur) => (cur[key] === face.name ? cur : { ...cur, [key]: face.name })); })
99
+ .catch(() => { });
100
+ }
101
+ return () => { live = false; askedRef.current.clear(); };
102
+ }, [client, design, fonts, hasLoader]);
64
103
  const [sessionReady, setSessionReady] = useState(false);
65
104
  const [connectionQuality, setConnectionQuality] = useState("unknown");
66
105
  const [network, setNetwork] = useState("unknown");
@@ -170,6 +209,7 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
170
209
  setError(null);
171
210
  if (client.config?.appearance)
172
211
  setAppearance(client.config.appearance);
212
+ adoptDesign(client.config);
173
213
  emitEvent({ type: "conversation_started" });
174
214
  }),
175
215
  client.on("close", (info) => {
@@ -231,6 +271,7 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
231
271
  const cfg = client.config ?? (await client.session());
232
272
  if (cfg?.appearance)
233
273
  setAppearance(cfg.appearance);
274
+ adoptDesign(cfg);
234
275
  setSessionReady(true);
235
276
  }
236
277
  catch {
@@ -334,6 +375,10 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
334
375
  launcherInset,
335
376
  currentScreen,
336
377
  appearance,
378
+ design,
379
+ fonts,
380
+ faces,
381
+ svg: svgKit,
337
382
  sessionReady,
338
383
  onEvent,
339
384
  track: (name, data) => client.track(name, data),
@@ -346,7 +391,7 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
346
391
  network,
347
392
  setVariables: (vars) => client.setVariables(vars),
348
393
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
349
- launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady,
394
+ launcherVisible, launcherInset, currentScreen, onEvent, appearance, design, fonts, faces, svgKit, sessionReady,
350
395
  surface, surfacePolicy, setSurface, connectionQuality, network]);
351
396
  const wantsCapture = captureTaps ?? !!fab;
352
397
  const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
@@ -3,20 +3,26 @@ import { useContext, useEffect, useRef } from "react";
3
3
  import { Animated, Easing, Text, View, useColorScheme } from "react-native";
4
4
  import { Ctx } from "./index.js";
5
5
  import { resolveNativeAppearance, resolveWidgetPalette } from "./appearance.js";
6
+ import { useDesign } from "./use-design.js";
6
7
  import { resolveLoader } from "../protocol.js";
7
8
  export function useGraineLoader() {
8
9
  const appearance = useContext(Ctx)?.appearance ?? null;
10
+ const design = useDesign();
9
11
  const look = resolveLoader(appearance?.loader);
10
12
  const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
11
- return { ...look, accent: look.color ?? dash.accent };
13
+ return { ...look, accent: look.color ?? (design.classic ? dash.accent : design.tokens.colors.accent) };
12
14
  }
13
- function useLoop(periodMs) {
15
+ function useLoop(periodMs, running = true) {
14
16
  const v = useRef(new Animated.Value(0)).current;
15
17
  useEffect(() => {
18
+ if (!running) {
19
+ v.setValue(0);
20
+ return;
21
+ }
16
22
  const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: periodMs, easing: Easing.linear, useNativeDriver: true }));
17
23
  loop.start();
18
24
  return () => loop.stop();
19
- }, [v, periodMs]);
25
+ }, [v, periodMs, running]);
20
26
  return v;
21
27
  }
22
28
  function Dots({ px, color, t }) {
@@ -56,18 +62,28 @@ function Mark({ px, color, t, logoUrl }) {
56
62
  export function GraineLoader(props) {
57
63
  const appearance = useContext(Ctx)?.appearance ?? null;
58
64
  const scheme = useColorScheme();
65
+ const design = useDesign();
59
66
  const look = resolveLoader(appearance?.loader);
60
67
  const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
61
- const color = props.color ?? look.color ?? dash.accent;
68
+ const color = props.color ?? look.color ?? (design.classic ? dash.accent : design.tokens.colors.accent);
62
69
  const px = props.size ?? look.px;
63
70
  const kind = props.kind ?? look.kind;
64
- const palette = resolveWidgetPalette(appearance, { scheme, accent: color });
65
- const t = useLoop(look.periodMs);
71
+ const palette = design.classic
72
+ ? resolveWidgetPalette(appearance, { scheme, accent: color })
73
+ : { border: design.tokens.colors.border, muted: design.tokens.colors.textMuted };
74
+ const t = useLoop(look.periodMs, design.classic || !design.reduceMotion);
66
75
  const label = props.label ?? (props.moment ? look.text[props.moment] : "");
76
+ const labelType = design.classic
77
+ ? { fontSize: Math.max(12, Math.round(px * 0.65)), fontWeight: "600" }
78
+ : {
79
+ fontSize: Math.max(design.tokens.font.scale.caption, Math.round(px * 0.65)),
80
+ fontWeight: String(design.tokens.font.weights.strong),
81
+ fontFamily: design.font(design.tokens.font.body),
82
+ };
67
83
  const mark = kind === "pulse" ? _jsx(Pulse, { px: px, color: color, t: t }) :
68
84
  kind === "ring" ? _jsx(Ring, { px: px, color: color, t: t, track: palette.border }) :
69
85
  kind === "bars" ? _jsx(Bars, { px: px, color: color, t: t }) :
70
86
  kind === "mark" ? _jsx(Mark, { px: px, color: color, t: t, logoUrl: dash.logoUrl ?? "" }) :
71
87
  _jsx(Dots, { px: px, color: color, t: t });
72
- return (_jsxs(View, { accessibilityRole: "progressbar", accessibilityLabel: label || "Loading", style: { flexDirection: props.stacked ? "column" : "row", alignItems: "center", gap: props.stacked ? 8 : 10 }, children: [mark, label ? (_jsx(Text, { style: { color: palette.muted, fontSize: Math.max(12, Math.round(px * 0.65)), fontWeight: "600" }, children: label })) : null] }));
88
+ return (_jsxs(View, { accessibilityRole: "progressbar", accessibilityLabel: label || "Loading", style: { flexDirection: props.stacked ? "column" : "row", alignItems: "center", gap: props.stacked ? 8 : 10 }, children: [mark, label ? (_jsx(Text, { style: { color: palette.muted, ...labelType }, children: label })) : null] }));
73
89
  }
@@ -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,18 @@
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
+ face: (family: string, weight: number) => {
10
+ fontFamily?: string;
11
+ fontWeight: any;
12
+ };
13
+ durationMs: number;
14
+ reduceMotion: boolean;
15
+ easing: (t: number) => number;
16
+ }
17
+ export declare function useDesign(scheme?: "light" | "dark" | "auto"): DesignLook;
18
+ export declare function useGraineTheme(): ThemeTokens;
@@ -0,0 +1,84 @@
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
+ import { faceFor, faceKey, textFace } from "./fonts.js";
8
+ const DEFAULT_DESIGN = sessionDesign(null);
9
+ const NO_FACES = {};
10
+ const classicSeen = new WeakMap();
11
+ function classic(design) {
12
+ let v = classicSeen.get(design);
13
+ if (v === undefined) {
14
+ v = isClassicLook(design);
15
+ classicSeen.set(design, v);
16
+ }
17
+ return v;
18
+ }
19
+ export function useReduceMotion() {
20
+ const [reduce, setReduce] = useState(false);
21
+ useEffect(() => {
22
+ let live = true;
23
+ const info = AccessibilityInfo;
24
+ try {
25
+ info?.isReduceMotionEnabled?.()?.then?.((v) => { if (live)
26
+ setReduce(!!v); }, () => { });
27
+ }
28
+ catch { }
29
+ let sub = null;
30
+ try {
31
+ sub = info?.addEventListener?.("reduceMotionChanged", (v) => setReduce(!!v));
32
+ }
33
+ catch {
34
+ sub = null;
35
+ }
36
+ return () => { live = false; try {
37
+ sub?.remove?.();
38
+ }
39
+ catch { } };
40
+ }, []);
41
+ return reduce;
42
+ }
43
+ export function easingOf(easing, bounce) {
44
+ if (easing === "linear" || easing === "none")
45
+ return Easing.linear;
46
+ if (easing === "emphasized")
47
+ return Easing.bezier(0.05, 0.7, 0.1, 1);
48
+ if (easing === "spring")
49
+ return Easing.out(Easing.back(Math.max(0.5, bounce * 4)));
50
+ return Easing.bezier(0.2, 0, 0, 1);
51
+ }
52
+ export function useDesign(scheme) {
53
+ const ctx = useContext(Ctx);
54
+ const device = useColorScheme();
55
+ const reduce = useReduceMotion();
56
+ const design = ctx?.design ?? DEFAULT_DESIGN;
57
+ const fonts = ctx?.fonts ?? null;
58
+ const faces = ctx?.faces ?? NO_FACES;
59
+ return useMemo(() => {
60
+ const tokens = scheme === "light" ? design.light : scheme === "dark" ? design.dark : pickTokens(design, device);
61
+ const durationMs = reduce || tokens.motion.easing === "none" ? 0 : Math.max(0, tokens.motion.durationMs);
62
+ return {
63
+ design,
64
+ tokens,
65
+ classic: classic(design),
66
+ font: (family) => {
67
+ const own = fontFamilyFor(family, fonts);
68
+ if (own)
69
+ return own;
70
+ const weight = family === tokens.font.heading && family !== tokens.font.body
71
+ ? tokens.font.weights.title : tokens.font.weights.body;
72
+ const f = faceFor(family, weight);
73
+ return f ? faces[faceKey(f.family, f.weight)] : undefined;
74
+ },
75
+ face: (family, weight) => textFace(family, weight, fonts, faces, fontFamilyFor),
76
+ durationMs,
77
+ reduceMotion: reduce,
78
+ easing: easingOf(tokens.motion.easing, tokens.motion.bounce),
79
+ };
80
+ }, [design, fonts, faces, device, reduce, scheme]);
81
+ }
82
+ export function useGraineTheme() {
83
+ return useDesign().tokens;
84
+ }
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ import type { GraineVoiceApi } from "./voice-launcher.js";
3
+ export interface GraineVoiceBarProps {
4
+ api: GraineVoiceApi;
5
+ onClose?: () => void;
6
+ bottomInset?: number;
7
+ draggable?: boolean;
8
+ placement?: {
9
+ bottomOffset?: number;
10
+ } | null;
11
+ onPlacementChange?: (placement: {
12
+ bottomOffset: number;
13
+ }) => void;
14
+ name?: string;
15
+ }
16
+ export declare function GraineVoiceBar({ api, onClose, bottomInset, draggable, placement, onPlacementChange, name: nameProp, }: GraineVoiceBarProps): React.JSX.Element;