@colixsystems/widget-sdk 0.4.1 → 0.7.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/README.md CHANGED
@@ -6,9 +6,27 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.4.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
9
+ `v0.7.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
10
10
 
11
- ### What's new in 0.4.1
11
+ ### What's new in 0.7.0
12
+
13
+ - **`datastoreTemplate` is now part of the public manifest contract.** `CONTRACT.manifestSchema` carries an optional `datastoreTemplate` entry alongside the existing fields. The TypeScript `WidgetManifest` already declared this since 0.3.0, but the runtime contract did not — the agent's system prompt only generated the field set advertised by `CONTRACT.manifestSchema`, which silently omitted `datastoreTemplate` from every AI-generated draft. The mismatch meant most AI-generated DATA widgets shipped without a seeded table, forcing the end-user to hand-build it before the widget would render anything useful. With the schema entry in place, the agent now defaults to including a `datastoreTemplate` whenever the widget reads or writes data, matching the no-code experience the platform promises.
14
+ - **Agent system prompt** ([backend/src/core/services/ai-widget-agent.service.js](../../backend/src/core/services/ai-widget-agent.service.js)) gains a dedicated `===== DATASTORE TEMPLATE =====` section, an updated DATA-widget example with a template, and a CONVERSATION BEHAVIOUR rule pinning the default. The note-list example (TextInput + create + update + delete) now shows the matching template too, including the column-name-match rule (`record.Body` ↔ `"name": "Body"`).
15
+
16
+ ### What was in 0.6.0
17
+
18
+ - **Primitives are now React Native through-and-through.** Web (Studio + Player) bundles with `react-native-web`; native (exported Expo app) uses the real `react-native`. The hand-written DOM wrappers in `primitives.js` are gone — both web and native files are now one-line re-exports from `react-native`. The widget API surface is unchanged for the components that already existed (`Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`), so existing 0.5.0 widgets keep running without source edits.
19
+ - **More primitives available for free.** `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, and `StyleSheet` are now exported alongside the original five. Authors who want them just import from `@colixsystems/widget-sdk` like any other primitive.
20
+ - **Per-component API docs link out to React Native.** The Developer Widgets page and the AI agent's system prompt now point at https://reactnative.dev/docs/<component> for prop details instead of duplicating them in the SDK contract. Less drift, more breadth (every RN prop — `accessibilityLabel`, `testID`, gesture handlers, …, works without explicit allowlisting).
21
+ - **Adding a new primitive is now a single edit.** Append the export name to `primitives.js` + `primitives.native.js` + the `CONTRACT.primitives` array. No web/native implementation split.
22
+ - **New peer dep.** `react-native-web >=0.19.0` (optional, marked under `peerDependenciesMeta`). The web bundler picks it up via an alias (`react-native` → `react-native-web` in the host's Vite / webpack config); the native bundler ignores it.
23
+
24
+ ### What was in 0.5.0
25
+
26
+ - **`TextInput` primitive.** Cross-platform controlled text field — web maps to `<input type="text">` (or `<textarea>` when `multiline` is true), native maps to `react-native` `TextInput`. Props: `value`, `onChangeText`, `placeholder`, `multiline`, `rows`, `disabled`, `style`. Fixes the gap that forced widgets to fall back to raw `<textarea>` (web-only). The AI Widget Agent's system prompt now documents this primitive.
27
+ - **`useDatastoreMutation(...).update(id, partial)` is wired.** Hits `PATCH /api/v1/tables/:tableId/records/:id`, which now exists on the backend (REQ-DML-PATCH). Partial-update semantics — only the supplied columns are mutated, the rest of the row is left intact. MANY_TO_MANY relations follow replace-set semantics when supplied; omitting the column leaves the existing links alone. Constraint enforcement (REQ-CONS-04) runs against the merged row, excluding self so a re-affirm doesn't trip its own UNIQUE.
28
+
29
+ ### What was in 0.4.1
12
30
 
13
31
  - **`useDatastoreQuery` returns a stable `refetch` identity.** The hook no longer rebinds the underlying callback when the host's `WidgetContext` value (a fresh object identity on every render in Studio + PageRenderer) changes. Widgets that put `refetch` in a `useEffect` dep array no longer loop.
14
32
 
@@ -17,7 +35,7 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
17
35
  - **`CONTRACT` is now a named export.** A frozen object literal describing the SDK surface every consumer (LLM system prompt, static analyzer, host builder, contract tests) derives from instead of declaring its own copy. See `docs/design/ai-widget-contract.md` for the full design and `src/contract.js` for the source. The contract carries:
18
36
 
19
37
  - `hooks` — name, signature, return shape, required `WidgetContext` slices, manifest scopes.
20
- - `primitives` — `Text` / `View` / `Pressable` / `Image` / `ScrollView` props.
38
+ - `primitives` — the cross-platform primitive list (names + the React Native component each one backs).
21
39
  - `manifestSchema` — the authoritative `WidgetManifest` field list (with `id` and `minAppStudioVersion`, not the legacy `manifestId` / `minAppStudioVer`).
22
40
  - `themeTokens` — the default `useTheme()` payload.
23
41
  - `widgetContextShape` — what the host must populate.
@@ -44,7 +62,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
44
62
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
45
63
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
46
64
  - `useDatastoreQuery`, `useDatastoreMutation`, `useWidgetEvent`, `useTheme`, `useI18n` — hooks that read from the host-provided `WidgetContext`.
47
- - `Text`, `View`, `Pressable`, `Image`, `ScrollView` — platform-aware primitives. Web entry maps them to DOM elements; the `react-native` entry maps them to React Native components.
65
+ - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet` re-exported from `react-native`. The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. See https://reactnative.dev/docs/ for per-component props.
48
66
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
49
67
 
50
68
  ## Linter
package/dist/contract.cjs CHANGED
@@ -87,61 +87,90 @@ const HOOKS = [
87
87
  },
88
88
  ];
89
89
 
90
+ // REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
91
+ // (backed by react-native-web on the browser, by react-native on the
92
+ // exported Expo app). The contract describes the *cross-platform
93
+ // subset* the SDK re-exports; the per-prop reference for each
94
+ // component is whatever React Native documents. Adding a new primitive
95
+ // = add the name to `./primitives.js` + `./primitives.native.js` (both
96
+ // one-line re-exports) and append it to this list.
90
97
  const PRIMITIVES = [
91
98
  {
92
99
  name: "Text",
93
100
  description:
94
- "Text node. Web: <span>. Native: react-native Text. Use `style` for typography.",
95
- props: {
96
- children: { type: "ReactNode", description: "Text content." },
97
- style: { type: "CSSObject | StyleObject", description: "Inline style." },
98
- },
101
+ "Text node. Backed by react-native Text (web: react-native-web maps to a styled <div>). See https://reactnative.dev/docs/text.",
102
+ rnComponent: "Text",
103
+ docsUrl: "https://reactnative.dev/docs/text",
99
104
  },
100
105
  {
101
106
  name: "View",
102
107
  description:
103
- "Block container. Web: <div>. Native: react-native View. Use for layout boxes.",
104
- props: {
105
- children: { type: "ReactNode" },
106
- style: { type: "CSSObject | StyleObject" },
107
- },
108
+ "Block container. Backed by react-native View. The cross-platform equivalent of a <div>. See https://reactnative.dev/docs/view.",
109
+ rnComponent: "View",
110
+ docsUrl: "https://reactnative.dev/docs/view",
108
111
  },
109
112
  {
110
113
  name: "Pressable",
111
114
  description:
112
- "Clickable wrapper. Use instead of raw <button> so cross-platform styling stays consistent.",
113
- props: {
114
- children: { type: "ReactNode" },
115
- style: { type: "CSSObject | StyleObject" },
116
- onPress: {
117
- type: "(event) => void",
118
- description: "Fires on click/tap. Web preventDefault is auto-applied.",
119
- },
120
- },
115
+ "Tappable / clickable wrapper. Use `onPress` (NOT `onClick`). See https://reactnative.dev/docs/pressable.",
116
+ rnComponent: "Pressable",
117
+ docsUrl: "https://reactnative.dev/docs/pressable",
121
118
  },
122
119
  {
123
120
  name: "Image",
124
121
  description:
125
- "Image. Pass an object with a `uri` (or a bare string on web). Falls back to `alt`.",
126
- props: {
127
- source: {
128
- type: "{ uri: string } | string",
129
- required: true,
130
- description: "Source object or bare URL string.",
131
- },
132
- alt: { type: "string", description: "Alt text for accessibility." },
133
- style: { type: "CSSObject | StyleObject" },
134
- },
122
+ "Image. `source` accepts `{ uri }` or a string URL. See https://reactnative.dev/docs/image.",
123
+ rnComponent: "Image",
124
+ docsUrl: "https://reactnative.dev/docs/image",
135
125
  },
136
126
  {
137
127
  name: "ScrollView",
138
128
  description:
139
- "Scrollable container. Pass `horizontal` to scroll on the x-axis.",
140
- props: {
141
- children: { type: "ReactNode" },
142
- horizontal: { type: "boolean" },
143
- style: { type: "CSSObject | StyleObject" },
144
- },
129
+ "Scrollable container. Pass `horizontal` to scroll on the x-axis. See https://reactnative.dev/docs/scrollview.",
130
+ rnComponent: "ScrollView",
131
+ docsUrl: "https://reactnative.dev/docs/scrollview",
132
+ },
133
+ {
134
+ name: "TextInput",
135
+ description:
136
+ "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.",
137
+ rnComponent: "TextInput",
138
+ docsUrl: "https://reactnative.dev/docs/textinput",
139
+ },
140
+ {
141
+ name: "FlatList",
142
+ description:
143
+ "Virtualised list. Render large arrays without paying for every row up front. Required props: `data`, `renderItem`, `keyExtractor`. See https://reactnative.dev/docs/flatlist.",
144
+ rnComponent: "FlatList",
145
+ docsUrl: "https://reactnative.dev/docs/flatlist",
146
+ },
147
+ {
148
+ name: "SectionList",
149
+ description:
150
+ "Like FlatList but grouped by section header. See https://reactnative.dev/docs/sectionlist.",
151
+ rnComponent: "SectionList",
152
+ docsUrl: "https://reactnative.dev/docs/sectionlist",
153
+ },
154
+ {
155
+ name: "ActivityIndicator",
156
+ description:
157
+ "Loading spinner. See https://reactnative.dev/docs/activityindicator.",
158
+ rnComponent: "ActivityIndicator",
159
+ docsUrl: "https://reactnative.dev/docs/activityindicator",
160
+ },
161
+ {
162
+ name: "Switch",
163
+ description:
164
+ "Toggle. Controlled — `value` + `onValueChange`. See https://reactnative.dev/docs/switch.",
165
+ rnComponent: "Switch",
166
+ docsUrl: "https://reactnative.dev/docs/switch",
167
+ },
168
+ {
169
+ name: "StyleSheet",
170
+ description:
171
+ "Helper for building style objects. `StyleSheet.create({ ... })` returns the same shape you can pass to `style={...}`. See https://reactnative.dev/docs/stylesheet.",
172
+ rnComponent: "StyleSheet",
173
+ docsUrl: "https://reactnative.dev/docs/stylesheet",
145
174
  },
146
175
  ];
147
176
 
@@ -235,6 +264,12 @@ const MANIFEST_SCHEMA = {
235
264
  description: "Declared events. Each entry { name, payloadSchema? }.",
236
265
  default: [],
237
266
  },
267
+ datastoreTemplate: {
268
+ type: "object",
269
+ required: false,
270
+ description:
271
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (must be declared earlier in the array). Tables persist across uninstalls.",
272
+ },
238
273
  };
239
274
 
240
275
  const WIDGET_CONTEXT_SHAPE = {
package/dist/contract.js CHANGED
@@ -1,17 +1,431 @@
1
- // Single-source-of-truth contract for the AI Widget Agent and every layer
2
- // that derives from it. See docs/design/ai-widget-contract.md for the full
3
- // rationale.
1
+ // ESM mirror of contract.cjs. Generated from contract.cjs keep both in lockstep.
2
+ // runtime) can `require()` it directly without dynamic import gymnastics.
4
3
  //
5
- // The canonical data lives in `contract.cjs` so backend services (CJS
6
- // runtime) can `require()` it without dynamic import gymnastics. This file
7
- // is the ESM mirror both modules export the exact same frozen
8
- // `CONTRACT` object, and the contract test asserts they stay in lockstep.
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
+ datastoreTemplate: {
263
+ type: "object",
264
+ required: false,
265
+ description:
266
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (must be declared earlier in the array). Tables persist across uninstalls.",
267
+ },
268
+ };
269
+
270
+ const WIDGET_CONTEXT_SHAPE = {
271
+ props: {
272
+ description: "Resolved property values, shape per manifest.propertySchema.",
273
+ required: true,
274
+ fields: {},
275
+ },
276
+ widget: {
277
+ description:
278
+ "Identity of the currently rendered widget instance ({ id, version }).",
279
+ required: true,
280
+ fields: { id: "manifest.id", version: "manifest.version" },
281
+ },
282
+ user: {
283
+ description: "Signed-in user ({ id, email, displayName }).",
284
+ required: true,
285
+ fields: { id: "string", email: "string", displayName: "string" },
286
+ },
287
+ workspace: {
288
+ description:
289
+ "Workspace identity + resolved theme ({ id, slug, theme, locale }).",
290
+ required: true,
291
+ fields: {
292
+ id: "string",
293
+ slug: "string",
294
+ theme: "ThemeTokens",
295
+ locale: "string",
296
+ },
297
+ },
298
+ navigation: {
299
+ description: "{ push(path), replace(path), back() }.",
300
+ required: true,
301
+ fields: { push: "function", replace: "function", back: "function" },
302
+ },
303
+ datastore: {
304
+ description:
305
+ "{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) } }.",
306
+ required: true,
307
+ fields: { records: "function" },
308
+ },
309
+ events: {
310
+ description: "{ emit(name, payload) }.",
311
+ required: true,
312
+ fields: { emit: "function" },
313
+ },
314
+ i18n: {
315
+ description: "{ t(key, fallback?), locale }.",
316
+ required: true,
317
+ fields: { t: "function", locale: "string" },
318
+ },
319
+ logger: {
320
+ description:
321
+ "{ debug, info, warn, error } — host-routed; never use console.*.",
322
+ required: true,
323
+ fields: {
324
+ debug: "function",
325
+ info: "function",
326
+ warn: "function",
327
+ error: "function",
328
+ },
329
+ },
330
+ };
331
+
332
+ const BUNDLE_EXPORT_CONTRACT = [
333
+ {
334
+ name: "wrapped",
335
+ description:
336
+ "default export = defineWidget({ manifest, component }) → resolves to { manifest, component, _kind: 'widget' }. Public marketplace widgets.",
337
+ predicate:
338
+ "typeof mod.default === 'object' && typeof mod.default.component === 'function' && typeof mod.default.manifest === 'object'",
339
+ manifestSource: "mod.default.manifest",
340
+ audience: "public-marketplace",
341
+ },
342
+ {
343
+ name: "bare-component",
344
+ description:
345
+ "default export = function MyWidget(props) {...} → host pairs it with the installation's pinnedVersion.manifestJson. AI-agent widgets.",
346
+ predicate: "typeof mod.default === 'function'",
347
+ manifestSource: "installation.pinnedVersion.manifestJson",
348
+ audience: "ai-agent",
349
+ },
350
+ ];
351
+
352
+ const BANNED_APIS = [
353
+ { identifier: "eval", reason: "Arbitrary code evaluation." },
354
+ {
355
+ identifier: "Function",
356
+ reason: "Function() constructor evaluates strings.",
357
+ },
358
+ {
359
+ identifier: "new Function",
360
+ reason: "Function() constructor evaluates strings.",
361
+ },
362
+ { identifier: "window", reason: "Host environment escape." },
363
+ {
364
+ identifier: "document",
365
+ reason: "Host environment escape; use SDK primitives.",
366
+ },
367
+ {
368
+ identifier: "process",
369
+ reason: "Node global; unavailable in the browser, banned for parity.",
370
+ },
371
+ {
372
+ identifier: "localStorage",
373
+ reason: "Bypasses host data model; not portable to native.",
374
+ },
375
+ {
376
+ identifier: "sessionStorage",
377
+ reason: "Same reason as localStorage.",
378
+ },
379
+ {
380
+ identifier: "fetch",
381
+ reason: "Direct network calls bypass the datastore client + tenant auth.",
382
+ },
383
+ {
384
+ identifier: "XMLHttpRequest",
385
+ reason: "Direct network calls bypass the datastore client + tenant auth.",
386
+ },
387
+ {
388
+ identifier: "import(",
389
+ reason: "Dynamic import bypasses the loader's allowlist.",
390
+ },
391
+ { identifier: "globalThis", reason: "Host environment escape." },
392
+ ];
393
+
394
+ const ALLOWED_BARE_IMPORTS = ["react", "@colixsystems/widget-sdk"];
395
+
396
+ function deepFreeze(value) {
397
+ if (value === null || typeof value !== "object") return value;
398
+ if (Object.isFrozen(value)) return value;
399
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
400
+ return Object.freeze(value);
401
+ }
402
+
403
+ const CONTRACT = deepFreeze({
404
+ version: "1.0.0",
405
+ hooks: HOOKS,
406
+ primitives: PRIMITIVES,
407
+ manifestSchema: MANIFEST_SCHEMA,
408
+ manifestCategories: CATEGORIES,
409
+ manifestPlatforms: PLATFORMS,
410
+ themeTokens: DEFAULT_THEME_TOKENS,
411
+ widgetContextShape: WIDGET_CONTEXT_SHAPE,
412
+ bundleExportContract: BUNDLE_EXPORT_CONTRACT,
413
+ bannedApis: BANNED_APIS,
414
+ allowedBareImports: ALLOWED_BARE_IMPORTS,
415
+ });
9
416
 
10
- import { createRequire } from "module";
417
+ function isHookAllowed(name) {
418
+ return CONTRACT.hooks.some((h) => h.name === name);
419
+ }
11
420
 
12
- const require = createRequire(import.meta.url);
13
- const cjs = require("./contract.cjs");
421
+ function requiredContextKeys() {
422
+ const keys = new Set();
423
+ for (const hook of CONTRACT.hooks) {
424
+ for (const dotPath of hook.requiredContextSlice) {
425
+ keys.add(dotPath.split(".")[0]);
426
+ }
427
+ }
428
+ return [...keys];
429
+ }
14
430
 
15
- export const CONTRACT = cjs.CONTRACT;
16
- export const isHookAllowed = cjs.isHookAllowed;
17
- export const requiredContextKeys = cjs.requiredContextKeys;
431
+ export { CONTRACT, isHookAllowed, requiredContextKeys };
package/dist/hooks.js CHANGED
@@ -66,7 +66,7 @@ function useWidgetContextOrThrow(hookName) {
66
66
  if (ctx == null) {
67
67
  throw new Error(
68
68
  `${hookName} must be used inside a WidgetContextProvider. ` +
69
- `The host (Studio, Player, or exported app) is responsible for mounting it.`
69
+ `The host (Studio, Player, or exported app) is responsible for mounting it.`,
70
70
  );
71
71
  }
72
72
  return ctx;
@@ -86,12 +86,17 @@ function toDatastoreError(err) {
86
86
  ? err.response.status
87
87
  : null;
88
88
  const bodyMessage =
89
- err && err.response && err.response.data && typeof err.response.data.error === "string"
89
+ err &&
90
+ err.response &&
91
+ err.response.data &&
92
+ typeof err.response.data.error === "string"
90
93
  ? err.response.data.error
91
94
  : null;
92
95
  const fallbackMessage =
93
96
  bodyMessage ||
94
- (err && typeof err.message === "string" ? err.message : "Datastore call failed");
97
+ (err && typeof err.message === "string"
98
+ ? err.message
99
+ : "Datastore call failed");
95
100
  let code = "INTERNAL";
96
101
  if (status === 400 || status === 422) code = "VALIDATION";
97
102
  else if (status === 409) code = "CONSTRAINT_VIOLATION";
@@ -100,7 +105,12 @@ function toDatastoreError(err) {
100
105
  // Surface a structured fieldErrors map when the server emitted one
101
106
  // (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
102
107
  let fieldErrors;
103
- if (err && err.response && err.response.data && Array.isArray(err.response.data.errors)) {
108
+ if (
109
+ err &&
110
+ err.response &&
111
+ err.response.data &&
112
+ Array.isArray(err.response.data.errors)
113
+ ) {
104
114
  const map = {};
105
115
  for (const entry of err.response.data.errors) {
106
116
  if (entry && typeof entry.field === "string") {
@@ -132,7 +142,9 @@ function toDatastoreError(err) {
132
142
  export function useDatastoreQuery(table, query) {
133
143
  const ctx = useWidgetContextOrThrow("useDatastoreQuery");
134
144
  if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
135
- throw new Error("useDatastoreQuery: host did not inject a datastore client");
145
+ throw new Error(
146
+ "useDatastoreQuery: host did not inject a datastore client",
147
+ );
136
148
  }
137
149
  const [data, setData] = useState([]);
138
150
  const [loading, setLoading] = useState(Boolean(table));
@@ -216,7 +228,9 @@ export function useDatastoreQuery(table, query) {
216
228
  export function useDatastoreMutation(table) {
217
229
  const ctx = useWidgetContextOrThrow("useDatastoreMutation");
218
230
  if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
219
- throw new Error("useDatastoreMutation: host did not inject a datastore client");
231
+ throw new Error(
232
+ "useDatastoreMutation: host did not inject a datastore client",
233
+ );
220
234
  }
221
235
  const ns = ctx.datastore.records(table);
222
236
  return {
@@ -292,7 +306,7 @@ export function useI18n() {
292
306
  }
293
307
  return typeof fallback === "string" ? fallback : key;
294
308
  },
295
- [hostT]
309
+ [hostT],
296
310
  );
297
311
  return { t, locale };
298
312
  }
package/dist/index.d.ts CHANGED
@@ -42,7 +42,11 @@ export interface WidgetPropertyDef {
42
42
  enum?: Array<{ value: unknown; label: string }>;
43
43
  items?: WidgetPropertyDef;
44
44
  properties?: Record<string, WidgetPropertyDef>;
45
- ui?: { widget?: "textarea" | "slider" | "code"; group?: string; order?: number };
45
+ ui?: {
46
+ widget?: "textarea" | "slider" | "code";
47
+ group?: string;
48
+ order?: number;
49
+ };
46
50
  validation?: { min?: number; max?: number; pattern?: string };
47
51
  }
48
52
 
@@ -181,7 +185,10 @@ export interface WidgetContext<TProps = unknown> {
181
185
  };
182
186
  datastore: unknown; // typed by @colixsystems/datastore-client
183
187
  events: { emit(eventName: string, payload?: unknown): void };
184
- i18n: { locale: string; t(key: string, vars?: Record<string, unknown>): string };
188
+ i18n: {
189
+ locale: string;
190
+ t(key: string, vars?: Record<string, unknown>): string;
191
+ };
185
192
  platform: "web" | "native";
186
193
  logger: {
187
194
  debug: (...args: unknown[]) => void;
@@ -226,16 +233,16 @@ export function defineWidget<TProps = Record<string, unknown>>(opts: {
226
233
  }): WidgetModule<TProps>;
227
234
 
228
235
  export function validateManifest(
229
- manifest: unknown
236
+ manifest: unknown,
230
237
  ): { ok: true } | { ok: false; errors: string[] };
231
238
 
232
239
  export function validatePropertySchema(
233
- schema: unknown
240
+ schema: unknown,
234
241
  ): { ok: true } | { ok: false; errors: string[] };
235
242
 
236
243
  export function validateProps<T = Record<string, unknown>>(
237
244
  schema: WidgetPropertySchema,
238
- props: unknown
245
+ props: unknown,
239
246
  ): { ok: true; value: T } | { ok: false; errors: string[] };
240
247
 
241
248
  export interface Query {
@@ -260,16 +267,14 @@ export interface MutationApi<T> {
260
267
 
261
268
  export function useDatastoreQuery<T = unknown>(
262
269
  table: string,
263
- query?: Query
270
+ query?: Query,
264
271
  ): QueryResult<T>;
265
272
 
266
273
  export function useDatastoreMutation<T = unknown>(
267
- table: string
274
+ table: string,
268
275
  ): MutationApi<T>;
269
276
 
270
- export function useWidgetEvent(
271
- name: string
272
- ): (payload?: unknown) => void;
277
+ export function useWidgetEvent(name: string): (payload?: unknown) => void;
273
278
 
274
279
  export function useTheme(): ThemeTokens;
275
280
 
@@ -291,10 +296,14 @@ export class DatastoreError extends Error {
291
296
  | "NOT_FOUND"
292
297
  | "INTERNAL";
293
298
  fieldErrors?: Record<string, string>;
294
- constructor(code: DatastoreError["code"], message: string, opts?: {
295
- fieldErrors?: Record<string, string>;
296
- cause?: unknown;
297
- });
299
+ constructor(
300
+ code: DatastoreError["code"],
301
+ message: string,
302
+ opts?: {
303
+ fieldErrors?: Record<string, string>;
304
+ cause?: unknown;
305
+ },
306
+ );
298
307
  }
299
308
 
300
309
  export function WidgetContextProvider(props: {
@@ -302,12 +311,21 @@ export function WidgetContextProvider(props: {
302
311
  children?: ReactNode;
303
312
  }): JSX.Element;
304
313
 
305
- // Primitives — platform-aware. Type as opaque components.
314
+ // Primitives — re-exported from `react-native` (web build aliases to
315
+ // `react-native-web`). Typed as opaque components here; widget authors
316
+ // targeting TypeScript should install `@types/react-native` for full
317
+ // prop typing.
306
318
  export const Text: any;
307
319
  export const View: any;
308
320
  export const Pressable: any;
309
321
  export const Image: any;
310
322
  export const ScrollView: any;
323
+ export const TextInput: any;
324
+ export const FlatList: any;
325
+ export const SectionList: any;
326
+ export const ActivityIndicator: any;
327
+ export const Switch: any;
328
+ export const StyleSheet: any;
311
329
 
312
330
  // Linter
313
331
  export interface LintFinding {
@@ -375,7 +393,14 @@ export interface AiWidgetContract {
375
393
  readonly manifestPlatforms: ReadonlyArray<string>;
376
394
  readonly themeTokens: ThemeTokens;
377
395
  readonly widgetContextShape: Readonly<
378
- Record<string, { description: string; required: boolean; fields: Record<string, unknown> }>
396
+ Record<
397
+ string,
398
+ {
399
+ description: string;
400
+ required: boolean;
401
+ fields: Record<string, unknown>;
402
+ }
403
+ >
379
404
  >;
380
405
  readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
381
406
  readonly bannedApis: ReadonlyArray<ContractBannedApi>;
package/dist/index.js CHANGED
@@ -14,6 +14,18 @@ export {
14
14
  useTheme,
15
15
  useI18n,
16
16
  } from "./hooks.js";
17
- export { Text, View, Pressable, Image, ScrollView } from "./primitives.js";
17
+ export {
18
+ Text,
19
+ View,
20
+ Pressable,
21
+ Image,
22
+ ScrollView,
23
+ TextInput,
24
+ FlatList,
25
+ SectionList,
26
+ ActivityIndicator,
27
+ Switch,
28
+ StyleSheet,
29
+ } from "./primitives.js";
18
30
  export { lintSource, bannedIdentifiers } from "./linter.js";
19
31
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
@@ -14,6 +14,18 @@ export {
14
14
  useTheme,
15
15
  useI18n,
16
16
  } from "./hooks.js";
17
- export { Text, View, Pressable, Image, ScrollView } from "./primitives.native.js";
17
+ export {
18
+ Text,
19
+ View,
20
+ Pressable,
21
+ Image,
22
+ ScrollView,
23
+ TextInput,
24
+ FlatList,
25
+ SectionList,
26
+ ActivityIndicator,
27
+ Switch,
28
+ StyleSheet,
29
+ } from "./primitives.native.js";
18
30
  export { lintSource, bannedIdentifiers } from "./linter.js";
19
31
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
@@ -0,0 +1,104 @@
1
+ // CommonJS-flavoured copy of the linter so backend services (CJS runtime)
2
+ // can `require()` it directly without dynamic-import gymnastics.
3
+ //
4
+ // The ESM `linter.js` is the source-of-truth. This file MUST mirror its
5
+ // rule set byte-for-byte — the contract test in packages/widget-sdk
6
+ // asserts the two are behaviour-equivalent. When you edit one, edit the
7
+ // other in the same PR. (Pattern matches `contract.cjs` / `contract.js`.)
8
+ //
9
+ // The SDK build script copies both into `dist/`.
10
+
11
+ "use strict";
12
+
13
+ const { CONTRACT } = require("./contract.cjs");
14
+
15
+ function _ruleForIdentifier(identifier, reason) {
16
+ const id = identifier;
17
+ if (id === "Function") {
18
+ return {
19
+ id: "no-function-constructor",
20
+ label: `${reason} (banned: Function() constructor)`,
21
+ pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
22
+ };
23
+ }
24
+ if (id === "new Function") {
25
+ return {
26
+ id: "no-new-function",
27
+ label: `${reason} (banned: new Function())`,
28
+ pattern: /\bnew\s+Function\s*\(/,
29
+ };
30
+ }
31
+ if (id === "import(") {
32
+ return {
33
+ id: "no-dynamic-import",
34
+ label: `${reason} (banned: dynamic import())`,
35
+ pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
36
+ };
37
+ }
38
+ if (id === "eval") {
39
+ return {
40
+ id: "no-eval",
41
+ label: `${reason} (banned: eval)`,
42
+ pattern: /\beval\s*\(/,
43
+ };
44
+ }
45
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ return {
47
+ id: `no-${id.toLowerCase()}`,
48
+ label: `${reason} (banned: ${id})`,
49
+ pattern: new RegExp(`\\b${escaped}\\b`),
50
+ };
51
+ }
52
+
53
+ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
54
+ _ruleForIdentifier(b.identifier, b.reason),
55
+ );
56
+
57
+ const EXTRA_RULES = [
58
+ {
59
+ id: "no-auth-store-import",
60
+ label: "widgets must not import the host's auth store",
61
+ pattern: /useAuthStore/,
62
+ },
63
+ {
64
+ id: "no-axios-import",
65
+ label:
66
+ "widgets must not import axios directly; use the injected datastore client",
67
+ pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
68
+ },
69
+ ];
70
+
71
+ const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
72
+
73
+ function bannedIdentifiers() {
74
+ return CONTRACT.bannedApis.map((b) => b.identifier);
75
+ }
76
+
77
+ function lintSource(source) {
78
+ if (typeof source !== "string") {
79
+ return {
80
+ ok: false,
81
+ findings: [
82
+ { rule: "input", label: "source must be a string", line: 0, snippet: "" },
83
+ ],
84
+ };
85
+ }
86
+ const findings = [];
87
+ const lines = source.split(/\r?\n/);
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const line = lines[i];
90
+ for (const rule of RULES) {
91
+ if (rule.pattern.test(line)) {
92
+ findings.push({
93
+ rule: rule.id,
94
+ label: rule.label,
95
+ line: i + 1,
96
+ snippet: line.trim().slice(0, 200),
97
+ });
98
+ }
99
+ }
100
+ }
101
+ return { ok: findings.length === 0, findings };
102
+ }
103
+
104
+ module.exports = { lintSource, bannedIdentifiers };
package/dist/manifest.js CHANGED
@@ -1,15 +1,144 @@
1
- // Shape validation for WidgetManifest per docs/architecture/widget-marketplace.md §2.1.
2
- // Pure JS no third-party deps. Banned-identifier scanning lives in linter.js.
3
- //
4
- // The implementation lives in `manifest.cjs` so backend services (CJS
5
- // runtime) can `require` the same validator the SDK ships to ESM
6
- // consumers. This file is a thin re-export so ESM imports keep working
7
- // unchanged.
1
+ // CommonJS mirror of manifest.js. Lets backend services (CJS) call
2
+ // `validateManifest(...)` without a dynamic-import detour. The two files
3
+ // share their constants by mutual import via createRequire — manifest.js
4
+ // re-exports the same functions defined here so there is exactly one
5
+ // implementation.
8
6
 
9
- import { createRequire } from "module";
7
+ const VALID_CATEGORIES = new Set([
8
+ "input", "display", "layout", "data", "media", "communication", "custom",
9
+ "INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "CUSTOM",
10
+ ]);
11
+ const VALID_PLATFORMS = new Set(["web", "native"]);
10
12
 
11
- const require = createRequire(import.meta.url);
12
- const cjs = require("./manifest.cjs");
13
+ function canonicalCategory(c) {
14
+ if (typeof c !== "string") return null;
15
+ const upper = c.toUpperCase();
16
+ return [
17
+ "INPUT",
18
+ "DISPLAY",
19
+ "LAYOUT",
20
+ "DATA",
21
+ "MEDIA",
22
+ "COMMUNICATION",
23
+ "CUSTOM",
24
+ ].includes(upper)
25
+ ? upper
26
+ : null;
27
+ }
13
28
 
14
- export const validateManifest = cjs.validateManifest;
15
- export const canonicalCategory = cjs.canonicalCategory;
29
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
30
+ const SEMVER_RANGE_RE = /^[\^~>=<]*\s*\d+\.\d+\.\d+/;
31
+ const ID_RE = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/;
32
+
33
+ function isNonEmptyString(v) {
34
+ return typeof v === "string" && v.length > 0;
35
+ }
36
+
37
+ function pushIf(errors, cond, msg) {
38
+ if (!cond) errors.push(msg);
39
+ }
40
+
41
+ function validateManifest(m) {
42
+ const errors = [];
43
+ if (m === null || typeof m !== "object") {
44
+ return { ok: false, errors: ["manifest must be an object"] };
45
+ }
46
+ const manifest = m;
47
+
48
+ pushIf(errors, isNonEmptyString(manifest.id), "manifest.id must be a non-empty string");
49
+ if (isNonEmptyString(manifest.id)) {
50
+ pushIf(
51
+ errors,
52
+ ID_RE.test(manifest.id),
53
+ "manifest.id must be reverse-DNS, e.g. com.acme.charts.barchart",
54
+ );
55
+ }
56
+
57
+ pushIf(errors, isNonEmptyString(manifest.name), "manifest.name must be a non-empty string");
58
+
59
+ pushIf(errors, isNonEmptyString(manifest.version), "manifest.version must be a non-empty string");
60
+ if (isNonEmptyString(manifest.version)) {
61
+ pushIf(errors, SEMVER_RE.test(manifest.version), "manifest.version must be a valid semver");
62
+ }
63
+
64
+ pushIf(
65
+ errors,
66
+ typeof manifest.category === "string" && VALID_CATEGORIES.has(manifest.category),
67
+ `manifest.category must be one of ${[...VALID_CATEGORIES].join(", ")}`,
68
+ );
69
+
70
+ pushIf(errors, isNonEmptyString(manifest.icon), "manifest.icon must be a non-empty string");
71
+ pushIf(errors, isNonEmptyString(manifest.description), "manifest.description must be a non-empty string");
72
+
73
+ if (manifest.author === null || typeof manifest.author !== "object") {
74
+ errors.push("manifest.author must be an object with a name");
75
+ } else {
76
+ const author = manifest.author;
77
+ pushIf(errors, isNonEmptyString(author.name), "manifest.author.name must be a non-empty string");
78
+ if (author.url !== undefined) {
79
+ pushIf(errors, isNonEmptyString(author.url), "manifest.author.url must be a string");
80
+ }
81
+ if (author.email !== undefined) {
82
+ pushIf(errors, isNonEmptyString(author.email), "manifest.author.email must be a string");
83
+ }
84
+ }
85
+
86
+ if (!Array.isArray(manifest.supportedPlatforms) || manifest.supportedPlatforms.length === 0) {
87
+ errors.push("manifest.supportedPlatforms must be a non-empty array");
88
+ } else {
89
+ for (const p of manifest.supportedPlatforms) {
90
+ if (!VALID_PLATFORMS.has(p)) {
91
+ errors.push(`manifest.supportedPlatforms contains invalid value "${p}"`);
92
+ }
93
+ }
94
+ }
95
+
96
+ pushIf(
97
+ errors,
98
+ isNonEmptyString(manifest.minAppStudioVersion),
99
+ "manifest.minAppStudioVersion must be a non-empty string",
100
+ );
101
+ if (isNonEmptyString(manifest.minAppStudioVersion)) {
102
+ pushIf(
103
+ errors,
104
+ SEMVER_RANGE_RE.test(manifest.minAppStudioVersion),
105
+ "manifest.minAppStudioVersion must be a semver range, e.g. >=2.4.0",
106
+ );
107
+ }
108
+
109
+ if (!Array.isArray(manifest.requestedScopes)) {
110
+ errors.push("manifest.requestedScopes must be an array (use [] for none)");
111
+ } else {
112
+ for (const s of manifest.requestedScopes) {
113
+ if (!isNonEmptyString(s)) {
114
+ errors.push("manifest.requestedScopes entries must be non-empty strings");
115
+ break;
116
+ }
117
+ }
118
+ }
119
+
120
+ if (manifest.propertySchema === null || typeof manifest.propertySchema !== "object") {
121
+ errors.push("manifest.propertySchema must be an object");
122
+ }
123
+
124
+ if (!Array.isArray(manifest.events)) {
125
+ errors.push("manifest.events must be an array (use [] for none)");
126
+ } else {
127
+ for (const e of manifest.events) {
128
+ if (e === null || typeof e !== "object") {
129
+ errors.push("manifest.events entries must be objects");
130
+ break;
131
+ }
132
+ if (!isNonEmptyString(e.name)) {
133
+ errors.push("manifest.events[].name must be a non-empty string");
134
+ }
135
+ }
136
+ }
137
+
138
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
139
+ }
140
+
141
+ export {
142
+ validateManifest,
143
+ canonicalCategory,
144
+ };
@@ -1,51 +1,35 @@
1
- // Web primitive wrappers. Map to plain DOM elements via React.createElement.
2
- // API surface only host CSS / theme tokens drive styling.
3
-
4
- import React from "react";
5
-
6
- const el = React.createElement;
7
-
8
- function asStyle(style) {
9
- return style && typeof style === "object" ? style : undefined;
10
- }
11
-
12
- export function Text({ children, style, ...rest }) {
13
- return el("span", { style: asStyle(style), ...rest }, children);
14
- }
15
-
16
- export function View({ children, style, ...rest }) {
17
- return el("div", { style: asStyle(style), ...rest }, children);
18
- }
19
-
20
- export function Pressable({ children, onPress, style, ...rest }) {
21
- const handle = onPress
22
- ? (ev) => {
23
- ev.preventDefault?.();
24
- onPress(ev);
25
- }
26
- : undefined;
27
- return el(
28
- "button",
29
- {
30
- type: "button",
31
- onClick: handle,
32
- style: asStyle(style),
33
- ...rest,
34
- },
35
- children
36
- );
37
- }
38
-
39
- export function Image({ source, style, alt, ...rest }) {
40
- const src = typeof source === "string" ? source : source?.uri;
41
- return el("img", { src, alt: alt ?? "", style: asStyle(style), ...rest });
42
- }
43
-
44
- export function ScrollView({ children, style, horizontal, ...rest }) {
45
- const merged = {
46
- overflowX: horizontal ? "auto" : "hidden",
47
- overflowY: horizontal ? "hidden" : "auto",
48
- ...(asStyle(style) || {}),
49
- };
50
- return el("div", { style: merged, ...rest }, children);
51
- }
1
+ // REQ-WSDK-RN-WEB: the SDK ships ONE primitive surface. On the web
2
+ // (Studio + Player) the host's bundler aliases `react-native` to
3
+ // `react-native-web` (see `frontend/vite.config.js`), which renders the
4
+ // same React Native API as DOM elements. The exported Expo app's Metro
5
+ // bundler resolves `react-native` to the real library via the SDK
6
+ // package.json `react-native` field — pointing at
7
+ // `primitives.native.js`, which mirrors this file.
8
+ //
9
+ // The previous hand-written DOM wrappers (one function per primitive,
10
+ // two implementations to keep in sync) are gone: the React Native API
11
+ // is the contract, react-native-web does the platform mapping for us,
12
+ // and adding a new primitive (FlatList, ActivityIndicator, ) becomes
13
+ // a one-line re-export rather than a paired implementation.
14
+ //
15
+ // Authors continue to import these from `@colixsystems/widget-sdk` — the
16
+ // SDK's index re-exports them under the names we document in CONTRACT.
17
+ // The static analyzer's allowedBareImports still rejects direct
18
+ // `react-native` imports; the SDK is the single entry point.
19
+
20
+ export {
21
+ Text,
22
+ View,
23
+ Pressable,
24
+ Image,
25
+ ScrollView,
26
+ TextInput,
27
+ // Additional cross-platform primitives the AI agent + handwritten
28
+ // widgets commonly reach for. All work in both react-native-web and
29
+ // native react-native without per-platform code.
30
+ FlatList,
31
+ SectionList,
32
+ ActivityIndicator,
33
+ Switch,
34
+ StyleSheet,
35
+ } from "react-native";
@@ -1,11 +1,21 @@
1
- // React Native primitive wrappers. Metro picks this entry through the
2
- // package.json `react-native` field. `react-native` is an optional peer
3
- // dependency — web-only consumers never import this file.
1
+ // REQ-WSDK-RN-WEB: native primitive re-exports. Metro picks this entry
2
+ // via the `react-native` field in package.json. Web mirror in
3
+ // `./primitives.js` re-exports from the SAME `react-native` module —
4
+ // the frontend's Vite alias rewrites it to `react-native-web` for
5
+ // browser builds, so the two files only differ in which `react-native`
6
+ // resolves at bundle time. Keep the export list in lockstep with
7
+ // `./primitives.js`.
4
8
 
5
- import { Text as RNText, View as RNView, Pressable as RNPressable, Image as RNImage, ScrollView as RNScrollView } from "react-native";
6
-
7
- export const Text = RNText;
8
- export const View = RNView;
9
- export const Pressable = RNPressable;
10
- export const Image = RNImage;
11
- export const ScrollView = RNScrollView;
9
+ export {
10
+ Text,
11
+ View,
12
+ Pressable,
13
+ Image,
14
+ ScrollView,
15
+ TextInput,
16
+ FlatList,
17
+ SectionList,
18
+ ActivityIndicator,
19
+ Switch,
20
+ StyleSheet,
21
+ } from "react-native";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.4.1",
3
+ "version": "0.7.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -51,7 +51,8 @@
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": ">=18.0.0",
54
- "react-native": "*"
54
+ "react-native": "*",
55
+ "react-native-web": ">=0.19.0"
55
56
  },
56
57
  "peerDependenciesMeta": {
57
58
  "react": {
@@ -59,6 +60,9 @@
59
60
  },
60
61
  "react-native": {
61
62
  "optional": true
63
+ },
64
+ "react-native-web": {
65
+ "optional": true
62
66
  }
63
67
  },
64
68
  "keywords": [