@graineai/inapp-react-native 0.37.0 → 0.39.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/INTEGRATION.md CHANGED
@@ -560,8 +560,12 @@ your funnel.
560
560
 
561
561
  ## Captions and hanging up
562
562
 
563
- `GraineVoiceLauncher`'s render prop gives you the whole transcript and the CC
564
- switch, so a caption box is a `map` and nothing else. **0.28.0.**
563
+ `GraineVoiceBar` draws the caption, the microphone and the hang-up in the
564
+ brand's design — mount it in the render prop and you are done (**0.38.0**):
565
+ `{(api) => <GraineVoiceBar api={api} onClose={close} />}`.
566
+
567
+ Drawing your own instead: the render prop gives you the whole transcript and
568
+ the CC switch, so a caption box is a `map` and nothing else. **0.28.0.**
565
569
 
566
570
  ```tsx
567
571
  <GraineVoiceLauncher webView={WebView} active={open} autoStart>
@@ -707,14 +711,20 @@ part. Everything else is unchanged.
707
711
 
708
712
  ## Making the bar yours
709
713
 
710
- Three levels, and most apps stop at the first.
714
+ Most apps stop at the first level — and since 0.38.0 the first level is the
715
+ whole design.
711
716
 
712
- **1. The dashboard.** Accent, title and logo come from Appearance on the agent
713
- and reach every surface without an app release. Set the colour in Graine and it
714
- changes here. This is the level a brand team should be working at.
717
+ **1. The dashboard.** The brand chooses a design in Appearance — one of fifteen
718
+ philosophies, tuned for colour, corners, typeface, launcher, icons, motion and
719
+ bubbles — and the SDK draws it on every surface with no app release:
720
+ `GraineVoiceBar` for the call, `GraineAgentBar` for the chat, the launcher and
721
+ every component. Give the provider `loadFont={Font.loadAsync}` and
722
+ `svg={{ Svg, Path }}` once and the SDK loads the design's typeface and draws its
723
+ icons itself. This is the level a brand team should be working at.
715
724
 
716
- **2. Props, when the dashboard cannot express it.** Any prop passed wins over
717
- the dashboard, so an app that needs something per-build still can.
725
+ **2. Props, when the dashboard cannot express it.** On `GraineAgentBar`, any
726
+ prop passed wins over the dashboard, so an app that needs something per-build
727
+ still can.
718
728
 
719
729
  ```tsx
720
730
  <GraineAgentBar
@@ -749,15 +759,16 @@ import type { GraineBarTheme } from "@graineai/inapp-react-native";
749
759
  | `onAccent` | Text and glyphs sitting on the accent, and on `danger` |
750
760
  | `danger` | Muted — the one state worth its own colour |
751
761
 
752
- **4. Or draw your own.** Everything the bar does is built on the public hooks,
753
- so an app with a design system should ignore the component entirely:
762
+ **4. Or draw your own.** Everything the bar does is built on the public hooks —
763
+ but the design chosen in the dashboard then stops reaching that part of your
764
+ app, so match it with `useGraineTheme()`, which returns the design's tokens:
754
765
 
755
766
  ```tsx
756
767
  const { connected, muted, setMuted, messages, send, caption } = useGraineAgent();
768
+ const theme = useGraineTheme(); // colours, type, corners, spacing, shadows
757
769
  ```
758
770
 
759
- That is the expected path for anyone with a brand team, and nothing else in the
760
- SDK depends on this component.
771
+ Nothing else in the SDK depends on these components.
761
772
 
762
773
  ---
763
774
 
@@ -1096,6 +1107,9 @@ told to ignore).
1096
1107
  | `endOnBackground` | `boolean` | Default `true`. You want it on. |
1097
1108
  | `appVersion` | `string` | Which build the conversation happened in. Lands on the call record. |
1098
1109
  | `friction` | `FrictionConfig` | This app's friction rules, layered over the dashboard's. See *Friction detection*. |
1110
+ | `loadFont` | `(fonts) => Promise` | expo-font's `Font.loadAsync`. The SDK loads the design's typeface itself — only the faces it uses, from Graine. **0.38.0** |
1111
+ | `svg` | `{ Svg, Path }` | react-native-svg's components, so the SDK draws its icons in the design's style. **0.38.0** |
1112
+ | `fonts` | `Record<string, string>` | Families your app already bundles (dashboard name → your `fontFamily`); they win over a download. |
1099
1113
 
1100
1114
  ### `GraineInAppClient`
1101
1115
 
package/README.md CHANGED
@@ -119,13 +119,17 @@ over the air.
119
119
 
120
120
  ```tsx
121
121
  import { WebView } from "react-native-webview";
122
- import { GraineProvider, GraineVoiceLauncher, GraineAgentBar } from "@graineai/inapp-react-native";
122
+ import * as Font from "expo-font";
123
+ import Svg, { Path } from "react-native-svg";
124
+ import { GraineProvider, GraineVoiceLauncher, GraineVoiceBar } from "@graineai/inapp-react-native";
123
125
 
124
126
  <GraineProvider
125
127
  baseUrl="…"
126
128
  publishableKey="pk_live_…"
127
129
  navigationRef={navigationRef}
128
130
  autoConnect={false} // the launcher owns the connection
131
+ loadFont={Font.loadAsync} // the SDK loads the design's typeface itself
132
+ svg={{ Svg, Path }} // …and draws its icons in the design's style
129
133
  >
130
134
  <RootNavigator />
131
135
  <GraineVoiceLauncher
@@ -133,7 +137,7 @@ import { GraineProvider, GraineVoiceLauncher, GraineAgentBar } from "@graineai/i
133
137
  autoStart
134
138
  onError={(m) => console.warn("[graine]", m)}
135
139
  >
136
- {() => <GraineAgentBar />}
140
+ {(api) => <GraineVoiceBar api={api} />}
137
141
  </GraineVoiceLauncher>
138
142
  </GraineProvider>
139
143
  ```
@@ -225,6 +229,7 @@ requests a permission on your behalf.
225
229
  | `useGraineIdentify()` | Who the customer is — it reaches the greeting and the call record. |
226
230
  | `useGraineHighlight()` / `useGraineWidget()` | Let the agent point at things and draw components. |
227
231
  | `GraineVoiceLauncher` | The voice engine: WebView, microphone, WebRTC or WebSocket. |
232
+ | `GraineVoiceBar` | The voice bar in the brand's design, as the dashboard previews it. Mount inside `GraineVoiceLauncher`. |
228
233
  | `fab={{ webView }}` on the provider / `GraineFab` | The floating button, mounted for you; opens the conversation, folds back on end. |
229
234
  | `captureTaps` / `GraineTouchCapture` | Every tap tracked automatically — no per-button wrapping. |
230
235
  | `useGraineInit()` | `{ isInitialized, error }`. |
package/dist/index.d.ts CHANGED
@@ -23,14 +23,17 @@ 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, useGraineTheme, 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 SvgKit, 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";
30
30
  export { resolveNativeAppearance, type NativeAppearance, type NativeAppearanceFallback, resolveWidgetPalette, type WidgetPalette, type WidgetMode, } from "./react-native/appearance.js";
31
31
  export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
32
32
  export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
33
+ export { GraineVoiceBar, type GraineVoiceBarProps } from "./react-native/voice-bar.js";
34
+ export { GraineLauncherButton, type GraineLauncherButtonProps } from "./react-native/fab.js";
35
+ export { faceFor, facesForDesign, fontUrl, FONT_ASSET_VERSION, type FontLoader, type FontFace } from "./react-native/fonts.js";
33
36
  export { GraineVoiceLauncher, CAPTION_HISTORY, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
34
37
  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";
38
+ export { DESIGN_PRESETS, DEFAULT_DESIGN_PRESET, DESIGN_FONTS, LAUNCHER_ICONS, SDK_ICONS, DESIGN_CHROMES, designPreset, normaliseDesignConfig, designFromAppearance, resolveTokens, resolveDesign, pickTokens, contrastRatio, readableOn, glyphUrl, type DesignConfig, type DesignPreset, type ThemeTokens, type ThemeColors, type ThemeShadow, type ResolvedDesign, type DesignBubbles, type DesignChrome, type LauncherShape, type MotionEasing, type IconStyle, type SdkIconName, } from "./theme.js";
36
39
  export { sessionDesign } from "./design.js";
package/dist/index.js CHANGED
@@ -21,7 +21,10 @@ export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
21
21
  export { resolveNativeAppearance, resolveWidgetPalette, } from "./react-native/appearance.js";
22
22
  export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
23
23
  export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
24
+ export { GraineVoiceBar } from "./react-native/voice-bar.js";
25
+ export { GraineLauncherButton } from "./react-native/fab.js";
26
+ export { faceFor, facesForDesign, fontUrl, FONT_ASSET_VERSION } from "./react-native/fonts.js";
24
27
  export { GraineVoiceLauncher, CAPTION_HISTORY, } from "./react-native/voice-launcher.js";
25
28
  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";
29
+ export { DESIGN_PRESETS, DEFAULT_DESIGN_PRESET, DESIGN_FONTS, LAUNCHER_ICONS, SDK_ICONS, DESIGN_CHROMES, designPreset, normaliseDesignConfig, designFromAppearance, resolveTokens, resolveDesign, pickTokens, contrastRatio, readableOn, glyphUrl, } from "./theme.js";
27
30
  export { sessionDesign } from "./design.js";
@@ -22,3 +22,10 @@ export interface GraineFabProps {
22
22
  prewarm?: boolean;
23
23
  }
24
24
  export declare function GraineFab({ webView, position, size, accent: accentProp, label, overlay, prewarm, onOpen, onClose }: GraineFabProps): React.JSX.Element;
25
+ export interface GraineLauncherButtonProps {
26
+ onPress: () => void;
27
+ style?: ViewStyle;
28
+ label?: string;
29
+ size?: number;
30
+ }
31
+ export declare function GraineLauncherButton({ onPress, style, label, size }: GraineLauncherButtonProps): React.JSX.Element;
@@ -83,6 +83,12 @@ export function GraineFab({ webView, position = "bottom-right", size = 56, accen
83
83
  return _jsx(AboveNativeScreens, { overlay: overlay, children: button({ start: () => { } }) });
84
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
85
  }
86
+ export function GraineLauncherButton({ onPress, style, label, size = 56 }) {
87
+ const { appearance, client } = useGraineAgent();
88
+ const look = useDesign();
89
+ const dashboard = resolveNativeAppearance(appearance, { accent: look.tokens.colors.accent, heading: "Assistant" });
90
+ return (_jsx(DesignedLauncher, { look: look, size: size, accent: look.tokens.colors.accent, name: dashboard.heading, label: label ?? `Talk to ${dashboard.heading}`, logoUrl: dashboard.logoUrl, origin: client.opts.baseUrl, place: style ?? {}, onPress: onPress }));
91
+ }
86
92
  function DesignedLauncher({ look, size, accent, name, label, logoUrl, origin, place, onPress, }) {
87
93
  const t = look.tokens;
88
94
  const [failed, setFailed] = useState([]);
@@ -0,0 +1 @@
1
+ export declare const FONT_WEIGHTS: Record<string, number[]>;
@@ -0,0 +1,26 @@
1
+ export const FONT_WEIGHTS = {
2
+ "Inter": [100, 200, 300, 400, 500, 600, 700, 800, 900],
3
+ "Roboto": [100, 200, 300, 400, 500, 600, 700, 800, 900],
4
+ "Geist": [100, 200, 300, 400, 500, 600, 700, 800, 900],
5
+ "IBM Plex Sans": [100, 200, 300, 400, 500, 600, 700],
6
+ "IBM Plex Sans Devanagari": [100, 200, 300, 400, 500, 600, 700],
7
+ "DM Sans": [100, 200, 300, 400, 500, 600, 700, 800, 900],
8
+ "Noto Sans": [100, 200, 300, 400, 500, 600, 700, 800, 900],
9
+ "Noto Sans Devanagari": [100, 200, 300, 400, 500, 600, 700, 800, 900],
10
+ "Noto Sans Tamil": [100, 200, 300, 400, 500, 600, 700, 800, 900],
11
+ "Mukta": [200, 300, 400, 500, 600, 700, 800],
12
+ "Hind": [300, 400, 500, 600, 700],
13
+ "Anek Devanagari": [100, 200, 300, 400, 500, 600, 700, 800],
14
+ "Poppins": [100, 200, 300, 400, 500, 600, 700, 800, 900],
15
+ "Baloo 2": [400, 500, 600, 700, 800],
16
+ "Nunito": [200, 300, 400, 500, 600, 700, 800, 900],
17
+ "Atkinson Hyperlegible Next": [200, 300, 400, 500, 600, 700, 800],
18
+ "Source Serif 4": [200, 300, 400, 500, 600, 700, 800, 900],
19
+ "Newsreader": [200, 300, 400, 500, 600, 700, 800],
20
+ "Fraunces": [100, 200, 300, 400, 500, 600, 700, 800, 900],
21
+ "Playfair Display": [400, 500, 600, 700, 800, 900],
22
+ "Tiro Devanagari Hindi": [400],
23
+ "Noto Serif Devanagari": [100, 200, 300, 400, 500, 600, 700, 800, 900],
24
+ "Martel": [200, 300, 400, 600, 700, 800, 900],
25
+ "JetBrains Mono": [100, 200, 300, 400, 500, 600, 700, 800],
26
+ };
@@ -0,0 +1,20 @@
1
+ import type { ResolvedDesign, ThemeTokens } from "../theme.js";
2
+ export type FontLoader = (fonts: Record<string, string>) => Promise<unknown>;
3
+ export interface FontFace {
4
+ family: string;
5
+ weight: number;
6
+ name: string;
7
+ url: string;
8
+ }
9
+ export type LoadedFaces = Record<string, string>;
10
+ export declare const faceKey: (family: string, weight: number) => string;
11
+ export declare function nearestWeight(available: number[], want: number): number | null;
12
+ export declare const FONT_ASSET_VERSION = 1;
13
+ export declare function fontUrl(origin: string, family: string, weight: number): string;
14
+ export declare function faceFor(family: string | null | undefined, weight: number, origin?: string): FontFace | null;
15
+ export declare function facesFor(t: ThemeTokens, origin?: string): FontFace[];
16
+ export declare function facesForDesign(design: ResolvedDesign, origin?: string): FontFace[];
17
+ export declare function textFace(family: string | null | undefined, weight: number, hostFonts: Record<string, string> | null | undefined, loaded: LoadedFaces | null | undefined, hostLookup: (family: string | null | undefined, fonts: Record<string, string> | null | undefined) => string | undefined): {
18
+ fontFamily?: string;
19
+ fontWeight: any;
20
+ };
@@ -0,0 +1,69 @@
1
+ import { FONT_WEIGHTS } from "./font-weights.js";
2
+ export const faceKey = (family, weight) => `${family}|${weight}`;
3
+ export function nearestWeight(available, want) {
4
+ if (!available.length)
5
+ return null;
6
+ let best = available[0];
7
+ for (const w of available) {
8
+ const d = Math.abs(w - want);
9
+ const bd = Math.abs(best - want);
10
+ if (d < bd || (d === bd && w > best))
11
+ best = w;
12
+ }
13
+ return best;
14
+ }
15
+ export const FONT_ASSET_VERSION = 1;
16
+ export function fontUrl(origin, family, weight) {
17
+ const base = String(origin || "https://www.graine.ai").replace(/\/+$/, "");
18
+ return `${base}/api/embed/font?f=${encodeURIComponent(family)}&w=${weight}&v=${FONT_ASSET_VERSION}`;
19
+ }
20
+ export function faceFor(family, weight, origin = "https://www.graine.ai") {
21
+ const want = String(family ?? "").trim();
22
+ if (!want || want.toLowerCase() === "system")
23
+ return null;
24
+ const canonical = Object.prototype.hasOwnProperty.call(FONT_WEIGHTS, want)
25
+ ? want
26
+ : Object.keys(FONT_WEIGHTS).find((k) => k.toLowerCase() === want.toLowerCase());
27
+ if (!canonical)
28
+ return null;
29
+ const w = nearestWeight(FONT_WEIGHTS[canonical], weight);
30
+ if (w == null)
31
+ return null;
32
+ return {
33
+ family: canonical,
34
+ weight: w,
35
+ name: `Graine-${canonical.replace(/[^A-Za-z0-9]/g, "")}-${w}`,
36
+ url: fontUrl(origin, canonical, w),
37
+ };
38
+ }
39
+ export function facesFor(t, origin) {
40
+ const out = new Map();
41
+ const add = (family, weight) => {
42
+ const f = faceFor(family, weight, origin);
43
+ if (f)
44
+ out.set(faceKey(f.family, f.weight), f);
45
+ };
46
+ add(t.font.body, t.font.weights.body);
47
+ add(t.font.body, t.font.weights.strong);
48
+ add(t.font.heading, t.font.weights.title);
49
+ add(t.font.heading, t.font.weights.strong);
50
+ return [...out.values()];
51
+ }
52
+ export function facesForDesign(design, origin) {
53
+ const out = new Map();
54
+ for (const t of [design.light, design.dark]) {
55
+ for (const f of facesFor(t, origin))
56
+ out.set(faceKey(f.family, f.weight), f);
57
+ }
58
+ return [...out.values()];
59
+ }
60
+ export function textFace(family, weight, hostFonts, loaded, hostLookup) {
61
+ const host = hostLookup(family, hostFonts);
62
+ if (host)
63
+ return { fontFamily: host, fontWeight: String(weight) };
64
+ const face = faceFor(family, weight);
65
+ const name = face && loaded ? loaded[faceKey(face.family, face.weight)] : undefined;
66
+ if (name)
67
+ return { fontFamily: name, fontWeight: "normal" };
68
+ return { fontWeight: String(weight) };
69
+ }
@@ -18,6 +18,7 @@ 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
20
  import type { ResolvedDesign } from "../theme.js";
21
+ import { type FontLoader, type LoadedFaces } from "./fonts.js";
21
22
  export { useGraineTheme } from "./use-design.js";
22
23
  import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
23
24
  interface GraineContextValue {
@@ -40,6 +41,8 @@ interface GraineContextValue {
40
41
  appearance: Record<string, any> | null;
41
42
  design: ResolvedDesign;
42
43
  fonts: Record<string, string> | null;
44
+ faces: LoadedFaces;
45
+ svg: SvgKit | null;
43
46
  sessionReady: boolean;
44
47
  onEvent: (fn: (e: GraineEvent) => void) => () => void;
45
48
  track: (name: string, data?: Record<string, unknown>) => void;
@@ -143,8 +146,14 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
143
146
  launcherDelayMs?: number;
144
147
  visibility?: LauncherVisibility;
145
148
  fonts?: Record<string, string>;
149
+ loadFont?: FontLoader;
150
+ svg?: SvgKit;
146
151
  }
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;
152
+ export interface SvgKit {
153
+ Svg: React.ComponentType<any>;
154
+ Path: React.ComponentType<any>;
155
+ }
156
+ export declare function GraineProvider({ children, autoConnect, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, fonts: fontsProp, loadFont, svg: svgProp, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
148
157
  export declare function useGraineAgent(): GraineContextValue;
149
158
  export declare function useGraineSurface(): {
150
159
  surface: AgentSurface;
@@ -13,7 +13,8 @@ export { widgetSurface } from "../protocol.js";
13
13
  export { resolveSurface } from "../protocol.js";
14
14
  import { resolveSurface } from "../protocol.js";
15
15
  import { sessionDesign } from "../design.js";
16
- import { sameValue } from "./design-math.js";
16
+ import { fontFamilyFor, sameValue } from "./design-math.js";
17
+ import { facesForDesign, faceKey } from "./fonts.js";
17
18
  export { useGraineTheme } from "./use-design.js";
18
19
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
19
20
  export const PENDING_WIDGET_MS = 12000;
@@ -32,7 +33,7 @@ export function useGraineClient() {
32
33
  }
33
34
  return client;
34
35
  }
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 }) {
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 }) {
36
37
  const clientRef = useRef(null);
37
38
  if (!clientRef.current) {
38
39
  clientRef.current =
@@ -73,6 +74,32 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
73
74
  }, []);
74
75
  const fontsKey = fontsProp ? JSON.stringify(fontsProp) : "";
75
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]);
76
103
  const [sessionReady, setSessionReady] = useState(false);
77
104
  const [connectionQuality, setConnectionQuality] = useState("unknown");
78
105
  const [network, setNetwork] = useState("unknown");
@@ -350,6 +377,8 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
350
377
  appearance,
351
378
  design,
352
379
  fonts,
380
+ faces,
381
+ svg: svgKit,
353
382
  sessionReady,
354
383
  onEvent,
355
384
  track: (name, data) => client.track(name, data),
@@ -362,7 +391,7 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
362
391
  network,
363
392
  setVariables: (vars) => client.setVariables(vars),
364
393
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
365
- launcherVisible, launcherInset, currentScreen, onEvent, appearance, design, fonts, sessionReady,
394
+ launcherVisible, launcherInset, currentScreen, onEvent, appearance, design, fonts, faces, svgKit, sessionReady,
366
395
  surface, surfacePolicy, setSurface, connectionQuality, network]);
367
396
  const wantsCapture = captureTaps ?? !!fab;
368
397
  const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
@@ -6,6 +6,10 @@ export interface DesignLook {
6
6
  tokens: ThemeTokens;
7
7
  classic: boolean;
8
8
  font: (family: string) => string | undefined;
9
+ face: (family: string, weight: number) => {
10
+ fontFamily?: string;
11
+ fontWeight: any;
12
+ };
9
13
  durationMs: number;
10
14
  reduceMotion: boolean;
11
15
  easing: (t: number) => number;
@@ -4,7 +4,9 @@ import { Ctx } from "./index.js";
4
4
  import { sessionDesign } from "../design.js";
5
5
  import { pickTokens } from "../theme.js";
6
6
  import { fontFamilyFor, isClassicLook } from "./design-math.js";
7
+ import { faceFor, faceKey, textFace } from "./fonts.js";
7
8
  const DEFAULT_DESIGN = sessionDesign(null);
9
+ const NO_FACES = {};
8
10
  const classicSeen = new WeakMap();
9
11
  function classic(design) {
10
12
  let v = classicSeen.get(design);
@@ -53,6 +55,7 @@ export function useDesign(scheme) {
53
55
  const reduce = useReduceMotion();
54
56
  const design = ctx?.design ?? DEFAULT_DESIGN;
55
57
  const fonts = ctx?.fonts ?? null;
58
+ const faces = ctx?.faces ?? NO_FACES;
56
59
  return useMemo(() => {
57
60
  const tokens = scheme === "light" ? design.light : scheme === "dark" ? design.dark : pickTokens(design, device);
58
61
  const durationMs = reduce || tokens.motion.easing === "none" ? 0 : Math.max(0, tokens.motion.durationMs);
@@ -60,12 +63,21 @@ export function useDesign(scheme) {
60
63
  design,
61
64
  tokens,
62
65
  classic: classic(design),
63
- font: (family) => fontFamilyFor(family, fonts),
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),
64
76
  durationMs,
65
77
  reduceMotion: reduce,
66
78
  easing: easingOf(tokens.motion.easing, tokens.motion.bounce),
67
79
  };
68
- }, [design, fonts, device, reduce, scheme]);
80
+ }, [design, fonts, faces, device, reduce, scheme]);
69
81
  }
70
82
  export function useGraineTheme() {
71
83
  return useDesign().tokens;
@@ -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;
@@ -0,0 +1,257 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useContext, useEffect, useRef, useState } from "react";
3
+ import { Animated, Easing, Image, PixelRatio, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
4
+ import { Ctx, useGraineAgent } from "./index.js";
5
+ import { GraineLoader } from "./loader.js";
6
+ import { GraineWidget } from "./widget.js";
7
+ import { resolveNativeAppearance } from "./appearance.js";
8
+ import { useDesign } from "./use-design.js";
9
+ import { glyphPixels, launcherRadius, letterSpacing, lineHeight, shadowStyle, surfaceFill } from "./design-math.js";
10
+ import { useKeyboardLift } from "./keyboard.js";
11
+ import { useDraggablePlacement } from "./placement.js";
12
+ import { glyphUrl, SDK_ICONS } from "../theme.js";
13
+ import { TitleBar, windowed } from "./window-chrome.js";
14
+ const BAR_HEIGHT = 60;
15
+ const BUTTON = 38;
16
+ const METER = [0.55, 1, 0.7, 0.4];
17
+ function statusOf(api, isChat, chatConnected) {
18
+ const conn = api.connectivity?.state;
19
+ const say = (text) => ({ text, waiting: null, meter: false });
20
+ if (api.error)
21
+ return say("Unavailable");
22
+ if (isChat)
23
+ return chatConnected ? say("Online") : { text: "Connecting…", waiting: "connecting", meter: false };
24
+ if (api.network === "offline")
25
+ return say("No connection");
26
+ if (api.reconnecting || conn === "retrying")
27
+ return { text: "Reconnecting…", waiting: "reconnecting", meter: false };
28
+ if (api.micDenied)
29
+ return say("Needs microphone");
30
+ if (conn === "failed")
31
+ return say("Couldn’t load · tap to retry");
32
+ if (!api.ready || api.connecting)
33
+ return { text: "Connecting…", waiting: "connecting", meter: false };
34
+ if (api.muted)
35
+ return say("Muted");
36
+ if (api.connected && api.phase === "thinking")
37
+ return { text: "Thinking…", waiting: "thinking", meter: false };
38
+ if (api.connected && api.caption?.role === "agent" && api.caption.live)
39
+ return { text: "Speaking", waiting: null, meter: true };
40
+ if (api.connected && api.quality === "poor")
41
+ return say("Weak connection");
42
+ if (api.connected)
43
+ return { text: "Listening", waiting: null, meter: true };
44
+ return say("Tap to talk");
45
+ }
46
+ export function GraineVoiceBar({ api, onClose, bottomInset, draggable = true, placement, onPlacementChange, name: nameProp, }) {
47
+ const ctx = useContext(Ctx);
48
+ const { messages, send, connected: chatConnected, appearance, surface, canSwitch, setSurface, launcherInset } = useGraineAgent();
49
+ const look = useDesign();
50
+ const t = look.tokens;
51
+ const c = t.colors;
52
+ const kit = ctx?.svg ?? null;
53
+ const origin = ctx?.client?.opts?.baseUrl || "https://www.graine.ai";
54
+ const dash = resolveNativeAppearance(appearance, { accent: c.accent, heading: "Assistant" });
55
+ const heading = dash.heading;
56
+ const spoken = nameProp || heading;
57
+ const isChat = surface === "chat";
58
+ const keyboardLift = useKeyboardLift();
59
+ const baseBottom = bottomInset ?? launcherInset?.bottom ?? 96;
60
+ const drag = useDraggablePlacement({
61
+ baseBottom,
62
+ enabled: draggable,
63
+ initialOffset: placement?.bottomOffset,
64
+ onChange: (bottomOffset) => onPlacementChange?.({ bottomOffset }),
65
+ });
66
+ const status = statusOf(api, isChat, chatConnected);
67
+ const conn = api.connectivity?.state;
68
+ const micBlocked = !!api.error || api.micDenied || api.connecting || (!api.ready && conn !== "failed");
69
+ const caption = !isChat && api.captionsOn && api.caption?.text ? api.caption : null;
70
+ const body = (size, weight) => ({
71
+ ...look.face(t.font.body, weight),
72
+ fontSize: size,
73
+ letterSpacing: letterSpacing(size, t.font.letterSpacing.body),
74
+ });
75
+ const endCall = () => {
76
+ api.end();
77
+ setTimeout(() => onClose?.(), 400);
78
+ };
79
+ return (_jsxs(View, { pointerEvents: "box-none", ...drag.panHandlers, onLayout: drag.onLayout, style: {
80
+ position: "absolute", zIndex: 900, gap: 8, left: 12, right: 12,
81
+ bottom: baseBottom + drag.offset + keyboardLift,
82
+ }, children: [_jsx(WidgetGuard, { children: _jsx(GraineWidget, {}) }), isChat ? (_jsx(ChatWindow, { look: look, kit: kit, origin: origin, heading: heading, logoUrl: dash.logoUrl ?? undefined, messages: messages, send: send, online: chatConnected, lifted: keyboardLift > 0, onCollapse: () => (canSwitch ? setSurface("voice") : endCall()) })) : caption ? (_jsx(CaptionCard, { look: look, role: caption.role, text: caption.text, live: !!caption.live })) : null, _jsx(Raised, { t: t, level: "floating", radius: t.shape.radius.xl, children: _jsxs(View, { style: {
83
+ height: BAR_HEIGHT, paddingHorizontal: 9, flexDirection: "row", alignItems: "center", gap: 10,
84
+ backgroundColor: surfaceFill(c.surface, t), borderWidth: t.shape.borderWidth, borderColor: c.border,
85
+ borderRadius: t.shape.radius.xl, ...softShadow(t, "floating"),
86
+ }, children: [_jsx(View, { style: { width: BUTTON, height: BUTTON, borderRadius: BUTTON / 2, backgroundColor: c.accent, alignItems: "center", justifyContent: "center", overflow: "hidden" }, children: _jsx(Mark, { t: t, size: BUTTON, glyph: 18, kit: kit, origin: origin, logoUrl: dash.logoUrl ?? undefined, initial: heading.charAt(0).toUpperCase() || "A", cover: true }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { ...body(14, t.font.weights.strong), color: c.text, lineHeight: 17.5 }, children: heading }), _jsx(View, { style: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 1 }, children: status.waiting ? (_jsx(GraineLoader, { moment: status.waiting, size: 14 })) : (_jsxs(_Fragment, { children: [status.meter ? _jsx(Meter, { look: look, color: c.accent }) : null, _jsx(Text, { numberOfLines: 1, style: { ...body(t.font.scale.caption, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.caption, 1.3) }, children: status.text })] })) })] }), canSwitch ? (_jsx(RoundButton, { label: isChat ? "Switch to a call" : "Switch to chat", fill: c.accentSoft, onPress: () => setSurface(isChat ? "voice" : "chat"), children: _jsx(Icon, { name: isChat ? "call" : "chat", t: t, color: c.text, kit: kit }) })) : null, isChat ? null : (_jsx(RoundButton, { label: !api.connected ? `Start talking to ${spoken}` : api.muted ? "Unmute" : "Mute", fill: api.muted ? c.danger : c.accent, disabled: micBlocked, onPress: () => {
87
+ if (conn === "failed") {
88
+ api.retry();
89
+ return;
90
+ }
91
+ if (api.connected)
92
+ api.setMuted(!api.muted);
93
+ else
94
+ api.start();
95
+ }, children: _jsx(Icon, { name: api.muted ? "micOff" : "mic", t: t, color: api.muted ? "#FFFFFF" : c.onAccent, kit: kit }) })), _jsx(RoundButton, { label: api.connected ? "End call" : "Close", fill: api.connected ? c.danger : c.accentSoft, onPress: endCall, children: _jsx(Icon, { name: api.connected ? "end" : "close", t: t, color: api.connected ? "#FFFFFF" : c.textMuted, kit: kit }) })] }) })] }));
96
+ }
97
+ function softShadow(t, level) {
98
+ return t.elevation.hardOffset ? {} : shadowStyle(t.elevation[level]);
99
+ }
100
+ function Raised({ t, radius, children }) {
101
+ const hard = t.elevation.hardOffset;
102
+ if (!hard)
103
+ return _jsx(_Fragment, { children: children });
104
+ return (_jsxs(View, { children: [_jsx(View, { pointerEvents: "none", style: { position: "absolute", left: hard.x, top: hard.y, right: -hard.x, bottom: -hard.y, borderRadius: radius, backgroundColor: t.colors.text } }), children] }));
105
+ }
106
+ function RoundButton({ label, fill, disabled = false, onPress, children, }) {
107
+ return (_jsx(Pressable, { onPress: onPress, disabled: disabled, hitSlop: 8, accessibilityRole: "button", accessibilityLabel: label, style: {
108
+ width: BUTTON, height: BUTTON, borderRadius: BUTTON / 2, alignItems: "center", justifyContent: "center",
109
+ backgroundColor: fill, opacity: disabled ? 0.4 : 1,
110
+ }, children: children }));
111
+ }
112
+ const FALLBACK = {
113
+ mic: "🎙", micOff: "🔇", end: "✕", send: "↑", chat: "💬", call: "📞", chevron: "⌄", close: "✕",
114
+ };
115
+ function Icon({ name, t, color, size = 18, kit, rotate }) {
116
+ if (!kit) {
117
+ return _jsx(Text, { style: { color, fontSize: Math.round(size * 0.8), lineHeight: size, textAlign: "center" }, children: FALLBACK[name] });
118
+ }
119
+ const { Svg, Path } = kit;
120
+ const icon = SDK_ICONS[name];
121
+ const style = t.icons.style;
122
+ const sharp = style === "sharp";
123
+ const filled = style === "filled";
124
+ const stroke = t.icons.stroke;
125
+ const common = { stroke: color, strokeLinecap: sharp ? "square" : "round", strokeLinejoin: sharp ? "miter" : "round" };
126
+ return (_jsx(View, { style: rotate ? { transform: [{ rotate: "180deg" }] } : undefined, children: _jsxs(Svg, { width: size, height: size, viewBox: "0 0 24 24", children: [icon.body ? (_jsx(Path, { d: icon.body, ...common, fill: filled || style === "duotone" ? color : "none", fillOpacity: style === "duotone" ? 0.28 : 1, strokeWidth: filled ? Math.max(1, stroke * 0.75) : stroke })) : null, icon.lines ? _jsx(Path, { d: icon.lines, ...common, fill: "none", strokeWidth: stroke }) : null] }) }));
127
+ }
128
+ function Mark({ t, size, glyph, kit, origin, logoUrl, initial, cover = false, }) {
129
+ const on = t.colors.onAccent;
130
+ const g = t.icons.launcher;
131
+ if (g) {
132
+ if (kit) {
133
+ const { Svg, Path } = kit;
134
+ return (_jsx(Svg, { width: glyph, height: glyph, viewBox: "0 0 24 24", children: _jsx(Path, { d: g.path, fill: g.stroke ? "none" : on, stroke: g.stroke ? on : "none", strokeWidth: g.stroke ? t.icons.stroke : 0, strokeLinecap: "round", strokeLinejoin: "round" }) }));
135
+ }
136
+ return (_jsx(Image, { source: { uri: glyphUrl(origin, g.id, on, glyphPixels(size, PixelRatio.get()), t.icons.stroke) }, style: { width: glyph, height: glyph }, accessibilityIgnoresInvertColors: true }));
137
+ }
138
+ if (logoUrl) {
139
+ return _jsx(Image, { source: { uri: logoUrl }, style: cover ? { width: size, height: size } : { width: glyph, height: glyph }, resizeMode: cover ? "cover" : "contain", accessibilityIgnoresInvertColors: true });
140
+ }
141
+ return _jsx(Text, { style: { color: on, fontSize: Math.round(size * 0.4), fontWeight: "700" }, children: initial });
142
+ }
143
+ function Meter({ look, color }) {
144
+ const still = look.durationMs <= 0;
145
+ const bars = useRef(METER.map(() => new Animated.Value(1))).current;
146
+ useEffect(() => {
147
+ if (still) {
148
+ bars.forEach((b) => b.setValue(1));
149
+ return;
150
+ }
151
+ const period = Math.max(420, look.durationMs * 3.2);
152
+ const ease = Easing.inOut(Easing.ease);
153
+ const runs = bars.map((b, i) => {
154
+ b.setValue(0.35);
155
+ return Animated.sequence([
156
+ Animated.delay(i * 90),
157
+ Animated.loop(Animated.sequence([
158
+ Animated.timing(b, { toValue: 1, duration: period, easing: ease, useNativeDriver: true }),
159
+ Animated.timing(b, { toValue: 0.35, duration: period, easing: ease, useNativeDriver: true }),
160
+ ])),
161
+ ]);
162
+ });
163
+ runs.forEach((r) => r.start());
164
+ return () => runs.forEach((r) => r.stop());
165
+ }, [still, look.durationMs, bars]);
166
+ return (_jsx(View, { style: { flexDirection: "row", alignItems: "flex-end", height: 12, gap: 2 }, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: METER.map((h, i) => (_jsx(Animated.View, { style: {
167
+ width: 2.5, height: Math.round(12 * h * 10) / 10, borderRadius: 1.25, backgroundColor: color,
168
+ transform: [{ scaleY: bars[i] }], transformOrigin: "bottom",
169
+ } }, i))) }));
170
+ }
171
+ function useRise(look) {
172
+ const v = useRef(new Animated.Value(look.durationMs > 0 ? 0 : 1)).current;
173
+ useEffect(() => {
174
+ if (look.durationMs <= 0) {
175
+ v.setValue(1);
176
+ return;
177
+ }
178
+ const a = Animated.timing(v, { toValue: 1, duration: look.durationMs, easing: look.easing, useNativeDriver: true });
179
+ a.start();
180
+ return () => a.stop();
181
+ }, []);
182
+ return { opacity: v, transform: [{ translateY: v.interpolate({ inputRange: [0, 1], outputRange: [8, 0] }) }] };
183
+ }
184
+ function CaptionCard({ look, role, text, live }) {
185
+ const t = look.tokens;
186
+ const c = t.colors;
187
+ const rise = useRise(look);
188
+ const win = windowed(t);
189
+ return (_jsx(Animated.View, { style: rise, children: _jsx(Raised, { t: t, level: "card", radius: t.shape.radius.lg, children: _jsxs(View, { style: {
190
+ backgroundColor: surfaceFill(c.surface, t), borderWidth: t.shape.borderWidth, borderColor: c.border,
191
+ borderRadius: t.shape.radius.lg, paddingHorizontal: 12, paddingVertical: 8, ...softShadow(t, "card"),
192
+ }, children: [win ? (_jsx(View, { style: { marginBottom: 6 }, children: _jsx(TitleBar, { t: t, title: "captions", size: "caption", radius: t.shape.radius.lg, bleed: { horizontal: 12, top: 8 } }) })) : null, role === "customer" ? (_jsx(Text, { style: { ...look.face(t.font.body, t.font.weights.strong), color: c.accent, fontSize: 10.5, letterSpacing: 0.6, textTransform: "uppercase", marginBottom: 2 }, children: "You" })) : null, _jsx(Text, { numberOfLines: 2, style: { ...look.face(t.font.body, t.font.weights.strong), color: c.text, fontSize: 13, lineHeight: 17.5, opacity: live ? 0.75 : 1 }, children: text })] }) }) }));
193
+ }
194
+ function ChatWindow({ look, kit, origin, heading, logoUrl, messages, send, online, lifted, onCollapse, }) {
195
+ const t = look.tokens;
196
+ const c = t.colors;
197
+ const r = t.shape.radius;
198
+ const bw = t.shape.borderWidth;
199
+ const pad = t.spacing.cardPadding;
200
+ const rise = useRise(look);
201
+ const [draft, setDraft] = useState("");
202
+ const scroll = useRef(null);
203
+ useEffect(() => {
204
+ const id = setTimeout(() => scroll.current?.scrollToEnd?.({ animated: true }), 40);
205
+ return () => clearTimeout(id);
206
+ }, [messages.length]);
207
+ const submit = () => {
208
+ const text = draft.trim();
209
+ if (!text)
210
+ return;
211
+ setDraft("");
212
+ send(text);
213
+ };
214
+ const markShape = t.shape.launcher === "pill" ? "circle" : t.shape.launcher;
215
+ const text = (size, weight) => ({
216
+ ...look.face(t.font.body, weight), fontSize: size,
217
+ letterSpacing: letterSpacing(size, t.font.letterSpacing.body),
218
+ });
219
+ const titleSize = Math.min(t.font.scale.title, 16);
220
+ return (_jsx(Animated.View, { style: rise, children: _jsx(Raised, { t: t, level: "floating", radius: r.xl, children: _jsx(View, { style: { backgroundColor: c.background, borderWidth: bw, borderColor: c.border, borderRadius: r.xl, ...softShadow(t, "floating") }, children: _jsxs(View, { style: { borderRadius: Math.max(0, r.xl - bw), overflow: "hidden" }, children: [windowed(t) ? (_jsx(TitleBar, { t: t, title: heading, online: online, radius: r.xl, right: _jsx(Pressable, { onPress: onCollapse, hitSlop: 10, accessibilityRole: "button", accessibilityLabel: "Collapse", children: _jsx(Icon, { name: "chevron", t: t, color: c.textMuted, kit: kit, size: 16, rotate: true }) }) })) : (_jsxs(View, { style: { height: 50, flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 12, backgroundColor: surfaceFill(c.surface, t), borderBottomWidth: bw, borderBottomColor: c.border }, children: [_jsx(View, { style: { width: 30, height: 30, borderRadius: launcherRadius(markShape, 30, t), backgroundColor: c.accent, alignItems: "center", justifyContent: "center", overflow: "hidden", ...(Platform.OS === "ios" && markShape === "squircle" ? { borderCurve: "continuous" } : null) }, children: _jsx(Mark, { t: t, size: 30, glyph: 14, kit: kit, origin: origin, logoUrl: logoUrl, initial: heading.charAt(0).toUpperCase() || "A" }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { ...look.face(t.font.heading, t.font.weights.title), color: c.text, fontSize: titleSize, lineHeight: lineHeight(titleSize, t.font.lineHeight.title), letterSpacing: letterSpacing(titleSize, t.font.letterSpacing.title) }, children: heading }), _jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: 4 }, children: [online ? _jsx(View, { style: { width: 6, height: 6, borderRadius: 3, backgroundColor: c.success } }) : null, _jsx(Text, { style: { ...text(t.font.scale.caption, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.caption, 1.2) }, children: online ? "Online" : "Connecting…" })] })] }), _jsx(Pressable, { onPress: onCollapse, hitSlop: 10, accessibilityRole: "button", accessibilityLabel: "Collapse", children: _jsx(Icon, { name: "chevron", t: t, color: c.textMuted, kit: kit, rotate: true }) })] })), _jsxs(ScrollView, { ref: scroll, style: { maxHeight: lifted ? 150 : 260 }, contentContainerStyle: { padding: 12, gap: t.spacing.gap }, keyboardShouldPersistTaps: "handled", children: [messages.length === 0 ? (_jsx(Text, { style: { ...text(t.font.scale.body, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.body, t.font.lineHeight.body) }, children: online ? `${heading} can see what you’re looking at — ask it anything.` : "Connecting…" })) : null, messages.map((m, i) => {
221
+ const b = bubble(t, m.role !== "agent");
222
+ return (_jsx(View, { style: [{ maxWidth: "84%" }, b.box], children: _jsx(Text, { style: { ...text(t.font.scale.body, t.font.weights.body), color: b.color, lineHeight: lineHeight(t.font.scale.body, t.font.lineHeight.body), opacity: m.live ? 0.75 : 1 }, children: m.text }) }, i));
223
+ })] }), _jsxs(View, { style: { height: 58, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 10, backgroundColor: c.background }, children: [_jsx(TextInput, { value: draft, onChangeText: setDraft, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false, placeholder: "Message\u2026", placeholderTextColor: c.textMuted, style: {
224
+ flex: 1, height: 40, paddingHorizontal: 14, backgroundColor: c.surfaceRaised, borderWidth: bw, borderColor: c.border,
225
+ borderRadius: r.xl, color: c.text, ...look.face(t.font.body, t.font.weights.body), fontSize: t.font.scale.body - 1,
226
+ } }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityRole: "button", accessibilityLabel: "Send", style: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center", backgroundColor: c.accent, opacity: draft.trim() ? 1 : 0.4 }, children: _jsx(Icon, { name: "send", t: t, color: c.onAccent, kit: kit }) })] })] }) }) }) }));
227
+ }
228
+ function bubble(t, user) {
229
+ const c = t.colors;
230
+ const r = t.shape.radius;
231
+ const pad = t.spacing.cardPadding;
232
+ const shape = {
233
+ alignSelf: user ? "flex-end" : "flex-start",
234
+ paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.75), borderRadius: r.lg,
235
+ ...(user ? { borderBottomRightRadius: r.sm } : { borderBottomLeftRadius: r.sm }),
236
+ };
237
+ if (t.bubbles === "outline") {
238
+ return { box: { ...shape, backgroundColor: "transparent", borderWidth: t.shape.borderWidth, borderColor: user ? c.accent : c.border }, color: c.text };
239
+ }
240
+ if (t.bubbles === "minimal") {
241
+ return user
242
+ ? { box: { ...shape, backgroundColor: c.accentSoft }, color: c.text }
243
+ : { box: { alignSelf: "flex-start", paddingHorizontal: 2, paddingVertical: 2, backgroundColor: "transparent" }, color: c.text };
244
+ }
245
+ return user
246
+ ? { box: { ...shape, backgroundColor: c.userBubble }, color: c.userBubbleText }
247
+ : { box: { ...shape, backgroundColor: c.agentBubble }, color: c.agentBubbleText };
248
+ }
249
+ class WidgetGuard extends React.Component {
250
+ constructor() {
251
+ super(...arguments);
252
+ this.state = { failed: false };
253
+ }
254
+ static getDerivedStateFromError() { return { failed: true }; }
255
+ componentDidCatch(error) { console.error("[graine] the agent's component failed to render", error); }
256
+ render() { return this.state.failed ? null : this.props.children; }
257
+ }
@@ -59,6 +59,8 @@ export interface GraineVoiceApi {
59
59
  quality: ConnectionQuality;
60
60
  network: "online" | "offline" | "unknown";
61
61
  reconnecting: boolean;
62
+ error: string | null;
63
+ micDenied: boolean;
62
64
  }
63
65
  export declare const CAPTION_HISTORY = 12;
64
66
  export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active: activeProp, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -20,6 +20,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
20
20
  const [connected, setConnected] = useState(false);
21
21
  const [connecting, setConnecting] = useState(false);
22
22
  const [muted, setMutedState] = useState(false);
23
+ const [fault, setFault] = useState(null);
24
+ const [micDenied, setMicDenied] = useState(false);
23
25
  const [ready, setReady] = useState(false);
24
26
  const readyRef = useRef(false);
25
27
  readyRef.current = ready;
@@ -214,6 +216,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
214
216
  }
215
217
  if (!(await ensureMic())) {
216
218
  setConnecting(false);
219
+ setMicDenied(true);
217
220
  onMicDenied?.("NotAllowedError");
218
221
  client.emit("mic_denied", { reason: "NotAllowedError" });
219
222
  return;
@@ -260,6 +263,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
260
263
  message: msg.message,
261
264
  });
262
265
  if (msg.reason === "not_configured" && msg.message) {
266
+ setFault(String(msg.message));
263
267
  onError?.(String(msg.message));
264
268
  }
265
269
  return;
@@ -346,6 +350,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
346
350
  return;
347
351
  case "graine:mic-denied":
348
352
  client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
353
+ setMicDenied(true);
349
354
  onMicDenied?.(String(msg.reason || "denied"));
350
355
  return;
351
356
  case "graine:widget": {
@@ -387,6 +392,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
387
392
  clearCaptions: useCallback(() => setCaptions([]), []),
388
393
  start: useCallback(() => {
389
394
  setConnecting(true);
395
+ setMicDenied(false);
396
+ setFault(null);
390
397
  setCaptions([]);
391
398
  setAwaitingAgent(false);
392
399
  client.clearPendingWidget();
@@ -402,6 +409,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
402
409
  quality,
403
410
  network,
404
411
  reconnecting,
412
+ error: fault,
413
+ micDenied,
405
414
  setMuted: useCallback((m) => {
406
415
  setMutedState(m);
407
416
  post({ type: "graine:set-muted", muted: m });
@@ -449,6 +458,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
449
458
  setConnected(false);
450
459
  setConnecting(false);
451
460
  setCaptions([]);
461
+ setMicDenied(false);
462
+ setFault(null);
452
463
  client.clearPendingWidget();
453
464
  onCallState?.({ connected: false, connecting: false });
454
465
  }, [active]);
@@ -7,6 +7,7 @@ import { resolveNativeAppearance, resolveWidgetPalette } from "./appearance.js";
7
7
  import { useDesign } from "./use-design.js";
8
8
  import { alpha, letterSpacing, lineHeight, onAccentFor, shadowStyle, softFor } from "./design-math.js";
9
9
  import { DEFAULT_DESIGN_PRESET, designPreset } from "../theme.js";
10
+ import { TitleBar, windowed } from "./window-chrome.js";
10
11
  import { PLATFORM_GRAMMAR, manySummary, resolveLoader, resolvePlatform, resolveWidgetLook, widgetSurface } from "../protocol.js";
11
12
  function tint(hex, alpha) {
12
13
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
@@ -19,6 +20,7 @@ const CLASSIC_SCALE = designPreset(DEFAULT_DESIGN_PRESET).font.scale;
19
20
  function classicSkin(theme, accent, radius) {
20
21
  return {
21
22
  classic: true,
23
+ window: null,
22
24
  size: (today) => today,
23
25
  weight: (today) => today,
24
26
  family: () => undefined,
@@ -50,6 +52,7 @@ function designSkin(look, theme, accent, radius, ownRadius, surfaceOverride) {
50
52
  const density = t.spacing.cardPadding / 14;
51
53
  return {
52
54
  classic: false,
55
+ window: windowed(t) ? t : null,
53
56
  size: (today, role, exact) => (exact ? f.scale[role] : Math.round(((today * f.scale[role]) / CLASSIC_SCALE[role]) * 10) / 10),
54
57
  weight: (_today, which) => String(f.weights[which]),
55
58
  family: (heading) => look.font(heading ? f.heading : f.body),
@@ -411,9 +414,11 @@ export function GraineWidget(props) {
411
414
  paddingHorizontal: 16, borderRadius: 999, backgroundColor: tint(theme.border, 0.4),
412
415
  }, children: _jsx(Text, { style: { color: theme.text, fontSize: k.size(13.5, "body"), fontWeight: k.weight("700", "strong"), fontFamily: k.family() }, children: "None of these" }) })] })] }) })] }));
413
416
  }
417
+ const cardPad = k.pad(14, 12);
418
+ const cardFill = k.window && !props.theme?.surface ? k.window.colors.surface : theme.surface;
414
419
  return (_jsxs(_Fragment, { children: [chosen, _jsxs(Raised, { k: k, style: {
415
- backgroundColor: theme.surface, borderRadius: radius, borderWidth: k.borderWidth, borderColor: theme.border,
416
- ...k.pad(14, 12), marginBottom: 8,
420
+ backgroundColor: cardFill, borderRadius: radius, borderWidth: k.borderWidth, borderColor: theme.border,
421
+ ...cardPad, marginBottom: 8,
417
422
  ...(k.classic ? k.depth.shadow : null),
418
- }, children: [title ? (_jsx(Text, { style: { color: theme.text, ...titleType(14.5), marginBottom: 2 }, children: title })) : null, prompt ? (_jsx(Text, { style: { color: theme.muted, fontSize: k.size(12.5, "caption", true), lineHeight: k.line(17, k.size(12.5, "caption", true)), fontFamily: k.family(), marginBottom: 8 }, children: prompt })) : _jsx(View, { style: { height: 6 } }), body, _jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Dismiss", hitSlop: 8, style: { alignSelf: "flex-end", marginTop: 10, minHeight: 44, justifyContent: "center" }, children: _jsx(Text, { style: { color: theme.muted, fontSize: k.size(12.5, "caption", true), fontWeight: k.weight("600", "body"), fontFamily: k.family() }, children: "Dismiss" }) })] })] }));
423
+ }, children: [k.window ? (_jsx(View, { style: { marginBottom: 10 }, children: _jsx(TitleBar, { t: k.window, title: title, radius: radius, bleed: { horizontal: cardPad.paddingHorizontal, top: cardPad.paddingVertical } }) })) : title ? (_jsx(Text, { style: { color: theme.text, ...titleType(14.5), marginBottom: 2 }, children: title })) : null, prompt ? (_jsx(Text, { style: { color: theme.muted, fontSize: k.size(12.5, "caption", true), lineHeight: k.line(17, k.size(12.5, "caption", true)), fontFamily: k.family(), marginBottom: 8 }, children: prompt })) : _jsx(View, { style: { height: 6 } }), body, _jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Dismiss", hitSlop: 8, style: { alignSelf: "flex-end", marginTop: 10, minHeight: 44, justifyContent: "center" }, children: _jsx(Text, { style: { color: theme.muted, fontSize: k.size(12.5, "caption", true), fontWeight: k.weight("600", "body"), fontFamily: k.family() }, children: "Dismiss" }) })] })] }));
419
424
  }
@@ -0,0 +1,30 @@
1
+ import React from "react";
2
+ import type { ThemeTokens } from "../theme.js";
3
+ export declare const WINDOW_CHROME: {
4
+ readonly height: {
5
+ readonly window: 32;
6
+ readonly caption: 24;
7
+ };
8
+ readonly dot: {
9
+ readonly window: 10;
10
+ readonly caption: 8;
11
+ };
12
+ readonly gap: 6;
13
+ readonly inset: 12;
14
+ readonly lights: readonly ["#FF5F57", "#FEBC2E", "#28C840"];
15
+ readonly online: 6;
16
+ };
17
+ export declare const windowed: (t: ThemeTokens) => boolean;
18
+ export declare const MONO: string;
19
+ export declare function TitleBar({ t, title, size, online, right, radius, bleed, }: {
20
+ t: ThemeTokens;
21
+ title: string;
22
+ size?: "window" | "caption";
23
+ online?: boolean;
24
+ right?: React.ReactNode;
25
+ radius?: number;
26
+ bleed?: {
27
+ horizontal: number;
28
+ top: number;
29
+ };
30
+ }): React.JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Platform, Text, View } from "react-native";
3
+ export const WINDOW_CHROME = {
4
+ height: { window: 32, caption: 24 },
5
+ dot: { window: 10, caption: 8 },
6
+ gap: 6,
7
+ inset: 12,
8
+ lights: ["#FF5F57", "#FEBC2E", "#28C840"],
9
+ online: 6,
10
+ };
11
+ export const windowed = (t) => t.chrome === "window";
12
+ export const MONO = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" });
13
+ export function TitleBar({ t, title, size = "window", online = false, right, radius = 0, bleed, }) {
14
+ const c = t.colors;
15
+ const h = WINDOW_CHROME.height[size];
16
+ const d = WINDOW_CHROME.dot[size];
17
+ const inner = Math.max(0, radius - t.shape.borderWidth);
18
+ return (_jsxs(View, { accessibilityRole: "header", style: {
19
+ height: h,
20
+ flexDirection: "row",
21
+ alignItems: "center",
22
+ paddingHorizontal: WINDOW_CHROME.inset,
23
+ backgroundColor: c.surfaceRaised,
24
+ borderBottomWidth: t.shape.borderWidth,
25
+ borderBottomColor: c.border,
26
+ borderTopLeftRadius: inner,
27
+ borderTopRightRadius: inner,
28
+ ...(bleed ? { marginHorizontal: -bleed.horizontal, marginTop: -bleed.top } : null),
29
+ }, children: [_jsx(View, { style: { flexDirection: "row", gap: WINDOW_CHROME.gap }, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: WINDOW_CHROME.lights.map((color) => (_jsx(View, { style: { width: d, height: d, borderRadius: d / 2, backgroundColor: color } }, color))) }), _jsxs(View, { pointerEvents: "none", style: { position: "absolute", left: 0, right: 0, top: 0, bottom: 0, flexDirection: "row", alignItems: "center", justifyContent: "center", paddingHorizontal: 64 }, children: [online ? (_jsx(View, { style: { width: WINDOW_CHROME.online, height: WINDOW_CHROME.online, borderRadius: WINDOW_CHROME.online / 2, backgroundColor: c.success, marginRight: 6 } })) : null, _jsx(Text, { numberOfLines: 1, style: { fontFamily: MONO, fontSize: t.font.scale.caption, color: c.textMuted }, children: title.toLowerCase() })] }), right ? _jsx(View, { style: { marginLeft: "auto" }, children: right }) : null] }));
30
+ }
package/dist/theme.d.ts CHANGED
@@ -8,6 +8,7 @@ export type LauncherShape = "circle" | "squircle" | "rounded-square" | "square"
8
8
  export type IconStyle = "outline" | "filled" | "duotone" | "sharp";
9
9
  export type MotionEasing = "standard" | "emphasized" | "spring" | "linear" | "none";
10
10
  export type DesignFamily = "graine" | "native" | "modern" | "enterprise" | "expressive";
11
+ export type DesignChrome = "none" | "window";
11
12
  export interface DesignConfig {
12
13
  preset: string;
13
14
  accent?: string;
@@ -24,6 +25,7 @@ export interface DesignConfig {
24
25
  launcherIcon?: string;
25
26
  launcherShape?: LauncherShape;
26
27
  iconStyle?: IconStyle;
28
+ chrome?: DesignChrome;
27
29
  }
28
30
  export interface ThemeColors {
29
31
  background: string;
@@ -121,6 +123,7 @@ export interface ThemeTokens {
121
123
  surfaceOpacity: number;
122
124
  } | null;
123
125
  bubbles: DesignBubbles;
126
+ chrome: DesignChrome;
124
127
  }
125
128
  export interface ResolvedDesign {
126
129
  config: DesignConfig;
@@ -145,6 +148,7 @@ export interface DesignPreset {
145
148
  };
146
149
  glass: ThemeTokens["glass"];
147
150
  bubbles: DesignBubbles;
151
+ chrome?: DesignChrome;
148
152
  colors: {
149
153
  light: Omit<ThemeColors, "accentSoft">;
150
154
  dark: Omit<ThemeColors, "accentSoft">;
@@ -158,6 +162,7 @@ export declare const DESIGN_NEUTRALS: ReadonlyArray<DesignNeutral>;
158
162
  export declare const DESIGN_BUBBLES: ReadonlyArray<DesignBubbles>;
159
163
  export declare const LAUNCHER_SHAPES: ReadonlyArray<LauncherShape>;
160
164
  export declare const ICON_STYLES: ReadonlyArray<IconStyle>;
165
+ export declare const DESIGN_CHROMES: ReadonlyArray<DesignChrome>;
161
166
  export declare function normaliseHex(value: unknown): string | null;
162
167
  export declare function mixHex(a: string, b: string, t: number): string;
163
168
  export declare function luminanceOf(hex: string): number;
@@ -170,6 +175,11 @@ export declare const LAUNCHER_ICONS: ReadonlyArray<{
170
175
  path: string;
171
176
  stroke: boolean;
172
177
  }>;
178
+ export type SdkIconName = "mic" | "micOff" | "end" | "send" | "chat" | "call" | "chevron" | "close";
179
+ export declare const SDK_ICONS: Readonly<Record<SdkIconName, {
180
+ body?: string;
181
+ lines?: string;
182
+ }>>;
173
183
  export declare const DESIGN_FONTS: ReadonlyArray<{
174
184
  family: string;
175
185
  category: "sans" | "serif" | "mono" | "display";
package/dist/theme.js CHANGED
@@ -6,6 +6,7 @@ export const DESIGN_NEUTRALS = ["preset", "cool", "neutral", "warm"];
6
6
  export const DESIGN_BUBBLES = ["filled", "outline", "minimal"];
7
7
  export const LAUNCHER_SHAPES = ["circle", "squircle", "rounded-square", "square", "pill"];
8
8
  export const ICON_STYLES = ["outline", "filled", "duotone", "sharp"];
9
+ export const DESIGN_CHROMES = ["none", "window"];
9
10
  const HEX6 = /^#?([0-9a-f]{6})$/i;
10
11
  const HEX3 = /^#?([0-9a-f]{3})$/i;
11
12
  export function normaliseHex(value) {
@@ -75,9 +76,20 @@ export const LAUNCHER_ICONS = [
75
76
  { id: "star", label: "Star", stroke: true, path: "M11.525 2.295a0.53 0.53 0 0 1 0.95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166 0.756a0.53 0.53 0 0 1 0.294 0.904l-3.736 3.638a2.123 2.123 0 0 0 -0.611 1.878l0.882 5.14a0.53 0.53 0 0 1 -0.771 0.56l-4.618 -2.428a2.122 2.122 0 0 0 -1.973 0L6.396 21.01a0.53 0.53 0 0 1 -0.77 -0.56l0.881 -5.139a2.122 2.122 0 0 0 -0.611 -1.879L2.16 9.795a0.53 0.53 0 0 1 0.294 -0.906l5.165 -0.755a2.122 2.122 0 0 0 1.597 -1.16z" },
76
77
  { id: "orbit", label: "Orbit", stroke: true, path: "M20.341 6.484A10 10 0 0 1 10.266 21.85M3.659 17.516A10 10 0 0 1 13.74 2.152M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0zM17 5a2 2 0 1 0 4 0a2 2 0 1 0 -4 0zM3 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0z" },
77
78
  { id: "sparkle-filled", label: "Sparkle (filled)", stroke: false, path: "M19.5 13.5a1.479 1.479 0 0 1 -0.977 1.401L13.688 16.688l-1.781 4.839a1.492 1.492 0 0 1 -2.801 0L7.312 16.688l-4.839 -1.781a1.492 1.492 0 0 1 0 -2.801L7.312 10.312l1.781 -4.839a1.492 1.492 0 0 1 2.801 0L13.688 10.312l4.839 1.781A1.479 1.479 0 0 1 19.5 13.5ZM14.25 4.5h1.5V6a0.75 0.75 0 0 0 1.5 0V4.5h1.5a0.75 0.75 0 0 0 0 -1.5H17.25V1.5a0.75 0.75 0 0 0 -1.5 0V3H14.25a0.75 0.75 0 0 0 0 1.5Zm8.25 3h-0.75V6.75a0.75 0.75 0 0 0 -1.5 0v0.75h-0.75a0.75 0.75 0 0 0 0 1.5h0.75v0.75a0.75 0.75 0 0 0 1.5 0V9h0.75a0.75 0.75 0 0 0 0 -1.5Z" },
79
+ { id: "buddy", label: "Buddy", stroke: true, path: "M12 3c4.97 0 8 3.03 8 8.5S16.97 21 12 21 4 17.97 4 11.5 7.03 3 12 3z M9.25 10.25v1.75 M14.75 10.25v1.75 M9.5 15.1c1.4 1.15 3.6 1.15 5 0" },
78
80
  { id: "chat-dots-filled", label: "Chat dots (filled)", stroke: false, path: "M12 2.25A9.75 9.75 0 0 0 3.392 16.582L2.328 19.775a1.5 1.5 0 0 0 1.897 1.897l3.192 -1.064A9.75 9.75 0 1 0 12 2.25ZM7.875 13.125a1.125 1.125 0 1 1 1.125 -1.125A1.125 1.125 0 0 1 7.875 13.125Zm4.125 0a1.125 1.125 0 1 1 1.125 -1.125A1.125 1.125 0 0 1 12 13.125Zm4.125 0a1.125 1.125 0 1 1 1.125 -1.125A1.125 1.125 0 0 1 16.125 13.125Z" },
79
81
  { id: "graphic-eq", label: "Voice equalizer (filled)", stroke: false, path: "M7 17v-10q0 -0.425 0.288 -0.713T8 6q0.425 0 0.713 0.288T9 7v10q0 0.425 -0.288 0.713T8 18q-0.425 0 -0.713 -0.288T7 17Zm4 4v-18q0 -0.425 0.288 -0.713T12 2q0.425 0 0.713 0.288T13 3v18q0 0.425 -0.288 0.713T12 22q-0.425 0 -0.713 -0.288T11 21ZM3 13v-2q0 -0.425 0.288 -0.713T4 10q0.425 0 0.713 0.288T5 11v2q0 0.425 -0.288 0.713T4 14q-0.425 0 -0.713 -0.288T3 13Zm12 4v-10q0 -0.425 0.288 -0.713T16 6q0.425 0 0.713 0.288T17 7v10q0 0.425 -0.288 0.713T16 18q-0.425 0 -0.713 -0.288T15 17Zm4 -4v-2q0 -0.425 0.288 -0.713T20 10q0.425 0 0.713 0.288T21 11v2q0 0.425 -0.288 0.713T20 14q-0.425 0 -0.713 -0.288T19 13Z" },
80
82
  ];
83
+ export const SDK_ICONS = {
84
+ mic: { body: "M12 2.5a3.5 3.5 0 0 0-3.5 3.5v6a3.5 3.5 0 0 0 7 0V6A3.5 3.5 0 0 0 12 2.5z", lines: "M18.5 11.5a6.5 6.5 0 0 1-13 0M12 18v3.5" },
85
+ micOff: { body: "M12 2.5a3.5 3.5 0 0 0-3.5 3.5v6a3.5 3.5 0 0 0 7 0V6A3.5 3.5 0 0 0 12 2.5z", lines: "M18.5 11.5a6.5 6.5 0 0 1-13 0M12 18v3.5M4 4l16 16" },
86
+ end: { body: "M4.2 14.6c4.6-4.1 11-4.1 15.6 0 .6.5.6 1.4.1 2l-1.3 1.4c-.4.5-1.1.6-1.7.3l-2.2-1.2c-.5-.3-.8-.8-.7-1.4l.2-1.3a11 11 0 0 0-4.4 0l.2 1.3c.1.6-.2 1.1-.7 1.4l-2.2 1.2c-.6.3-1.3.2-1.7-.3L4.1 16.6c-.5-.6-.5-1.5.1-2z" },
87
+ send: { body: "M4 12 20 4l-4 16-4.5-6.5L4 12z", lines: "M11.5 13.5 20 4" },
88
+ chat: { body: "M20 12a8 8 0 0 1-11.6 7.1L4 20l1-4.2A8 8 0 1 1 20 12z" },
89
+ call: { lines: "M13.832 16.568a1 1 0 0 0 1.213 -0.303l0.355 -0.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2A18 18 0 0 1 2 4a2 2 0 0 1 2 -2h3a2 2 0 0 1 2 2v3a2 2 0 0 1 -0.8 1.6l-0.468 0.351a1 1 0 0 0 -0.292 1.233a14 14 0 0 0 6.392 6.384" },
90
+ chevron: { lines: "M6 15l6-6 6 6" },
91
+ close: { lines: "M6 6l12 12M18 6 6 18" },
92
+ };
81
93
  export const DESIGN_FONTS = [
82
94
  { family: "system", category: "sans", scripts: ["latin", "devanagari", "bengali", "tamil", "telugu", "kannada", "malayalam", "gujarati", "gurmukhi", "oriya"] },
83
95
  { family: "Inter", category: "sans", scripts: ["latin", "latin-ext", "cyrillic", "cyrillic-ext", "greek", "greek-ext", "vietnamese"] },
@@ -1061,6 +1073,121 @@ const P_SOFT_PLAYFUL = {
1061
1073
  }
1062
1074
  }
1063
1075
  };
1076
+ const P_DESKTOP_BUDDY = {
1077
+ "id": "desktop-buddy",
1078
+ "name": "Desktop Buddy",
1079
+ "tagline": "A friendly little computer — clean white windows, a blue pointer and a bit of bounce.",
1080
+ "family": "modern",
1081
+ "inspiredBy": [
1082
+ "heyclicky.com — the playful desktop landing: Inter, glossy pills, a soft 0 10px 24px shadow, #0f7fff"
1083
+ ],
1084
+ "font": {
1085
+ "body": "Inter",
1086
+ "heading": "Inter",
1087
+ "scale": {
1088
+ "caption": 12,
1089
+ "body": 15,
1090
+ "title": 18,
1091
+ "display": 28
1092
+ },
1093
+ "weights": {
1094
+ "body": 400,
1095
+ "strong": 600,
1096
+ "title": 600
1097
+ },
1098
+ "letterSpacing": {
1099
+ "body": 0,
1100
+ "title": -0.01
1101
+ },
1102
+ "lineHeight": {
1103
+ "body": 1.5,
1104
+ "title": 1.25
1105
+ }
1106
+ },
1107
+ "shape": {
1108
+ "radius": {
1109
+ "sm": 8,
1110
+ "md": 12,
1111
+ "lg": 16,
1112
+ "xl": 24
1113
+ },
1114
+ "borderWidth": 1,
1115
+ "launcher": "circle"
1116
+ },
1117
+ "spacing": {
1118
+ "unit": 4,
1119
+ "cardPadding": 16,
1120
+ "gap": 10
1121
+ },
1122
+ "elevation": {
1123
+ "card": {
1124
+ "y": 10,
1125
+ "blur": 24,
1126
+ "spread": 0,
1127
+ "opacity": 0.12,
1128
+ "color": "#000000"
1129
+ },
1130
+ "floating": {
1131
+ "y": 14,
1132
+ "blur": 32,
1133
+ "spread": 0,
1134
+ "opacity": 0.16,
1135
+ "color": "#000000"
1136
+ },
1137
+ "hardOffset": null
1138
+ },
1139
+ "motion": {
1140
+ "durationMs": 320,
1141
+ "easing": "spring",
1142
+ "bounce": 0.25
1143
+ },
1144
+ "icons": {
1145
+ "style": "outline",
1146
+ "stroke": 2,
1147
+ "launcher": "buddy"
1148
+ },
1149
+ "glass": null,
1150
+ "bubbles": "filled",
1151
+ "chrome": "window",
1152
+ "colors": {
1153
+ "light": {
1154
+ "background": "#FBFBFB",
1155
+ "surface": "#FFFFFF",
1156
+ "surfaceRaised": "#F5F5F5",
1157
+ "text": "#0A0A0A",
1158
+ "textMuted": "#626262",
1159
+ "border": "#E7E7E7",
1160
+ "accent": "#0F7FFF",
1161
+ "onAccent": "#FFFFFF",
1162
+ "success": "#1F9D55",
1163
+ "warning": "#FF7800",
1164
+ "danger": "#ED1B26",
1165
+ "userBubble": "#0F7FFF",
1166
+ "userBubbleText": "#FFFFFF",
1167
+ "agentBubble": "#F0F0F0",
1168
+ "agentBubbleText": "#0A0A0A",
1169
+ "scrim": "#000000"
1170
+ },
1171
+ "dark": {
1172
+ "background": "#0B0B0C",
1173
+ "surface": "#161618",
1174
+ "surfaceRaised": "#1F1F22",
1175
+ "text": "#F5F5F5",
1176
+ "textMuted": "#A1A1A6",
1177
+ "border": "#2C2C30",
1178
+ "accent": "#3D8BFF",
1179
+ "onAccent": "#FFFFFF",
1180
+ "success": "#34C759",
1181
+ "warning": "#FF9F0A",
1182
+ "danger": "#FF453A",
1183
+ "userBubble": "#3D8BFF",
1184
+ "userBubbleText": "#FFFFFF",
1185
+ "agentBubble": "#1F1F22",
1186
+ "agentBubbleText": "#F5F5F5",
1187
+ "scrim": "#000000"
1188
+ }
1189
+ }
1190
+ };
1064
1191
  const P_NEO_BRUTALISM = {
1065
1192
  "id": "neo-brutalism",
1066
1193
  "name": "Neo-Brutalist",
@@ -1751,7 +1878,7 @@ const P_HIGH_CONTRAST = {
1751
1878
  }
1752
1879
  }
1753
1880
  };
1754
- export const DESIGN_PRESETS = [GRAINE_CLASSIC, P_CHAT_NEUTRAL, P_MATERIAL_3, P_IOS_NATIVE, P_MINIMAL_MONO, P_FLUENT_2, P_CARBON, P_LIQUID_GLASS, P_SOFT_PLAYFUL, P_NEO_BRUTALISM, P_EDITORIAL, P_LUXURY, P_RETRO_TERMINAL, P_WARM_EARTHY, P_HIGH_CONTRAST];
1881
+ export const DESIGN_PRESETS = [GRAINE_CLASSIC, P_CHAT_NEUTRAL, P_MATERIAL_3, P_IOS_NATIVE, P_MINIMAL_MONO, P_FLUENT_2, P_CARBON, P_LIQUID_GLASS, P_SOFT_PLAYFUL, P_DESKTOP_BUDDY, P_NEO_BRUTALISM, P_EDITORIAL, P_LUXURY, P_RETRO_TERMINAL, P_WARM_EARTHY, P_HIGH_CONTRAST];
1755
1882
  export const DEFAULT_DESIGN_PRESET = "graine-classic";
1756
1883
  export function designPreset(id) {
1757
1884
  const want = String(id ?? "").trim().toLowerCase();
@@ -1812,6 +1939,9 @@ export function normaliseDesignConfig(raw) {
1812
1939
  const iconStyle = pick(r.iconStyle, ICON_STYLES);
1813
1940
  if (iconStyle)
1814
1941
  out.iconStyle = iconStyle;
1942
+ const chrome = pick(r.chrome, DESIGN_CHROMES);
1943
+ if (chrome)
1944
+ out.chrome = chrome;
1815
1945
  return out;
1816
1946
  }
1817
1947
  export function designFromAppearance(appearance) {
@@ -1949,6 +2079,7 @@ export function resolveTokens(configInput, scheme) {
1949
2079
  },
1950
2080
  glass,
1951
2081
  bubbles,
2082
+ chrome: config.chrome ?? p.chrome ?? "none",
1952
2083
  };
1953
2084
  }
1954
2085
  export function resolveDesign(configInput) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",