@graineai/inapp-react-native 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/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,12 +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, 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";
38
+ export { DESIGN_PRESETS, DEFAULT_DESIGN_PRESET, DESIGN_FONTS, LAUNCHER_ICONS, SDK_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, type SdkIconName, } from "./theme.js";
39
+ export { sessionDesign } from "./design.js";
package/dist/index.js CHANGED
@@ -14,12 +14,17 @@ 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";
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";
29
+ export { DESIGN_PRESETS, DEFAULT_DESIGN_PRESET, DESIGN_FONTS, LAUNCHER_ICONS, SDK_ICONS, designPreset, normaliseDesignConfig, designFromAppearance, resolveTokens, resolveDesign, pickTokens, contrastRatio, readableOn, glyphUrl, } from "./theme.js";
30
+ 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
+ }
@@ -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;
@@ -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,56 @@ 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
+ 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
+ }
92
+ function DesignedLauncher({ look, size, accent, name, label, logoUrl, origin, place, onPress, }) {
93
+ const t = look.tokens;
94
+ const [failed, setFailed] = useState([]);
95
+ const fail = (uri) => setFailed((cur) => (cur.includes(uri) ? cur : [...cur, uri]));
96
+ const shown = useRef(new Animated.Value(look.durationMs > 0 ? 0 : 1)).current;
97
+ useEffect(() => {
98
+ if (look.durationMs <= 0) {
99
+ shown.setValue(1);
100
+ return;
101
+ }
102
+ const a = Animated.timing(shown, { toValue: 1, duration: look.durationMs, easing: look.easing, useNativeDriver: true });
103
+ a.start();
104
+ return () => a.stop();
105
+ }, []);
106
+ const onAccent = onAccentFor(accent, t);
107
+ const pill = t.shape.launcher === "pill";
108
+ const radius = launcherRadius(t.shape.launcher, size, t);
109
+ const g = Math.round(size * 0.46 * 10) / 10;
110
+ const glyphUri = t.icons.launcher
111
+ ? glyphUrl(origin, t.icons.launcher.id, onAccent, glyphPixels(size, PixelRatio.get()), t.icons.stroke)
112
+ : null;
113
+ const glyph = glyphUri && !failed.includes(glyphUri) ? glyphUri : null;
114
+ const logo = logoUrl && !failed.includes(logoUrl) ? logoUrl : null;
115
+ const hard = t.elevation.hardOffset;
116
+ const corners = {
117
+ borderRadius: radius,
118
+ ...(t.shape.launcher === "squircle" && Platform.OS === "ios" ? { borderCurve: "continuous" } : null),
119
+ };
120
+ 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: {
121
+ color: onAccent, fontSize: Math.round(size * 0.46), fontWeight: String(t.font.weights.title),
122
+ fontFamily: look.font(t.font.heading),
123
+ }, children: name.trim().charAt(0).toUpperCase() || "\u2022" }));
124
+ 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 }) => ({
125
+ ...corners,
126
+ height: size, minWidth: size, width: pill ? undefined : size,
127
+ flexDirection: "row", alignItems: "center", justifyContent: "center",
128
+ gap: pill ? 8 : 0, paddingHorizontal: pill ? Math.round(size * 0.32) : 0,
129
+ backgroundColor: accent,
130
+ opacity: pressed ? 0.85 : 1,
131
+ ...(hard ? { borderWidth: t.shape.borderWidth, borderColor: t.colors.text } : shadowStyle(t.elevation.floating)),
132
+ }), children: [mark, pill ? (_jsx(Text, { numberOfLines: 1, style: {
133
+ color: onAccent, fontSize: t.font.scale.body, fontWeight: String(t.font.weights.strong),
134
+ fontFamily: look.font(t.font.body),
135
+ }, children: name })) : null] })] }));
78
136
  }
@@ -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
+ }
@@ -17,6 +17,9 @@ 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
+ import { type FontLoader, type LoadedFaces } from "./fonts.js";
22
+ export { useGraineTheme } from "./use-design.js";
20
23
  import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
21
24
  interface GraineContextValue {
22
25
  client: GraineInAppClient;
@@ -36,6 +39,10 @@ interface GraineContextValue {
36
39
  launcherInset: LauncherInset;
37
40
  currentScreen: string | null;
38
41
  appearance: Record<string, any> | null;
42
+ design: ResolvedDesign;
43
+ fonts: Record<string, string> | null;
44
+ faces: LoadedFaces;
45
+ svg: SvgKit | null;
39
46
  sessionReady: boolean;
40
47
  onEvent: (fn: (e: GraineEvent) => void) => () => void;
41
48
  track: (name: string, data?: Record<string, unknown>) => void;
@@ -138,8 +145,15 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
138
145
  includeScreens?: string[];
139
146
  launcherDelayMs?: number;
140
147
  visibility?: LauncherVisibility;
148
+ fonts?: Record<string, string>;
149
+ loadFont?: FontLoader;
150
+ svg?: SvgKit;
141
151
  }
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;
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;
143
157
  export declare function useGraineAgent(): GraineContextValue;
144
158
  export declare function useGraineSurface(): {
145
159
  surface: AgentSurface;