@colixsystems/widget-sdk 0.3.0 → 0.6.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,425 @@
1
+ // ESM mirror of contract.cjs. Generated from contract.cjs — keep both in lockstep.
2
+ // runtime) can `require()` it directly without dynamic import gymnastics.
3
+ //
4
+ // The ESM `contract.js` re-exports the SAME object as the `CONTRACT` named
5
+ // export. The contract test asserts the two are identical (deep-equal) so
6
+ // drift between this file and the ESM source-of-truth is caught in CI.
7
+ //
8
+ // Keep edits in lockstep between `contract.cjs` and `contract.js`. The
9
+ // SDK build script copies both into `dist/`.
10
+
11
+ const DEFAULT_THEME_TOKENS = Object.freeze({
12
+ colors: Object.freeze({
13
+ primary: "#ff6b5b",
14
+ onPrimary: "#ffffff",
15
+ surface: "#ffffff",
16
+ onSurface: "#111827",
17
+ surfaceMuted: "#f8fafc",
18
+ onSurfaceMuted: "#475569",
19
+ border: "#e2e8f0",
20
+ danger: "#dc2626",
21
+ success: "#059669",
22
+ warning: "#d97706",
23
+ info: "#0284c7",
24
+ }),
25
+ spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
26
+ radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
27
+ typography: Object.freeze({
28
+ fontFamily:
29
+ 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
30
+ sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
31
+ }),
32
+ });
33
+
34
+ const HOOKS = [
35
+ {
36
+ name: "useTheme",
37
+ signature: "useTheme()",
38
+ returnShape: {
39
+ colors:
40
+ "{ primary, onPrimary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }",
41
+ spacing: "{ xs, sm, md, lg, xl }",
42
+ radii: "{ sm, md, lg, pill }",
43
+ typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
44
+ },
45
+ requiredContextSlice: ["workspace.theme"],
46
+ scopes: null,
47
+ },
48
+ {
49
+ name: "useI18n",
50
+ signature: "useI18n()",
51
+ returnShape: {
52
+ t: "(key: string, fallback?: string) => string",
53
+ locale: "string",
54
+ },
55
+ requiredContextSlice: ["i18n.t", "i18n.locale"],
56
+ scopes: null,
57
+ },
58
+ {
59
+ name: "useDatastoreQuery",
60
+ signature: "useDatastoreQuery(tableId, options?)",
61
+ returnShape: {
62
+ data: "Record[]",
63
+ loading: "boolean",
64
+ error: "DatastoreError | null",
65
+ refetch: "() => Promise<void>",
66
+ },
67
+ requiredContextSlice: ["datastore.records"],
68
+ scopes: ["datastore.read:*"],
69
+ },
70
+ {
71
+ name: "useDatastoreMutation",
72
+ signature: "useDatastoreMutation(tableId)",
73
+ returnShape: {
74
+ create: "(record) => Promise<Record> // rejects with DatastoreError",
75
+ update:
76
+ "(id, partial) => Promise<Record> // rejects with DatastoreError",
77
+ delete: "(id) => Promise<void> // rejects with DatastoreError",
78
+ },
79
+ requiredContextSlice: ["datastore.records"],
80
+ scopes: ["datastore.write:*"],
81
+ },
82
+ {
83
+ name: "useWidgetEvent",
84
+ signature: "useWidgetEvent(eventName)",
85
+ returnShape: { emit: "(payload?) => void" },
86
+ requiredContextSlice: ["events.emit"],
87
+ scopes: null,
88
+ },
89
+ ];
90
+
91
+ // REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
92
+ const PRIMITIVES = [
93
+ {
94
+ name: "Text",
95
+ description:
96
+ "Text node. Backed by react-native Text (web: react-native-web maps to a styled <div>). See https://reactnative.dev/docs/text.",
97
+ rnComponent: "Text",
98
+ docsUrl: "https://reactnative.dev/docs/text",
99
+ },
100
+ {
101
+ name: "View",
102
+ description:
103
+ "Block container. Backed by react-native View. The cross-platform equivalent of a <div>. See https://reactnative.dev/docs/view.",
104
+ rnComponent: "View",
105
+ docsUrl: "https://reactnative.dev/docs/view",
106
+ },
107
+ {
108
+ name: "Pressable",
109
+ description:
110
+ "Tappable / clickable wrapper. Use `onPress` (NOT `onClick`). See https://reactnative.dev/docs/pressable.",
111
+ rnComponent: "Pressable",
112
+ docsUrl: "https://reactnative.dev/docs/pressable",
113
+ },
114
+ {
115
+ name: "Image",
116
+ description:
117
+ "Image. `source` accepts `{ uri }` or a string URL. See https://reactnative.dev/docs/image.",
118
+ rnComponent: "Image",
119
+ docsUrl: "https://reactnative.dev/docs/image",
120
+ },
121
+ {
122
+ name: "ScrollView",
123
+ description:
124
+ "Scrollable container. Pass `horizontal` to scroll on the x-axis. See https://reactnative.dev/docs/scrollview.",
125
+ rnComponent: "ScrollView",
126
+ docsUrl: "https://reactnative.dev/docs/scrollview",
127
+ },
128
+ {
129
+ name: "TextInput",
130
+ description:
131
+ "Single-line or multi-line text field. Controlled — pass `value` + `onChangeText`. Set `multiline` for a textarea-style field (`numberOfLines` controls visible rows). See https://reactnative.dev/docs/textinput.",
132
+ rnComponent: "TextInput",
133
+ docsUrl: "https://reactnative.dev/docs/textinput",
134
+ },
135
+ {
136
+ name: "FlatList",
137
+ description:
138
+ "Virtualised list. Render large arrays without paying for every row up front. Required props: `data`, `renderItem`, `keyExtractor`. See https://reactnative.dev/docs/flatlist.",
139
+ rnComponent: "FlatList",
140
+ docsUrl: "https://reactnative.dev/docs/flatlist",
141
+ },
142
+ {
143
+ name: "SectionList",
144
+ description:
145
+ "Like FlatList but grouped by section header. See https://reactnative.dev/docs/sectionlist.",
146
+ rnComponent: "SectionList",
147
+ docsUrl: "https://reactnative.dev/docs/sectionlist",
148
+ },
149
+ {
150
+ name: "ActivityIndicator",
151
+ description:
152
+ "Loading spinner. See https://reactnative.dev/docs/activityindicator.",
153
+ rnComponent: "ActivityIndicator",
154
+ docsUrl: "https://reactnative.dev/docs/activityindicator",
155
+ },
156
+ {
157
+ name: "Switch",
158
+ description:
159
+ "Toggle. Controlled — `value` + `onValueChange`. See https://reactnative.dev/docs/switch.",
160
+ rnComponent: "Switch",
161
+ docsUrl: "https://reactnative.dev/docs/switch",
162
+ },
163
+ {
164
+ name: "StyleSheet",
165
+ description:
166
+ "Helper for building style objects. `StyleSheet.create({ ... })` returns the same shape you can pass to `style={...}`. See https://reactnative.dev/docs/stylesheet.",
167
+ rnComponent: "StyleSheet",
168
+ docsUrl: "https://reactnative.dev/docs/stylesheet",
169
+ },
170
+ ];
171
+
172
+ const CATEGORIES = [
173
+ "INPUT",
174
+ "DISPLAY",
175
+ "LAYOUT",
176
+ "DATA",
177
+ "MEDIA",
178
+ "COMMUNICATION",
179
+ "CUSTOM",
180
+ ];
181
+ const PLATFORMS = ["web", "native"];
182
+
183
+ // Reverse-DNS-ish manifest id, e.g. "com.acme.charts.barchart". Two or
184
+ // more labels, lowercase alnum + hyphen, label starts with a letter. The
185
+ // analyzer + the SDK validator both read this from the contract so a
186
+ // rename / relaxation can only happen in one place.
187
+ const MANIFEST_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
188
+
189
+ const MANIFEST_SCHEMA = {
190
+ id: {
191
+ type: "string",
192
+ required: true,
193
+ description: "Reverse-DNS identifier, e.g. com.acme.hello.",
194
+ example: "com.acme.hello",
195
+ pattern: MANIFEST_ID_PATTERN,
196
+ },
197
+ name: {
198
+ type: "string",
199
+ required: true,
200
+ description: "Human-readable widget name.",
201
+ },
202
+ version: {
203
+ type: "string",
204
+ required: true,
205
+ description: "Semver. Patch bump per publish unless author overrides.",
206
+ example: "1.0.0",
207
+ },
208
+ category: {
209
+ type: "enum",
210
+ required: true,
211
+ description:
212
+ "One of " + CATEGORIES.join(", ") + ". Drives the palette section.",
213
+ values: CATEGORIES,
214
+ },
215
+ icon: {
216
+ type: "string",
217
+ required: true,
218
+ description: "Icon identifier, e.g. lucide:sparkles.",
219
+ default: "lucide:sparkles",
220
+ },
221
+ description: {
222
+ type: "string",
223
+ required: true,
224
+ description: "1-2 sentence storefront description.",
225
+ },
226
+ author: {
227
+ type: "object",
228
+ required: true,
229
+ description: "{ name: string, url?: string, email?: string }",
230
+ },
231
+ supportedPlatforms: {
232
+ type: "string[]",
233
+ required: true,
234
+ description: "Subset of " + PLATFORMS.join(", ") + ".",
235
+ default: ["web"],
236
+ },
237
+ minAppStudioVersion: {
238
+ type: "string",
239
+ required: true,
240
+ description: "Semver range, e.g. >=2.4.0.",
241
+ default: ">=0.1.0",
242
+ },
243
+ requestedScopes: {
244
+ type: "string[]",
245
+ required: true,
246
+ description:
247
+ "Permission scopes; use [] unless the widget reads/writes data.",
248
+ default: [],
249
+ },
250
+ propertySchema: {
251
+ type: "object",
252
+ required: true,
253
+ description: "Map of prop name to property definition.",
254
+ default: {},
255
+ },
256
+ events: {
257
+ type: "object[]",
258
+ required: true,
259
+ description: "Declared events. Each entry { name, payloadSchema? }.",
260
+ default: [],
261
+ },
262
+ };
263
+
264
+ const WIDGET_CONTEXT_SHAPE = {
265
+ props: {
266
+ description: "Resolved property values, shape per manifest.propertySchema.",
267
+ required: true,
268
+ fields: {},
269
+ },
270
+ widget: {
271
+ description:
272
+ "Identity of the currently rendered widget instance ({ id, version }).",
273
+ required: true,
274
+ fields: { id: "manifest.id", version: "manifest.version" },
275
+ },
276
+ user: {
277
+ description: "Signed-in user ({ id, email, displayName }).",
278
+ required: true,
279
+ fields: { id: "string", email: "string", displayName: "string" },
280
+ },
281
+ workspace: {
282
+ description:
283
+ "Workspace identity + resolved theme ({ id, slug, theme, locale }).",
284
+ required: true,
285
+ fields: {
286
+ id: "string",
287
+ slug: "string",
288
+ theme: "ThemeTokens",
289
+ locale: "string",
290
+ },
291
+ },
292
+ navigation: {
293
+ description: "{ push(path), replace(path), back() }.",
294
+ required: true,
295
+ fields: { push: "function", replace: "function", back: "function" },
296
+ },
297
+ datastore: {
298
+ description:
299
+ "{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) } }.",
300
+ required: true,
301
+ fields: { records: "function" },
302
+ },
303
+ events: {
304
+ description: "{ emit(name, payload) }.",
305
+ required: true,
306
+ fields: { emit: "function" },
307
+ },
308
+ i18n: {
309
+ description: "{ t(key, fallback?), locale }.",
310
+ required: true,
311
+ fields: { t: "function", locale: "string" },
312
+ },
313
+ logger: {
314
+ description:
315
+ "{ debug, info, warn, error } — host-routed; never use console.*.",
316
+ required: true,
317
+ fields: {
318
+ debug: "function",
319
+ info: "function",
320
+ warn: "function",
321
+ error: "function",
322
+ },
323
+ },
324
+ };
325
+
326
+ const BUNDLE_EXPORT_CONTRACT = [
327
+ {
328
+ name: "wrapped",
329
+ description:
330
+ "default export = defineWidget({ manifest, component }) → resolves to { manifest, component, _kind: 'widget' }. Public marketplace widgets.",
331
+ predicate:
332
+ "typeof mod.default === 'object' && typeof mod.default.component === 'function' && typeof mod.default.manifest === 'object'",
333
+ manifestSource: "mod.default.manifest",
334
+ audience: "public-marketplace",
335
+ },
336
+ {
337
+ name: "bare-component",
338
+ description:
339
+ "default export = function MyWidget(props) {...} → host pairs it with the installation's pinnedVersion.manifestJson. AI-agent widgets.",
340
+ predicate: "typeof mod.default === 'function'",
341
+ manifestSource: "installation.pinnedVersion.manifestJson",
342
+ audience: "ai-agent",
343
+ },
344
+ ];
345
+
346
+ const BANNED_APIS = [
347
+ { identifier: "eval", reason: "Arbitrary code evaluation." },
348
+ {
349
+ identifier: "Function",
350
+ reason: "Function() constructor evaluates strings.",
351
+ },
352
+ {
353
+ identifier: "new Function",
354
+ reason: "Function() constructor evaluates strings.",
355
+ },
356
+ { identifier: "window", reason: "Host environment escape." },
357
+ {
358
+ identifier: "document",
359
+ reason: "Host environment escape; use SDK primitives.",
360
+ },
361
+ {
362
+ identifier: "process",
363
+ reason: "Node global; unavailable in the browser, banned for parity.",
364
+ },
365
+ {
366
+ identifier: "localStorage",
367
+ reason: "Bypasses host data model; not portable to native.",
368
+ },
369
+ {
370
+ identifier: "sessionStorage",
371
+ reason: "Same reason as localStorage.",
372
+ },
373
+ {
374
+ identifier: "fetch",
375
+ reason: "Direct network calls bypass the datastore client + tenant auth.",
376
+ },
377
+ {
378
+ identifier: "XMLHttpRequest",
379
+ reason: "Direct network calls bypass the datastore client + tenant auth.",
380
+ },
381
+ {
382
+ identifier: "import(",
383
+ reason: "Dynamic import bypasses the loader's allowlist.",
384
+ },
385
+ { identifier: "globalThis", reason: "Host environment escape." },
386
+ ];
387
+
388
+ const ALLOWED_BARE_IMPORTS = ["react", "@colixsystems/widget-sdk"];
389
+
390
+ function deepFreeze(value) {
391
+ if (value === null || typeof value !== "object") return value;
392
+ if (Object.isFrozen(value)) return value;
393
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
394
+ return Object.freeze(value);
395
+ }
396
+
397
+ const CONTRACT = deepFreeze({
398
+ version: "1.0.0",
399
+ hooks: HOOKS,
400
+ primitives: PRIMITIVES,
401
+ manifestSchema: MANIFEST_SCHEMA,
402
+ manifestCategories: CATEGORIES,
403
+ manifestPlatforms: PLATFORMS,
404
+ themeTokens: DEFAULT_THEME_TOKENS,
405
+ widgetContextShape: WIDGET_CONTEXT_SHAPE,
406
+ bundleExportContract: BUNDLE_EXPORT_CONTRACT,
407
+ bannedApis: BANNED_APIS,
408
+ allowedBareImports: ALLOWED_BARE_IMPORTS,
409
+ });
410
+
411
+ function isHookAllowed(name) {
412
+ return CONTRACT.hooks.some((h) => h.name === name);
413
+ }
414
+
415
+ function requiredContextKeys() {
416
+ const keys = new Set();
417
+ for (const hook of CONTRACT.hooks) {
418
+ for (const dotPath of hook.requiredContextSlice) {
419
+ keys.add(dotPath.split(".")[0]);
420
+ }
421
+ }
422
+ return [...keys];
423
+ }
424
+
425
+ export { CONTRACT, isHookAllowed, requiredContextKeys };