@graineai/inapp-react-native 0.36.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.
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ConnectionQuality, type QualitySample, type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext, type ScreenField, type AgentSurface } from "./protocol.js";
2
2
  import { type FrictionConfig } from "./friction.js";
3
+ import type { ResolvedDesign } from "./theme.js";
3
4
  export declare const SUBPROTOCOL = "graine.embed.v1";
4
5
  export interface GraineInAppOptions {
5
6
  baseUrl: string;
@@ -17,6 +18,7 @@ export interface SessionConfig {
17
18
  webcallAgentId?: string;
18
19
  wsHost?: string;
19
20
  appearance?: Record<string, any>;
21
+ design?: ResolvedDesign;
20
22
  variables?: Record<string, string>;
21
23
  components?: any[];
22
24
  appActions?: Array<{
@@ -0,0 +1,5 @@
1
+ import { type ResolvedDesign } from "./theme.js";
2
+ export declare function sessionDesign(session: {
3
+ design?: unknown;
4
+ appearance?: unknown;
5
+ } | null | undefined): ResolvedDesign;
package/dist/design.js ADDED
@@ -0,0 +1,50 @@
1
+ import { designFromAppearance, normaliseHex, resolveDesign } from "./theme.js";
2
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3
+ const NULLABLE = {
4
+ "elevation.card": { y: 0, blur: 0, spread: 0, opacity: 0, color: "#000000" },
5
+ "elevation.floating": { y: 0, blur: 0, spread: 0, opacity: 0, color: "#000000" },
6
+ "elevation.hardOffset": { x: 0, y: 0 },
7
+ "icons.launcher": { id: "", path: "", stroke: true },
8
+ glass: { blur: 0, surfaceOpacity: 1 },
9
+ };
10
+ function fill(fallback, given, path) {
11
+ const template = NULLABLE[path];
12
+ if (template) {
13
+ if (given === null)
14
+ return null;
15
+ if (!isObject(given))
16
+ return fallback;
17
+ return fillObject(isObject(fallback) ? fallback : template, given, path);
18
+ }
19
+ if (isObject(fallback))
20
+ return isObject(given) ? fillObject(fallback, given, path) : fallback;
21
+ if (typeof fallback === "number")
22
+ return typeof given === "number" && Number.isFinite(given) ? given : fallback;
23
+ if (typeof fallback === "boolean")
24
+ return typeof given === "boolean" ? given : fallback;
25
+ if (typeof fallback === "string") {
26
+ if (typeof given !== "string" || !given)
27
+ return fallback;
28
+ if (/^#[0-9a-f]{6}$/i.test(fallback))
29
+ return normaliseHex(given) ?? fallback;
30
+ return given;
31
+ }
32
+ return fallback;
33
+ }
34
+ function fillObject(fallback, given, path) {
35
+ const out = {};
36
+ for (const key of Object.keys(fallback))
37
+ out[key] = fill(fallback[key], given[key], path ? `${path}.${key}` : key);
38
+ return out;
39
+ }
40
+ export function sessionDesign(session) {
41
+ const raw = session?.design;
42
+ if (!isObject(raw) || !isObject(raw.config))
43
+ return resolveDesign(designFromAppearance(session?.appearance));
44
+ const local = resolveDesign(raw.config);
45
+ return {
46
+ config: local.config,
47
+ light: fill(local.light, raw.light, ""),
48
+ dark: fill(local.dark, raw.dark, ""),
49
+ };
50
+ }
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type Fri
23
23
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
24
24
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
25
25
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
26
- export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
26
+ export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, useGraineTheme, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
27
27
  export { requestMicrophonePermission } from "./react-native/permissions.js";
28
28
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, type GraineFabProps, type GraineTouchCaptureProps, } from "./react-native/fab.js";
29
29
  export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
@@ -32,3 +32,5 @@ export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcV
32
32
  export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
33
33
  export { GraineVoiceLauncher, CAPTION_HISTORY, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
34
34
  export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation.js";
35
+ export { DESIGN_PRESETS, DEFAULT_DESIGN_PRESET, DESIGN_FONTS, LAUNCHER_ICONS, designPreset, normaliseDesignConfig, designFromAppearance, resolveTokens, resolveDesign, pickTokens, contrastRatio, readableOn, glyphUrl, type DesignConfig, type DesignPreset, type ThemeTokens, type ThemeColors, type ThemeShadow, type ResolvedDesign, type DesignBubbles, type LauncherShape, type MotionEasing, type IconStyle, } from "./theme.js";
36
+ export { sessionDesign } from "./design.js";
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "
14
14
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
15
15
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
16
16
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
17
- export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, } from "./react-native/index.js";
17
+ export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, useGraineTheme, } from "./react-native/index.js";
18
18
  export { requestMicrophonePermission } from "./react-native/permissions.js";
19
19
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
20
20
  export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
@@ -23,3 +23,5 @@ export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-
23
23
  export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
24
24
  export { GraineVoiceLauncher, CAPTION_HISTORY, } from "./react-native/voice-launcher.js";
25
25
  export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation.js";
26
+ export { DESIGN_PRESETS, DEFAULT_DESIGN_PRESET, DESIGN_FONTS, LAUNCHER_ICONS, designPreset, normaliseDesignConfig, designFromAppearance, resolveTokens, resolveDesign, pickTokens, contrastRatio, readableOn, glyphUrl, } from "./theme.js";
27
+ export { sessionDesign } from "./design.js";
@@ -0,0 +1,13 @@
1
+ import { type ResolvedDesign, type ThemeShadow, type ThemeTokens } from "../theme.js";
2
+ export declare function sameValue(a: unknown, b: unknown): boolean;
3
+ export declare function isClassicLook(design: ResolvedDesign): boolean;
4
+ export declare function fontFamilyFor(family: string | null | undefined, fonts: Record<string, string> | null | undefined): string | undefined;
5
+ export declare function alpha(hex: string, a: number): string;
6
+ export declare function shadowStyle(s: ThemeShadow | null | undefined): Record<string, unknown>;
7
+ export declare function launcherRadius(shape: string, height: number, tokens: ThemeTokens): number;
8
+ export declare function glyphPixels(size: number, pixelRatio: number): number;
9
+ export declare function onAccentFor(accent: string, tokens: ThemeTokens): string;
10
+ export declare function softFor(accent: string, tokens: ThemeTokens): string;
11
+ export declare function surfaceFill(color: string, tokens: ThemeTokens): string;
12
+ export declare function letterSpacing(size: number, em: number): number | undefined;
13
+ export declare function lineHeight(size: number, multiplier: number): number;
@@ -0,0 +1,85 @@
1
+ import { DEFAULT_DESIGN_PRESET, mixHex, normaliseHex, readableOn, resolveDesign, } from "../theme.js";
2
+ export function sameValue(a, b) {
3
+ if (a === b)
4
+ return true;
5
+ if (!a || !b || typeof a !== "object" || typeof b !== "object")
6
+ return false;
7
+ if (Array.isArray(a) !== Array.isArray(b))
8
+ return false;
9
+ const ka = Object.keys(a), kb = Object.keys(b);
10
+ if (ka.length !== kb.length)
11
+ return false;
12
+ return ka.every((k) => sameValue(a[k], b[k]));
13
+ }
14
+ export function isClassicLook(design) {
15
+ const c = design.config;
16
+ if (c.launcherIcon !== undefined)
17
+ return false;
18
+ const legacy = { preset: DEFAULT_DESIGN_PRESET };
19
+ if (c.accent !== undefined)
20
+ legacy.accent = c.accent;
21
+ if (c.scheme !== undefined)
22
+ legacy.scheme = c.scheme;
23
+ if (c.roundness !== undefined)
24
+ legacy.roundness = c.roundness;
25
+ const classic = resolveDesign(legacy);
26
+ return sameValue(design.light, classic.light) && sameValue(design.dark, classic.dark);
27
+ }
28
+ export function fontFamilyFor(family, fonts) {
29
+ const want = String(family ?? "").trim();
30
+ if (!want || want.toLowerCase() === "system" || !fonts)
31
+ return undefined;
32
+ const hit = Object.prototype.hasOwnProperty.call(fonts, want)
33
+ ? fonts[want]
34
+ : Object.entries(fonts).find(([k]) => k.toLowerCase() === want.toLowerCase())?.[1];
35
+ return typeof hit === "string" && hit.trim() ? hit : undefined;
36
+ }
37
+ export function alpha(hex, a) {
38
+ const n = parseInt((normaliseHex(hex) ?? "#000000").slice(1), 16);
39
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.round(a * 1000) / 1000})`;
40
+ }
41
+ export function shadowStyle(s) {
42
+ if (!s)
43
+ return {};
44
+ return {
45
+ shadowColor: s.color,
46
+ shadowOpacity: s.opacity,
47
+ shadowRadius: Math.round((s.blur / 2) * 10) / 10,
48
+ shadowOffset: { width: 0, height: s.y },
49
+ elevation: Math.max(1, Math.round((s.y + s.blur / 2) / 2)),
50
+ };
51
+ }
52
+ export function launcherRadius(shape, height, tokens) {
53
+ if (shape === "squircle")
54
+ return Math.round(height * 0.3);
55
+ if (shape === "rounded-square")
56
+ return tokens.shape.radius.lg;
57
+ if (shape === "square")
58
+ return tokens.shape.radius.sm;
59
+ return height / 2;
60
+ }
61
+ export function glyphPixels(size, pixelRatio) {
62
+ return Math.round(size * 0.46 * (pixelRatio > 0 ? pixelRatio : 1));
63
+ }
64
+ export function onAccentFor(accent, tokens) {
65
+ const a = normaliseHex(accent);
66
+ if (!a || a === normaliseHex(tokens.colors.accent))
67
+ return tokens.colors.onAccent;
68
+ return readableOn(a, [tokens.colors.onAccent, "#FFFFFF", "#101318"]);
69
+ }
70
+ export function softFor(accent, tokens) {
71
+ const a = normaliseHex(accent);
72
+ if (!a || a === normaliseHex(tokens.colors.accent))
73
+ return tokens.colors.accentSoft;
74
+ return mixHex(tokens.colors.surface, a, tokens.scheme === "light" ? 0.12 : 0.22);
75
+ }
76
+ export function surfaceFill(color, tokens) {
77
+ return tokens.glass ? alpha(color, tokens.glass.surfaceOpacity) : color;
78
+ }
79
+ export function letterSpacing(size, em) {
80
+ const v = Math.round(size * em * 100) / 100;
81
+ return v ? v : undefined;
82
+ }
83
+ export function lineHeight(size, multiplier) {
84
+ return Math.round(size * multiplier * 10) / 10;
85
+ }
@@ -1,10 +1,13 @@
1
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { useCallback, useContext, useRef, useState } from "react";
3
- import { Image, Pressable, Text, View } from "react-native";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useContext, useEffect, useRef, useState } from "react";
3
+ import { Animated, Image, PixelRatio, Platform, Pressable, Text, View } from "react-native";
4
4
  import { Ctx, useGraineAgent, useGraineClient } from "./index.js";
5
5
  import { GraineVoiceLauncher } from "./voice-launcher.js";
6
6
  import { GraineAgentBar } from "./ui.js";
7
7
  import { resolveNativeAppearance } from "./appearance.js";
8
+ import { useDesign } from "./use-design.js";
9
+ import { glyphPixels, launcherRadius, onAccentFor, shadowStyle } from "./design-math.js";
10
+ import { glyphUrl } from "../theme.js";
8
11
  import { UNNAMED_TAP } from "../protocol.js";
9
12
  function AboveNativeScreens({ overlay: Overlay, children, }) {
10
13
  if (!Overlay)
@@ -54,8 +57,9 @@ export function GraineTouchCapture({ children, enabled = true, style }) {
54
57
  return (_jsx(View, { style: [{ flex: 1 }, style], onTouchStart: onTouchStart, onTouchEnd: onTouchEnd, children: children }));
55
58
  }
56
59
  export function GraineFab({ webView, position = "bottom-right", size = 56, accent: accentProp, label, overlay, prewarm = true, onOpen, onClose }) {
57
- const { launcherVisible, launcherInset, appearance, error } = useGraineAgent();
60
+ const { launcherVisible, launcherInset, appearance, error, client } = useGraineAgent();
58
61
  const [open, setOpen] = useState(false);
62
+ const look = useDesign();
59
63
  const dashboard = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "Assistant", radius: 18 });
60
64
  const accent = accentProp ?? dashboard.accent;
61
65
  const name = dashboard.heading;
@@ -64,6 +68,9 @@ export function GraineFab({ webView, position = "bottom-right", size = 56, accen
64
68
  if (error || !launcherVisible)
65
69
  return null;
66
70
  const side = position === "bottom-left" ? { left: launcherInset.right } : { right: launcherInset.right };
71
+ if (!look.classic) {
72
+ return (_jsx(DesignedLauncher, { look: look, size: size, accent: accentProp ?? look.tokens.colors.accent, name: name, label: label ?? `Talk to ${name}`, logoUrl: dashboard.logoUrl, origin: client.opts.baseUrl, place: { position: "absolute", bottom: launcherInset.bottom, ...side }, onPress: () => { setOpen(true); api.start(); onOpen?.(); } }));
73
+ }
67
74
  return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label ?? `Talk to ${name}`, onPress: () => { setOpen(true); api.start(); onOpen?.(); }, style: ({ pressed }) => ({
68
75
  position: "absolute", bottom: launcherInset.bottom, ...side,
69
76
  width: size, height: size, borderRadius: size / 2, backgroundColor: dashboardAccent,
@@ -74,5 +81,50 @@ export function GraineFab({ webView, position = "bottom-right", size = 56, accen
74
81
  };
75
82
  if (!prewarm && !open)
76
83
  return _jsx(AboveNativeScreens, { overlay: overlay, children: button({ start: () => { } }) });
77
- return (_jsx(GraineVoiceLauncher, { webView: webView, onEnded: () => { setOpen(false); onClose?.(); }, children: (api) => (_jsx(AboveNativeScreens, { overlay: overlay, children: open ? _jsx(GraineAgentBar, { accent: dashboardAccent, name: name }) : button(api) })) }));
84
+ return (_jsx(GraineVoiceLauncher, { webView: webView, onEnded: () => { setOpen(false); onClose?.(); }, children: (api) => (_jsx(AboveNativeScreens, { overlay: overlay, children: open ? _jsx(GraineAgentBar, { accent: look.classic ? dashboardAccent : accentProp, name: name }) : button(api) })) }));
85
+ }
86
+ function DesignedLauncher({ look, size, accent, name, label, logoUrl, origin, place, onPress, }) {
87
+ const t = look.tokens;
88
+ const [failed, setFailed] = useState([]);
89
+ const fail = (uri) => setFailed((cur) => (cur.includes(uri) ? cur : [...cur, uri]));
90
+ const shown = useRef(new Animated.Value(look.durationMs > 0 ? 0 : 1)).current;
91
+ useEffect(() => {
92
+ if (look.durationMs <= 0) {
93
+ shown.setValue(1);
94
+ return;
95
+ }
96
+ const a = Animated.timing(shown, { toValue: 1, duration: look.durationMs, easing: look.easing, useNativeDriver: true });
97
+ a.start();
98
+ return () => a.stop();
99
+ }, []);
100
+ const onAccent = onAccentFor(accent, t);
101
+ const pill = t.shape.launcher === "pill";
102
+ const radius = launcherRadius(t.shape.launcher, size, t);
103
+ const g = Math.round(size * 0.46 * 10) / 10;
104
+ const glyphUri = t.icons.launcher
105
+ ? glyphUrl(origin, t.icons.launcher.id, onAccent, glyphPixels(size, PixelRatio.get()), t.icons.stroke)
106
+ : null;
107
+ const glyph = glyphUri && !failed.includes(glyphUri) ? glyphUri : null;
108
+ const logo = logoUrl && !failed.includes(logoUrl) ? logoUrl : null;
109
+ const hard = t.elevation.hardOffset;
110
+ const corners = {
111
+ borderRadius: radius,
112
+ ...(t.shape.launcher === "squircle" && Platform.OS === "ios" ? { borderCurve: "continuous" } : null),
113
+ };
114
+ const mark = glyph ? (_jsx(Image, { source: { uri: glyph }, style: { width: g, height: g }, onError: () => fail(glyph), accessibilityIgnoresInvertColors: true }, glyph)) : logo ? (_jsx(Image, { source: { uri: logo }, resizeMode: "contain", style: { width: g, height: g }, onError: () => fail(logo), accessibilityIgnoresInvertColors: true }, logo)) : (_jsx(Text, { style: {
115
+ color: onAccent, fontSize: Math.round(size * 0.46), fontWeight: String(t.font.weights.title),
116
+ fontFamily: look.font(t.font.heading),
117
+ }, children: name.trim().charAt(0).toUpperCase() || "\u2022" }));
118
+ return (_jsxs(Animated.View, { pointerEvents: "box-none", style: [place, { opacity: shown, transform: [{ scale: shown.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) }] }], children: [hard ? (_jsx(View, { pointerEvents: "none", style: { ...corners, position: "absolute", top: hard.y, left: hard.x, right: -hard.x, bottom: -hard.y, backgroundColor: t.colors.text } })) : null, _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: onPress, style: ({ pressed }) => ({
119
+ ...corners,
120
+ height: size, minWidth: size, width: pill ? undefined : size,
121
+ flexDirection: "row", alignItems: "center", justifyContent: "center",
122
+ gap: pill ? 8 : 0, paddingHorizontal: pill ? Math.round(size * 0.32) : 0,
123
+ backgroundColor: accent,
124
+ opacity: pressed ? 0.85 : 1,
125
+ ...(hard ? { borderWidth: t.shape.borderWidth, borderColor: t.colors.text } : shadowStyle(t.elevation.floating)),
126
+ }), children: [mark, pill ? (_jsx(Text, { numberOfLines: 1, style: {
127
+ color: onAccent, fontSize: t.font.scale.body, fontWeight: String(t.font.weights.strong),
128
+ fontFamily: look.font(t.font.body),
129
+ }, children: name })) : null] })] }));
78
130
  }
@@ -17,6 +17,8 @@ export type { AgentSurface, SurfacePolicy } from "../protocol.js";
17
17
  import { type AgentSurface, type ConnectionQuality } from "../protocol.js";
18
18
  export type { ConnectionQuality, QualitySample } from "../protocol.js";
19
19
  import type { FrictionConfig } from "../friction.js";
20
+ import type { ResolvedDesign } from "../theme.js";
21
+ export { useGraineTheme } from "./use-design.js";
20
22
  import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
21
23
  interface GraineContextValue {
22
24
  client: GraineInAppClient;
@@ -36,6 +38,8 @@ interface GraineContextValue {
36
38
  launcherInset: LauncherInset;
37
39
  currentScreen: string | null;
38
40
  appearance: Record<string, any> | null;
41
+ design: ResolvedDesign;
42
+ fonts: Record<string, string> | null;
39
43
  sessionReady: boolean;
40
44
  onEvent: (fn: (e: GraineEvent) => void) => () => void;
41
45
  track: (name: string, data?: Record<string, unknown>) => void;
@@ -138,8 +142,9 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
138
142
  includeScreens?: string[];
139
143
  launcherDelayMs?: number;
140
144
  visibility?: LauncherVisibility;
145
+ fonts?: Record<string, string>;
141
146
  }
142
- export declare function GraineProvider({ children, autoConnect, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
147
+ export declare function GraineProvider({ children, autoConnect, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, fonts: fontsProp, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
143
148
  export declare function useGraineAgent(): GraineContextValue;
144
149
  export declare function useGraineSurface(): {
145
150
  surface: AgentSurface;
@@ -12,6 +12,9 @@ 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 { sameValue } from "./design-math.js";
17
+ export { useGraineTheme } from "./use-design.js";
15
18
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
16
19
  export const PENDING_WIDGET_MS = 12000;
17
20
  export const AWAITING_REPLY_MS = 12000;
@@ -29,7 +32,7 @@ export function useGraineClient() {
29
32
  }
30
33
  return client;
31
34
  }
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 }) {
35
+ export function GraineProvider({ children, autoConnect = true, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, fonts: fontsProp, client: providedClient, ...options }) {
33
36
  const clientRef = useRef(null);
34
37
  if (!clientRef.current) {
35
38
  clientRef.current =
@@ -61,6 +64,15 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
61
64
  return () => { listenersRef.current.delete(fn); };
62
65
  }, []);
63
66
  const [appearance, setAppearance] = useState(null);
67
+ const [design, setDesign] = useState(() => sessionDesign(client.config));
68
+ const adoptDesign = useCallback((cfg) => {
69
+ if (!cfg)
70
+ return;
71
+ const next = sessionDesign(cfg);
72
+ setDesign((cur) => (sameValue(cur, next) ? cur : next));
73
+ }, []);
74
+ const fontsKey = fontsProp ? JSON.stringify(fontsProp) : "";
75
+ const fonts = useMemo(() => (fontsKey ? JSON.parse(fontsKey) : null), [fontsKey]);
64
76
  const [sessionReady, setSessionReady] = useState(false);
65
77
  const [connectionQuality, setConnectionQuality] = useState("unknown");
66
78
  const [network, setNetwork] = useState("unknown");
@@ -170,6 +182,7 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
170
182
  setError(null);
171
183
  if (client.config?.appearance)
172
184
  setAppearance(client.config.appearance);
185
+ adoptDesign(client.config);
173
186
  emitEvent({ type: "conversation_started" });
174
187
  }),
175
188
  client.on("close", (info) => {
@@ -231,6 +244,7 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
231
244
  const cfg = client.config ?? (await client.session());
232
245
  if (cfg?.appearance)
233
246
  setAppearance(cfg.appearance);
247
+ adoptDesign(cfg);
234
248
  setSessionReady(true);
235
249
  }
236
250
  catch {
@@ -334,6 +348,8 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
334
348
  launcherInset,
335
349
  currentScreen,
336
350
  appearance,
351
+ design,
352
+ fonts,
337
353
  sessionReady,
338
354
  onEvent,
339
355
  track: (name, data) => client.track(name, data),
@@ -346,7 +362,7 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
346
362
  network,
347
363
  setVariables: (vars) => client.setVariables(vars),
348
364
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
349
- launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady,
365
+ launcherVisible, launcherInset, currentScreen, onEvent, appearance, design, fonts, sessionReady,
350
366
  surface, surfacePolicy, setSurface, connectionQuality, network]);
351
367
  const wantsCapture = captureTaps ?? !!fab;
352
368
  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
  }