@ateam-ai-mobile/plugin-runtime 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.
- package/dist/index.d.mts +1129 -0
- package/dist/index.d.ts +1129 -0
- package/dist/index.js +2388 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2369 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/api/plugins.ts +257 -0
- package/src/bridge/native.ts +266 -0
- package/src/bridge/types.ts +268 -0
- package/src/bundles/bundleManager.ts +396 -0
- package/src/bundles/storage.ts +68 -0
- package/src/hooks/usePlugin.ts +185 -0
- package/src/hooks/useRemotePlugins.ts +97 -0
- package/src/host.ts +117 -0
- package/src/index.ts +79 -0
- package/src/registry/SurfaceDispatcher.tsx +66 -0
- package/src/registry/store.ts +131 -0
- package/src/registry/types.ts +60 -0
- package/src/sdk/components.ts +370 -0
- package/src/sdk/index.ts +76 -0
- package/src/sdk/primitives.ts +22 -0
- package/src/sdk/registration.ts +52 -0
- package/src/sdk/types.ts +315 -0
- package/src/sdk/useApi.ts +78 -0
- package/src/sdk/usePluginTheme.ts +86 -0
- package/src/surfaces/AdaOrb.tsx +318 -0
- package/src/surfaces/SurfaceCard.tsx +137 -0
- package/src/surfaces/SurfaceDrawer.tsx +216 -0
- package/src/surfaces/SurfaceFullscreen.tsx +207 -0
- package/src/utils/sha256.ts +85 -0
package/src/sdk/types.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adas/plugin-sdk — Type definitions
|
|
3
|
+
*
|
|
4
|
+
* This is the developer contract for native mobile plugins.
|
|
5
|
+
* Both UI and service plugins use these interfaces.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Plugin Context ──────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface PluginContext {
|
|
11
|
+
tenant: string;
|
|
12
|
+
pluginId: string;
|
|
13
|
+
connectorId?: string;
|
|
14
|
+
user?: { id: string; name: string; roles: string[] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Bridge (shared across web and native) ───────────────────
|
|
18
|
+
|
|
19
|
+
export interface PluginBridge {
|
|
20
|
+
/** Wait for host initialization */
|
|
21
|
+
onInit(cb: (ctx: PluginContext) => void): void;
|
|
22
|
+
|
|
23
|
+
/** Call an MCP tool on any connector */
|
|
24
|
+
mcpCall(tool: string, args: any, connectorId?: string): Promise<any>;
|
|
25
|
+
|
|
26
|
+
/** Raw JSON-RPC to /mcp endpoint */
|
|
27
|
+
mcpFetch(body: any): Promise<any>;
|
|
28
|
+
|
|
29
|
+
/** Send a command to the host */
|
|
30
|
+
sendCommand(type: string, payload: any): void;
|
|
31
|
+
|
|
32
|
+
/** Listen for commands from the host */
|
|
33
|
+
onCommand(cb: (type: string, payload: any) => void): () => void;
|
|
34
|
+
|
|
35
|
+
/** Close this plugin and return to chat */
|
|
36
|
+
close(): void;
|
|
37
|
+
|
|
38
|
+
/** Post a message into the user's chat thread, exactly as if the user
|
|
39
|
+
* typed it. Use for confirmed user actions (e.g. "Connect Gmail" with
|
|
40
|
+
* an approval modal) that should kick off a flow the orchestrator
|
|
41
|
+
* already knows how to handle. Pair with `close()` to land the user on
|
|
42
|
+
* the chat screen so they see Ada working on it. */
|
|
43
|
+
sendMessage(text: string): void;
|
|
44
|
+
|
|
45
|
+
/** Pre-fill the chat composer with `text` without sending. The user lands
|
|
46
|
+
* on chat with the text already typed and the input focused, so they can
|
|
47
|
+
* edit/review before tapping send. Use for "draft a message about X"
|
|
48
|
+
* flows where you want the user to review before committing. Pair with
|
|
49
|
+
* `close()` to dismiss the plugin surface. */
|
|
50
|
+
prepareMessage(text: string): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Native capabilities (only available in RN plugins) ──────
|
|
54
|
+
|
|
55
|
+
export interface CameraAPI {
|
|
56
|
+
takePhoto(opts?: { quality?: number; facing?: 'front' | 'back' }): Promise<{ uri: string; width: number; height: number }>;
|
|
57
|
+
scanBarcode(opts?: { formats?: string[] }): Promise<{ data: string; format: string }>;
|
|
58
|
+
pickImage(opts?: { multiple?: boolean }): Promise<{ uri: string }[]>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LocationAPI {
|
|
62
|
+
getCurrent(opts?: { accuracy?: 'low' | 'high' }): Promise<{ latitude: number; longitude: number; altitude?: number }>;
|
|
63
|
+
watchPosition(cb: (pos: { latitude: number; longitude: number }) => void): () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface StorageAPI {
|
|
67
|
+
get(key: string): Promise<string | null>;
|
|
68
|
+
set(key: string, value: string): Promise<void>;
|
|
69
|
+
delete(key: string): Promise<void>;
|
|
70
|
+
db: {
|
|
71
|
+
execute(sql: string, params?: any[]): Promise<{ rows: any[]; changes: number }>;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BiometricsAPI {
|
|
76
|
+
isAvailable(): Promise<boolean>;
|
|
77
|
+
authenticate(opts: { reason: string }): Promise<{ success: boolean }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface NotificationsAPI {
|
|
81
|
+
schedule(opts: { title: string; body: string; trigger: { seconds?: number; date?: string } }): Promise<string>;
|
|
82
|
+
cancel(id: string): Promise<void>;
|
|
83
|
+
onReceived(cb: (notification: { id: string; title: string; body: string; data?: any }) => void): () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface HapticsAPI {
|
|
87
|
+
impact(style: 'light' | 'medium' | 'heavy'): void;
|
|
88
|
+
notification(type: 'success' | 'warning' | 'error'): void;
|
|
89
|
+
selection(): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ConnectivityAPI {
|
|
93
|
+
isOnline(): boolean;
|
|
94
|
+
onChange(cb: (online: boolean) => void): () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FileSystemAPI {
|
|
98
|
+
readFile(path: string): Promise<string>;
|
|
99
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
100
|
+
pickDocument(opts?: { types?: string[] }): Promise<{ uri: string; name: string; size: number }>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Cookies API ────────────────────────────────────────────
|
|
104
|
+
// Reads the platform's shared cookie jar (NSHTTPCookieStorage on iOS,
|
|
105
|
+
// CookieManager on Android) after a WebView session has populated it.
|
|
106
|
+
// This is how plugins extract HttpOnly cookies from a user's login —
|
|
107
|
+
// `document.cookie` only sees non-HttpOnly. Plugin must declare
|
|
108
|
+
// `cookies: true` in its manifest's native capabilities.
|
|
109
|
+
|
|
110
|
+
export interface WebViewCookie {
|
|
111
|
+
name: string;
|
|
112
|
+
value: string;
|
|
113
|
+
domain?: string;
|
|
114
|
+
path?: string;
|
|
115
|
+
expires?: number; // unix seconds
|
|
116
|
+
httpOnly?: boolean;
|
|
117
|
+
secure?: boolean;
|
|
118
|
+
sameSite?: 'Lax' | 'Strict' | 'None';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CookiesAPI {
|
|
122
|
+
/** Get all cookies attached to a URL's effective domain. */
|
|
123
|
+
get(url: string): Promise<WebViewCookie[]>;
|
|
124
|
+
/** Clear cookies for a domain (e.g. to force re-login). */
|
|
125
|
+
clear(url: string): Promise<{ ok: boolean }>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Sharing API (native share sheet) ──────────────────────
|
|
129
|
+
export interface SharingAPI {
|
|
130
|
+
/** Is the share sheet available on this device? */
|
|
131
|
+
isAvailable(): Promise<boolean>;
|
|
132
|
+
/** Share a local file URI via the OS share sheet. */
|
|
133
|
+
shareFile(fileUri: string, opts?: { mimeType?: string; UTI?: string; dialogTitle?: string }): Promise<{ ok: boolean; error?: string }>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Scanner API (QR / barcode) ────────────────────────────
|
|
137
|
+
export interface ScannerAPI {
|
|
138
|
+
/** Prompt the user for camera permission. */
|
|
139
|
+
requestPermissions(): Promise<{ granted: boolean; status: string }>;
|
|
140
|
+
/**
|
|
141
|
+
* Scanner is camera-based; the plugin mounts the scanner component
|
|
142
|
+
* directly from device-bridge. This API exposes only the permission
|
|
143
|
+
* wrapper at the plugin-sdk level — the actual scan is a React
|
|
144
|
+
* component lifecycle.
|
|
145
|
+
*/
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── NFC API (tag read) ────────────────────────────────────
|
|
149
|
+
export interface NfcNdefRecord {
|
|
150
|
+
type: string;
|
|
151
|
+
payload: string;
|
|
152
|
+
}
|
|
153
|
+
export interface NfcAPI {
|
|
154
|
+
/** Is NFC hardware present? */
|
|
155
|
+
isSupported(): Promise<boolean>;
|
|
156
|
+
/** Is NFC turned on? (Android has a toggle; iOS always enabled when supported.) */
|
|
157
|
+
isEnabled(): Promise<boolean>;
|
|
158
|
+
/** Read the next NFC tag presented to the device within the timeout. */
|
|
159
|
+
readOnce(timeoutMs?: number): Promise<{ ok: boolean; tagId?: string; records?: NfcNdefRecord[]; error?: string }>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Biometrics API (Face ID / Touch ID / fingerprint) ─────
|
|
163
|
+
// NOTE: supersedes the original BiometricsAPI stub above. Same name,
|
|
164
|
+
// richer implementation.
|
|
165
|
+
export interface BiometricsAPIv2 {
|
|
166
|
+
/** What can this device do right now? */
|
|
167
|
+
capabilities(): Promise<{ hasHardware: boolean; isEnrolled: boolean; supportedTypes: Array<'fingerprint' | 'face' | 'iris'> }>;
|
|
168
|
+
/** Prompt the user. Resolves { success } or { success:false, error }. */
|
|
169
|
+
authenticate(opts: { reason: string; cancelLabel?: string; fallbackToPasscode?: boolean }): Promise<{ success: boolean; error?: string }>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
export interface PluginNative {
|
|
174
|
+
camera: CameraAPI;
|
|
175
|
+
location: LocationAPI;
|
|
176
|
+
storage: StorageAPI;
|
|
177
|
+
/** @deprecated use `biometricsV2` for the native-backed API. This stub
|
|
178
|
+
* remains for backwards compatibility. */
|
|
179
|
+
biometrics: BiometricsAPI;
|
|
180
|
+
biometricsV2: BiometricsAPIv2;
|
|
181
|
+
notifications: NotificationsAPI;
|
|
182
|
+
haptics: HapticsAPI;
|
|
183
|
+
connectivity: ConnectivityAPI;
|
|
184
|
+
fileSystem: FileSystemAPI;
|
|
185
|
+
cookies: CookiesAPI;
|
|
186
|
+
sharing: SharingAPI;
|
|
187
|
+
scanner: ScannerAPI;
|
|
188
|
+
nfc: NfcAPI;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Native capability flags (manifest declaration) ──────────
|
|
192
|
+
|
|
193
|
+
export interface NativeCapabilities {
|
|
194
|
+
camera?: boolean;
|
|
195
|
+
location?: boolean;
|
|
196
|
+
biometrics?: boolean;
|
|
197
|
+
offline?: boolean;
|
|
198
|
+
backgroundTasks?: boolean;
|
|
199
|
+
fileSystem?: boolean;
|
|
200
|
+
notifications?: boolean;
|
|
201
|
+
bluetooth?: boolean;
|
|
202
|
+
haptics?: boolean;
|
|
203
|
+
/** Read the platform cookie jar after a WebView login flow. */
|
|
204
|
+
cookies?: boolean;
|
|
205
|
+
/** Render a native WebView (the plugin uses react-native-webview directly). */
|
|
206
|
+
webview?: boolean;
|
|
207
|
+
/** Native share sheet. */
|
|
208
|
+
sharing?: boolean;
|
|
209
|
+
/** Camera-based QR / barcode scanning. */
|
|
210
|
+
scanner?: boolean;
|
|
211
|
+
/** NFC tag reading (iOS entitlement + Android permission required). */
|
|
212
|
+
nfc?: boolean;
|
|
213
|
+
/** Real biometric auth (Face ID / Touch ID / fingerprint). */
|
|
214
|
+
biometricsV2?: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Lifecycle hooks ─────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
export interface PluginHooks {
|
|
220
|
+
onAppStart?: boolean;
|
|
221
|
+
onPushReceived?: boolean;
|
|
222
|
+
onConnectivity?: boolean;
|
|
223
|
+
onForeground?: boolean;
|
|
224
|
+
onBackground?: boolean;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Theme tokens (matches host app theme) ───────────────────
|
|
228
|
+
|
|
229
|
+
export interface ThemeTokens {
|
|
230
|
+
colors: {
|
|
231
|
+
bg: string;
|
|
232
|
+
surface: string;
|
|
233
|
+
surfaceAlt: string;
|
|
234
|
+
input: string;
|
|
235
|
+
border: string;
|
|
236
|
+
text: string;
|
|
237
|
+
textSecondary: string;
|
|
238
|
+
textMuted: string;
|
|
239
|
+
accent: string;
|
|
240
|
+
accentSoft: string;
|
|
241
|
+
success: string;
|
|
242
|
+
successSoft: string;
|
|
243
|
+
warning: string;
|
|
244
|
+
warningSoft: string;
|
|
245
|
+
error: string;
|
|
246
|
+
errorSoft: string;
|
|
247
|
+
purple: string;
|
|
248
|
+
purpleSoft: string;
|
|
249
|
+
};
|
|
250
|
+
spacing: { xs: number; sm: number; md: number; lg: number; xl: number; xxl: number };
|
|
251
|
+
fontSize: { xs: number; sm: number; md: number; lg: number; xl: number };
|
|
252
|
+
borderRadius: { sm: number; md: number; lg: number; full: number };
|
|
253
|
+
isDark: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── What every plugin receives ──────────────────────────────
|
|
257
|
+
|
|
258
|
+
export interface PluginProps {
|
|
259
|
+
bridge: PluginBridge;
|
|
260
|
+
native: PluginNative;
|
|
261
|
+
theme: ThemeTokens;
|
|
262
|
+
context: PluginContext;
|
|
263
|
+
/** Engine-supplied props from `sys.focusUiPlugin(pluginId, props)`.
|
|
264
|
+
* Plugins read these on first mount to support flows like "open with the
|
|
265
|
+
* add modal pre-shown" or "prefill text X". Undefined when the plugin was
|
|
266
|
+
* opened by the user (no props from engine). */
|
|
267
|
+
props?: any;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Plugin Manifest (extended) ──────────────────────────────
|
|
271
|
+
|
|
272
|
+
export type RenderMode =
|
|
273
|
+
| { mode: 'iframe'; iframeUrl: string; external?: boolean }
|
|
274
|
+
| { mode: 'react-native'; component: string; bundleId?: string; bundleUrl?: string }
|
|
275
|
+
| {
|
|
276
|
+
mode: 'adaptive';
|
|
277
|
+
iframe: { iframeUrl: string; external?: boolean };
|
|
278
|
+
reactNative: { component: string; bundleId?: string; bundleUrl?: string };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export interface PluginManifest {
|
|
282
|
+
id: string;
|
|
283
|
+
name: string;
|
|
284
|
+
version: string;
|
|
285
|
+
description?: string;
|
|
286
|
+
type?: 'ui' | 'service' | 'hybrid';
|
|
287
|
+
render?: RenderMode;
|
|
288
|
+
native?: NativeCapabilities;
|
|
289
|
+
hooks?: PluginHooks;
|
|
290
|
+
channels?: string[] | { events: string[]; actions: string[] };
|
|
291
|
+
capabilities?: Record<string, unknown>;
|
|
292
|
+
_source?: string;
|
|
293
|
+
_connectorId?: string;
|
|
294
|
+
_mcpEndpoint?: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Registration types ──────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
export interface RegisteredUIPlugin {
|
|
300
|
+
id: string;
|
|
301
|
+
type: 'ui' | 'hybrid';
|
|
302
|
+
capabilities?: NativeCapabilities;
|
|
303
|
+
Component: React.FC<PluginProps>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface RegisteredServicePlugin {
|
|
307
|
+
id: string;
|
|
308
|
+
type: 'service';
|
|
309
|
+
capabilities?: NativeCapabilities;
|
|
310
|
+
hooks?: PluginHooks;
|
|
311
|
+
onStart: (props: Omit<PluginProps, 'theme'>) => Promise<void> | void;
|
|
312
|
+
onStop?: () => Promise<void> | void;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple plugin API — call any connector tool.
|
|
3
|
+
*
|
|
4
|
+
* Supports THREE usage patterns:
|
|
5
|
+
*
|
|
6
|
+
* // Pattern 1: Direct function call
|
|
7
|
+
* const api = useApi(bridge);
|
|
8
|
+
* const tasks = await api('task-board-mcp', 'tasks.list', {});
|
|
9
|
+
*
|
|
10
|
+
* // Pattern 2: .call(connector, tool, args) — connector first
|
|
11
|
+
* const api = useApi(bridge);
|
|
12
|
+
* const tasks = await api.call('memory-mcp', 'rules.list', {});
|
|
13
|
+
*
|
|
14
|
+
* // Pattern 3: .call(tool, args, connector) — connector last (rn-src plugins use this!)
|
|
15
|
+
* const api = useApi(bridge);
|
|
16
|
+
* const tasks = await api.call('memory.list', { type: 'rule' }, 'memory-mcp');
|
|
17
|
+
*
|
|
18
|
+
* All three work. The .call method auto-detects argument order.
|
|
19
|
+
*/
|
|
20
|
+
import { useMemo } from 'react';
|
|
21
|
+
import type { PluginBridge } from './types';
|
|
22
|
+
|
|
23
|
+
interface PluginApi {
|
|
24
|
+
(connectorId: string, tool: string, args?: Record<string, any>): Promise<any>;
|
|
25
|
+
call(connectorOrTool: string, toolOrArgs?: string | Record<string, any>, argsOrConnector?: Record<string, any> | string): Promise<any>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a simple API function to call connector tools.
|
|
30
|
+
*/
|
|
31
|
+
export function useApi(bridge: PluginBridge): PluginApi {
|
|
32
|
+
return useMemo(() => {
|
|
33
|
+
// Core implementation: (connectorId, tool, args) => Promise
|
|
34
|
+
const apiFn = async (connectorId: string, tool: string, args: Record<string, any> = {}): Promise<any> => {
|
|
35
|
+
if (!bridge) {
|
|
36
|
+
throw new Error('useApi: bridge is required. Pass it from plugin props.');
|
|
37
|
+
}
|
|
38
|
+
return bridge.mcpCall(tool, args, connectorId);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Add .call method that supports ALL THREE patterns:
|
|
42
|
+
// api.call(connector, tool, args) — 2nd arg is string → connector first
|
|
43
|
+
// api.call(tool, args, connector) — 3rd arg is string → connector last (rn-src pattern)
|
|
44
|
+
// api.call(tool, args) — 2 args only → use bridge default connector
|
|
45
|
+
const callMethod = async (
|
|
46
|
+
first: string,
|
|
47
|
+
second?: string | Record<string, any>,
|
|
48
|
+
third?: Record<string, any> | string,
|
|
49
|
+
): Promise<any> => {
|
|
50
|
+
if (!bridge) {
|
|
51
|
+
throw new Error('useApi: bridge is required. Pass it from plugin props.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof second === 'string') {
|
|
55
|
+
// api.call(connector, tool, args) — 2nd arg is tool name string
|
|
56
|
+
return bridge.mcpCall(second, (third as Record<string, any>) || {}, first);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof third === 'string') {
|
|
60
|
+
// api.call(tool, args, connector) — 3rd arg is connector ID string
|
|
61
|
+
return bridge.mcpCall(first, (second as Record<string, any>) || {}, third);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// api.call(tool, args) — no connector, use bridge default
|
|
65
|
+
return bridge.mcpCall(first, (second as Record<string, any>) || {});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Override .call on the function to be our custom method
|
|
69
|
+
// (shadows Function.prototype.call intentionally)
|
|
70
|
+
Object.defineProperty(apiFn, 'call', {
|
|
71
|
+
value: callMethod,
|
|
72
|
+
writable: false,
|
|
73
|
+
configurable: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return apiFn as PluginApi;
|
|
77
|
+
}, [bridge]);
|
|
78
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme delivered to plugins via `usePluginTheme()`.
|
|
3
|
+
*
|
|
4
|
+
* Theming flows from the HOST — plugin-side fallback hex values are a no-op
|
|
5
|
+
* by design. The default is the Ada warm-paper palette (matching the
|
|
6
|
+
* reference app); hosts with their own brand call `setPluginThemeColors()`
|
|
7
|
+
* once at startup to override.
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
10
|
+
import { ada, spacing, fontSize, borderRadius } from '@ateam-ai-mobile/core';
|
|
11
|
+
import type { ThemeTokens } from './types';
|
|
12
|
+
|
|
13
|
+
type ThemeColors = ThemeTokens['colors'];
|
|
14
|
+
|
|
15
|
+
// Ada palette mapped onto the plugin theme contract — mirrors the reference
|
|
16
|
+
// app's useTheme.ts. Everything renders against warm paper; dark mode is on
|
|
17
|
+
// the roadmap.
|
|
18
|
+
const adaColors: ThemeColors = {
|
|
19
|
+
bg: ada.paper,
|
|
20
|
+
surface: ada.paperSoft,
|
|
21
|
+
surfaceAlt: ada.paperDeep,
|
|
22
|
+
input: ada.paper,
|
|
23
|
+
border: ada.border,
|
|
24
|
+
text: ada.ink,
|
|
25
|
+
textSecondary: ada.inkSoft,
|
|
26
|
+
textMuted: ada.inkMuted,
|
|
27
|
+
accent: ada.orange,
|
|
28
|
+
accentSoft: ada.orangeSoft,
|
|
29
|
+
success: '#5A8A5C',
|
|
30
|
+
successSoft: 'rgba(90,138,92,0.14)',
|
|
31
|
+
warning: '#D49A4E',
|
|
32
|
+
warningSoft: 'rgba(212,154,78,0.16)',
|
|
33
|
+
error: '#C15545',
|
|
34
|
+
errorSoft: 'rgba(193,85,69,0.14)',
|
|
35
|
+
purple: ada.orangeDeep,
|
|
36
|
+
purpleSoft: ada.orangeSoft,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let _colors: ThemeColors = adaColors;
|
|
40
|
+
let _isDark = false;
|
|
41
|
+
const _subscribers = new Set<() => void>();
|
|
42
|
+
|
|
43
|
+
/** Override the plugin theme (host brand). Call once at startup — mounted
|
|
44
|
+
* plugins re-render with the new tokens. */
|
|
45
|
+
export function setPluginThemeColors(colors: Partial<ThemeColors>, opts?: { isDark?: boolean }): void {
|
|
46
|
+
_colors = { ...adaColors, ...colors };
|
|
47
|
+
if (opts?.isDark !== undefined) _isDark = opts.isDark;
|
|
48
|
+
_subscribers.forEach(fn => fn());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function subscribe(fn: () => void): () => void {
|
|
52
|
+
_subscribers.add(fn);
|
|
53
|
+
return () => { _subscribers.delete(fn); };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function usePluginTheme(): ThemeTokens {
|
|
57
|
+
const colors = useSyncExternalStore(subscribe, () => _colors);
|
|
58
|
+
const isDark = useSyncExternalStore(subscribe, () => _isDark);
|
|
59
|
+
|
|
60
|
+
// Memoize so plugins don't re-render on every host render.
|
|
61
|
+
return useMemo(() => ({
|
|
62
|
+
colors,
|
|
63
|
+
spacing: {
|
|
64
|
+
xs: spacing.xs,
|
|
65
|
+
sm: spacing.sm,
|
|
66
|
+
md: spacing.md,
|
|
67
|
+
lg: spacing.lg,
|
|
68
|
+
xl: spacing.xl,
|
|
69
|
+
xxl: spacing.xxl,
|
|
70
|
+
},
|
|
71
|
+
fontSize: {
|
|
72
|
+
xs: fontSize.xs,
|
|
73
|
+
sm: fontSize.sm,
|
|
74
|
+
md: fontSize.md,
|
|
75
|
+
lg: fontSize.lg,
|
|
76
|
+
xl: fontSize.xl,
|
|
77
|
+
},
|
|
78
|
+
borderRadius: {
|
|
79
|
+
sm: borderRadius.sm,
|
|
80
|
+
md: borderRadius.md,
|
|
81
|
+
lg: borderRadius.lg,
|
|
82
|
+
full: borderRadius.full,
|
|
83
|
+
},
|
|
84
|
+
isDark,
|
|
85
|
+
}), [colors, isDark]);
|
|
86
|
+
}
|